// ==UserScript== // @name External links on Trakt // @version 3.4.1 // @description Adds more external links to Trakt.tv pages, including dub information for anime shows. // @author Journey Over // @license MIT // @match *://trakt.tv/* // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/libs/utils/utils.min.js // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/wikidata/wikidata.min.js // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/armhaglund/armhaglund.min.js // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/anilist/anilist.min.js // @require https://cdn.jsdelivr.net/npm/node-creation-observer@1.2.0/release/node-creation-observer-latest.min.js // @require https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js // @grant GM_deleteValue // @grant GM_getValue // @grant GM_listValues // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_info // @run-at document-start // @inject-into content // @icon https://www.google.com/s2/favicons?sz=64&domain=trakt.tv // @homepageURL https://github.com/StylusThemes/Userscripts // @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/external-links-on-trakt.user.js // @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/external-links-on-trakt.user.js // ==/UserScript== (function() { 'use strict'; const logger = Logger('External links on Trakt', { debug: false }); const CONSTANTS = { CACHE_DURATION: 24 * 60 * 60 * 1000, SCRIPT_ID: GM_info.script.name.toLowerCase().replace(/\s/g, '-'), CONFIG_KEY: 'external-trakt-links-config', TITLE: `${GM_info.script.name} Settings`, DUB_LANGUAGE_KEY: 'Dub Language', METADATA_SITES: [ { name: 'Rotten Tomatoes', desc: 'Provides a direct link to Rotten Tomatoes for the selected title.' }, { name: 'Metacritic', desc: 'Provides a direct link to Metacritic for the selected title.' }, { name: 'Letterboxd', desc: 'Provides a direct link to Letterboxd for the selected title.' }, { name: 'TVmaze', desc: 'Provides a direct link to TVmaze for the selected title.' }, { name: 'Mediux', desc: 'Provides a direct link to the Mediux Poster site for the selected title.' }, { name: 'MyAnimeList', desc: 'Provides a direct link to MyAnimeList for the selected title.' }, { name: 'AniDB', desc: 'Provides a direct link to AniDB for the selected title.' }, { name: 'AniList', desc: 'Provides a direct link to AniList for the selected title.' }, { name: 'Kitsu', desc: 'Provides a direct link to Kitsu for the selected title.' }, { name: 'AniSearch', desc: 'Provides a direct link to AniSearch for the selected title.' }, { name: 'LiveChart', desc: 'Provides a direct link to LiveChart for the selected title.' } ], STREAMING_SITES: [ { name: 'BrocoFlix', desc: 'Provides a direct link to the BrocoFlix streaming page for the selected title.' }, { name: 'Cineby', desc: 'Provides a direct link to the Cineby streaming page for the selected title.' }, { name: 'Moviemaze', desc: 'Provides a direct link to the Moviemaze streaming page for the selected title.' }, { name: 'P-Stream', desc: 'Provides a direct link to the P-Stream streaming page for the selected title.' }, { name: 'Rive', desc: 'Provides a direct link to the Rive streaming page for the selected title.' }, { name: 'Wovie', desc: 'Provides a direct link to the Wovie streaming page for the selected title.' }, { name: 'XPrime', desc: 'Provides a direct link to the XPrime streaming page for the selected title.' } ], DUB_INFO: { name: 'Dub Information', desc: 'Show dub information for anime shows.' }, DUB_LANGUAGES: [ { name: 'English', value: 'ENGLISH' }, { name: 'German', value: 'GERMAN' }, { name: 'Italian', value: 'ITALIAN' }, { name: 'Spanish', value: 'SPANISH' }, { name: 'French', value: 'FRENCH' }, { name: 'Korean', value: 'KOREAN' }, { name: 'Portuguese', value: 'PORTUGUESE' }, { name: 'Hebrew', value: 'HEBREW' }, { name: 'Hungarian', value: 'HUNGARIAN' }, { name: 'Chinese', value: 'CHINESE' }, { name: 'Arabic', value: 'ARABIC' }, { name: 'Filipino', value: 'FILIPINO' }, { name: 'Catalan', value: 'CATALAN' }, { name: 'Polish', value: 'POLISH' }, { name: 'Norwegian', value: 'NORWEGIAN' } ], LINK_ORDER: [ 'Official Site', 'IMDb', 'TMDB', 'TVDB', 'Rotten Tomatoes', 'Metacritic', 'Letterboxd', 'TVmaze', 'MyAnimeList', 'AniDB', 'AniList', 'Kitsu', 'AniSearch', 'LiveChart', 'Fanart.tv', 'Mediux', 'BrocoFlix', 'Cineby', 'Moviemaze', 'P-Stream', 'Rive', 'Wovie', 'XPrime', 'JustWatch', 'Wikipedia', 'Twitter', 'Facebook', 'Instagram' ] }; const DEFAULT_CONFIG = Object.fromEntries([ ...CONSTANTS.METADATA_SITES.map(site => [site.name, true]), ...CONSTANTS.STREAMING_SITES.map(site => [site.name, true]), [CONSTANTS.DUB_INFO.name, true], [CONSTANTS.DUB_LANGUAGE_KEY, 'ENGLISH'] ]); class TraktExternalLinks { constructor() { this.config = { ...DEFAULT_CONFIG }; this.mediaInfo = null; this.wikidata = null; this.armhaglund = null; this.anilist = null; } async init() { await this.loadConfig(); this.initializeAPIs(); this.setupEventListeners(); } async loadConfig() { const savedConfig = GM_getValue(CONSTANTS.CONFIG_KEY); if (savedConfig) { this.config = { ...DEFAULT_CONFIG, ...savedConfig }; } } initializeAPIs() { this.wikidata = new Wikidata(); this.armhaglund = new ArmHaglund(); this.anilist = new AniList(); } setupEventListeners() { NodeCreationObserver.onCreation('.sidebar .external', () => this.handleExternalLinks()); NodeCreationObserver.onCreation('body', () => this.addSettingsMenu()); NodeCreationObserver.onCreation('.text.readmore', () => this.handleCollectionLinks()); } // Extract media information from URL path and existing external links getMediaInfo() { const pathParts = location.pathname.split('/'); const type = pathParts[1] === 'movies' ? 'movie' : 'tv'; const imdbHref = $('#external-link-imdb').attr('href') || ''; const imdbId = imdbHref.match(/tt\d+/)?.[0] || null; const tmdbHref = $('#external-link-tmdb').attr('href') || ''; const tmdbMatch = tmdbHref.match(/\/(movie|tv)\/(\d+)/); const tmdbId = tmdbMatch?.[2] || null; const slug = pathParts[2] || ''; const title = slug .split('-') .slice(1) .join('-') .replace(/-\d{4}$/, ''); const seasonIndex = pathParts.indexOf('seasons'); const episodeIndex = pathParts.indexOf('episodes'); const season = seasonIndex > 0 ? +pathParts[seasonIndex + 1] : null; const episode = episodeIndex > 0 ? +pathParts[episodeIndex + 1] : null; return { type, imdbId, tmdbId, title, season: season || '1', episode: episode || '1', isSeasonPage: !!season && !episode }; } async handleExternalLinks() { try { await this.clearExpiredCache(); this.mediaInfo = this.getMediaInfo(); if (this.mediaInfo.imdbId) { await this.processWikidataLinks(); } if (this.mediaInfo.tmdbId || this.mediaInfo.imdbId) { this.addCustomLinks(); } this.sortLinks(); if (this.mediaInfo.anilistId) { this.addDubInfo(); } } catch (error) { logger.error(`Failed handling external links: ${error.message}`); } } // Sort links according to predefined order, keeping unknown links at the end sortLinks() { const container = $('.sidebar .external'); const listItem = container.find('li').first(); const links = listItem.children('a').detach(); const getKey = element => { const $element = $(element); const key = $element.data('site') || $element.data('original-title') || $element.text().trim(); return key.toLowerCase(); }; const orderMap = new Map( CONSTANTS.LINK_ORDER.map((name, index) => [name.toLowerCase(), index]) ); const sorted = links.toArray().sort((a, b) => { const aKey = getKey(a); const bKey = getKey(b); const aOrder = orderMap.get(aKey) ?? Infinity; const bOrder = orderMap.get(bKey) ?? Infinity; return aOrder - bOrder; }); listItem.append(sorted); } createLink(name, url) { const id = `external-link-${name.toLowerCase().replace(/\s/g, '-')}`; if (document.getElementById(id)) return; const linkElement = `${name}`; $('.sidebar .external li').append(linkElement); logger.debug(`Added link: ${name} -> ${url}`); } // Fetch Wikidata links with fallback to ArmHaglund for missing anime IDs async processWikidataLinks() { const cache = GM_getValue(this.mediaInfo.imdbId); if (this.isCacheValid(cache)) { this.addWikidataLinks(cache.links); this.mediaInfo.anilistId = cache.links.AniList?.value.match(/\/anime\/(\d+)/)?.[1]; return; } try { let data = await this.wikidata.links(this.mediaInfo.imdbId, 'IMDb', this.mediaInfo.type); // ArmHaglund provides better anime ID coverage than Wikidata if (this.needsExtraIds(data.links)) { await this.fetchExtraIds(data); } const hasMeaningfulData = Object.keys(data.links).length > 0 || data.item; if (hasMeaningfulData) { GM_setValue(this.mediaInfo.imdbId, { links: data.links, item: data.item, time: Date.now() }); this.addWikidataLinks(data.links); this.mediaInfo.anilistId = data.links.AniList?.value.match(/\/anime\/(\d+)/)?.[1]; logger.debug(`Fetched new Wikidata links: ${JSON.stringify(data.links)}`); } } catch (error) { logger.error(`Failed fetching Wikidata links: ${error.message}`); // Don't create empty cache entries on failure } } // Check if we're missing key anime database links that ArmHaglund can provide needsExtraIds(links) { const required = ['MyAnimeList', 'AniDB', 'AniList', 'Kitsu', 'AniSearch', 'LiveChart']; return required.some(site => !links[site]); } async fetchExtraIds(data) { try { const extensionData = await this.armhaglund.fetchIds('imdb', this.mediaInfo.imdbId); if (extensionData) { this.mergeExtraIds(data.links, extensionData); } } catch (extensionError) { logger.debug(`Failed to fetch from Arm Haglund: ${extensionError.message}`); } } // Map ArmHaglund API response keys to Wikidata link format and URLs mergeExtraIds(links, extensionData) { const URL_MAPPINGS = { themoviedb: (id) => `https://www.themoviedb.org/${this.mediaInfo.type === 'movie' ? 'movie' : 'tv'}/${id}`, thetvdb: (id) => `https://thetvdb.com/dereferrer/${this.mediaInfo.type === 'movie' ? 'movie' : 'series'}/${id}`, imdb: (id) => `https://www.imdb.com/title/${id}`, myanimelist: (id) => `https://myanimelist.net/anime/${id}`, anidb: (id) => `https://anidb.net/anime/${id}`, anilist: (id) => `https://anilist.co/anime/${id}`, kitsu: (id) => `https://kitsu.app/anime/${id}`, anisearch: (id) => `https://www.anisearch.com/anime/${id}`, livechart: (id) => `https://www.livechart.me/anime/${id}` }; const LINK_MAPPINGS = { themoviedb: 'TMDB', thetvdb: 'TVDB', imdb: 'IMDb', myanimelist: 'MyAnimeList', anidb: 'AniDB', anilist: 'AniList', kitsu: 'Kitsu', anisearch: 'AniSearch', livechart: 'LiveChart' }; for (const [apiKey, linkKey] of Object.entries(LINK_MAPPINGS)) { if (!links[linkKey] && extensionData[apiKey]) { links[linkKey] = { value: URL_MAPPINGS[apiKey](extensionData[apiKey]) }; } } } addWikidataLinks(links) { const animeSites = new Set(['MyAnimeList', 'AniDB', 'AniList', 'Kitsu', 'AniSearch', 'LiveChart']); for (const [site, link] of Object.entries(links)) { if ( site !== 'Trakt' && link?.value && this.config[site] !== false && !this.linkExists(site) && // Don't show anime sites on season pages (they're show-level only for now) !(this.mediaInfo.isSeasonPage && animeSites.has(site)) ) { this.createLink(site, link.value); } } } // Query AniList for dub information using voice actor language filtering async queryAnilist(id) { const query = ` query($id: Int!, $type: MediaType, $page: Int = 1, $language: StaffLanguage){ Media(id: $id, type: $type){ characters(page: $page, sort: [ROLE], role: MAIN){ edges { node{id} voiceActors(language: $language){language} } } } } `; const response = await this.anilist.query(query, { id: parseInt(id), type: 'ANIME', language: this.config[CONSTANTS.DUB_LANGUAGE_KEY] }); return response.data.Media.characters.edges; } addDubInfo() { if (!this.config['Dub Information'] || !this.mediaInfo?.anilistId) return; if (!$('.sidebar .poster').length) return; const cacheKey = this.mediaInfo.imdbId; const selectedLanguage = this.config['Dub Language']; const cache = GM_getValue(cacheKey); if (cache?.dubStatus?.[selectedLanguage] !== undefined) { this.displayDubInfo(cache.dubStatus[selectedLanguage]); return; } this.queryAnilist(this.mediaInfo.anilistId) .then(edges => { // Check if any main characters have voice actors in the selected language const hasDub = edges.some(edge => edge.voiceActors?.length > 0); const updatedCache = { ...cache, dubStatus: { ...cache?.dubStatus, [selectedLanguage]: hasDub } }; GM_setValue(cacheKey, updatedCache); this.displayDubInfo(hasDub); }) .catch(error => { logger.error(`Failed fetching AniList dub info: ${error.message}`); // Cache the failure to avoid repeated API calls const cache = GM_getValue(cacheKey); const updatedCache = { ...cache, dubStatus: { ...cache?.dubStatus, [selectedLanguage]: false } }; GM_setValue(cacheKey, updatedCache); }); } displayDubInfo(hasDub) { if (!hasDub) return; const selectedLang = CONSTANTS.DUB_LANGUAGES.find( lang => lang.value === this.config['Dub Language'] ); const langName = selectedLang?.name || 'Dub'; const container = $('.sidebar .btn-watch-now'); if (!container.length || $('.dubbed-info').length) return; const dubbedInfoHtml = `