// ==UserScript==
// @name External links on Trakt
// @version 3.2.3
// @description Adds more external links to Trakt.tv pages.
// @author Journey Over
// @license MIT
// @match *://trakt.tv/*
// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/gm/gmcompat.js
// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/wikidata/index.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/release/node-creation-observer-latest.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js
// @grant GM.deleteValue
// @grant GM.getValue
// @grant GM.listValues
// @grant GM.setValue
// @grant GM.xmlHttpRequest
// @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
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==
/* global $, NodeCreationObserver, Wikidata */
(function() {
'use strict';
// ==============================
// Constants and Configuration
// ==============================
const CONSTANTS = {
CACHE_DURATION: 36e5, // 1 hour in milliseconds
SCRIPT_ID: GM.info.script.name.toLowerCase().replace(/\s/g, '-'),
CONFIG_KEY: 'external-trakt-links-config',
TITLE: `${GM.info.script.name} Settings`,
SCRIPT_NAME: GM.info.script.name,
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.'
},
],
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'
]
};
// Default configuration values
const DEFAULT_CONFIG = Object.fromEntries([
['logging', false],
['debugging', false],
...CONSTANTS.METADATA_SITES.map(site => [site.name, true]),
...CONSTANTS.STREAMING_SITES.map(site => [site.name, true])
]);
// ======================
// Core Functionality
// ======================
class TraktExternalLinks {
constructor() {
// Initialize with default configuration
this.config = {
...DEFAULT_CONFIG
};
this.wikidata = null; // Wikidata API instance
this.mediaInfo = null; // Current media item metadata
this.linkSettings = [ // All supported link settings
...CONSTANTS.METADATA_SITES,
...CONSTANTS.STREAMING_SITES
];
}
// ======================
// Logging Methods
// ======================
info(message, ...args) {
if (this.config.logging) {
console.info(`${CONSTANTS.SCRIPT_NAME}: INFO: ${message}`, ...args);
}
}
warn(message, ...args) {
if (this.config.logging) {
console.warn(`${CONSTANTS.SCRIPT_NAME}: WARN: ${message}`, ...args);
}
}
error(message, ...args) {
if (this.config.logging) {
console.error(`${CONSTANTS.SCRIPT_NAME}: ERROR: ${message}`, ...args);
}
}
debug(message, ...args) {
if (this.config.debugging) {
console.debug(`${CONSTANTS.SCRIPT_NAME}: DEBUG: ${message}`, ...args);
}
}
// ======================
// Initialization
// ======================
async init() {
// Main initialization sequence
await this.loadConfig();
this.initializeWikidata();
this.logInitialization();
this.setupEventListeners();
}
logInitialization() {
const {
version,
author
} = GM.info.script;
const headerStyle = 'color:red;font-weight:bold;font-size:18px;';
const versionText = version ? `v${version} ` : '';
console.log(
`%c${CONSTANTS.SCRIPT_NAME}\n%c${versionText}by ${author} is running!`,
headerStyle,
''
);
this.info('Script initialized');
this.debug('Debugging mode enabled');
this.debug('Current configuration:', this.config);
}
async loadConfig() {
// Load saved configuration from storage
const savedConfig = await GMC.getValue(CONSTANTS.CONFIG_KEY);
if (savedConfig) {
this.config = {
...DEFAULT_CONFIG,
...savedConfig
};
}
}
initializeWikidata() {
// Initialize Wikidata API with debugging option
this.wikidata = new Wikidata({
debug: this.config.debugging
});
}
// ======================
// Event Handling
// ======================
setupEventListeners() {
// Watch for external links container and body element creation
NodeCreationObserver.onCreation('.sidebar .external', () => this.handleExternalLinks());
NodeCreationObserver.onCreation('body', () => this.addSettingsMenu());
// Watch for collection links in list descriptions on collection pages
NodeCreationObserver.onCreation('.text.readmore', () => this.handleCollectionLinks());
}
// ======================
// Media Info
// ======================
getMediaInfo() {
// Extract media metadata from URL and DOM elements
const pathParts = location.pathname.split('/');
const type = pathParts[1] === 'movies' ? 'movie' : 'tv';
// Safely get IDs from existing external links
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 ? tmdbMatch[2] : null;
// Extract title from URL slug
const slug = pathParts[2] || '';
const title = slug.split('-')
.slice(1) // Remove any leading ID
.join('-')
.replace(/-\d{4}$/, ''); // Remove year suffix
// Parse season/episode structure
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
};
}
// ======================
// Link Management
// ======================
async handleExternalLinks() {
// Main link processing pipeline
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();
} catch (error) {
this.error(`Failed handling external links: ${error.message}`);
}
}
sortLinks() {
const container = $('.sidebar .external');
const listItem = container.find('li').first();
const links = listItem.children('a').detach();
const orderMap = new Map(CONSTANTS.LINK_ORDER.map((name, i) => [name.toLowerCase(), i]));
const sorted = links.toArray().sort((a, b) => {
const getKey = el => {
const $el = $(el);
// Check data-site first, then data-original-title, then text
return $el.data('site') ||
$el.data('original-title') ||
$el.text().trim();
};
// Normalize the key for comparison
const aKey = getKey(a).toLowerCase();
const bKey = getKey(b).toLowerCase();
return (orderMap.get(aKey) ?? Infinity) - (orderMap.get(bKey) ?? Infinity);
});
listItem.append(sorted);
}
createLink(name, url) {
// Create new external link element if it doesn't exist
const id = `external-link-${name.toLowerCase().replace(/\s/g, '-')}`;
if (!document.getElementById(id)) {
$('.sidebar .external li').append(
`<a target="_blank" id="${id}" href="${url}" data-original-title="" title="">${name}</a>`
);
this.debug(`Added ${name} link: ${url}`);
}
}
// ======================
// Wikidata Integration
// ======================
async processWikidataLinks() {
// Handle Wikidata links with caching
const cache = await GMC.getValue(this.mediaInfo.imdbId);
if (this.isCacheValid(cache)) {
this.debug('Using cached Wikidata data');
this.addWikidataLinks(cache.links);
return;
}
try {
// Fetch fresh data from Wikidata API
const data = await this.wikidata.links(this.mediaInfo.imdbId, 'IMDb', this.mediaInfo.type);
await GMC.setValue(this.mediaInfo.imdbId, {
links: data.links,
item: data.item,
time: Date.now()
});
this.addWikidataLinks(data.links);
this.debug('New Wikidata data fetched:', data.item);
} catch (error) {
this.error(`Failed fetching Wikidata links: ${error.message}`);
}
}
addWikidataLinks(links) {
// Add links from Wikidata data, filtering out anime sites for season pages
const animeSites = new Set(['MyAnimeList', 'AniDB', 'AniList', 'Kitsu', 'AniSearch', 'LiveChart']);
Object.entries(links).forEach(([site, link]) => {
if (
site !== 'Trakt' &&
link?.value &&
this.config[site] !== false &&
!this.linkExists(site) &&
!(this.mediaInfo.isSeasonPage && animeSites.has(site))
) {
this.createLink(site, link.value);
}
});
}
// ======================
// Custom Link Builders
// ======================
addCustomLinks() {
// Define custom link templates and conditions
const customLinks = [{
name: 'Letterboxd',
url: () => `https://letterboxd.com/tmdb/${this.mediaInfo.tmdbId}`,
condition: () => this.mediaInfo.type === 'movie',
requiredData: 'tmdbId'
},
{
name: 'Mediux',
url: () => {
const path = this.mediaInfo.type === 'movie' ? 'movies' : 'shows';
return `https://mediux.pro/${path}/${this.mediaInfo.tmdbId}`;
},
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'BrocoFlix',
url: () => `https://brocoflix.com/pages/info?id=${this.mediaInfo.tmdbId}&type=${this.mediaInfo.type}`,
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'Cineby',
url: () => {
const show = this.mediaInfo.type === 'tv' ? `/${this.mediaInfo.season}/${this.mediaInfo.episode}` : '';
return `https://www.cineby.app/${this.mediaInfo.type}/${this.mediaInfo.tmdbId}${show}`;
},
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'Moviemaze',
url: () => {
const show = this.mediaInfo.type === 'tv' ? `?season=${this.mediaInfo.season}&ep=${this.mediaInfo.episode}` : '';
return `https://moviemaze.cc/watch/${this.mediaInfo.type}/${this.mediaInfo.tmdbId}${show}`;
},
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'P-Stream',
url: () => {
const show = this.mediaInfo.type === 'tv' ? `/${this.mediaInfo.season}/${this.mediaInfo.episode}` : '';
return `https://iframe.pstream.mov/embed/tmdb-${this.mediaInfo.type}-${this.mediaInfo.tmdbId}${show}`;
},
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'Rive',
url: () => {
const show = this.mediaInfo.type === 'tv' ? `&season=${this.mediaInfo.season}&episode=${this.mediaInfo.episode}` : '';
return `https://rivestream.org/watch?type=${this.mediaInfo.type}&id=${this.mediaInfo.tmdbId}${show}`;
},
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'Wovie',
url: () => {
const show = this.mediaInfo.type === 'tv' ? `?season=${this.mediaInfo.season}&episode=${this.mediaInfo.episode}` : '';
return `https://wovie.vercel.app/play/${this.mediaInfo.type}/${this.mediaInfo.tmdbId}/${this.mediaInfo.title}${show}`;
},
condition: () => true,
requiredData: 'tmdbId'
},
{
name: 'XPrime',
url: () => {
const show = this.mediaInfo.type === 'tv' ? `/${this.mediaInfo.season}/${this.mediaInfo.episode}` : '';
return `https://xprime.tv/watch/${this.mediaInfo.tmdbId}${show}`;
},
condition: () => true,
requiredData: 'tmdbId'
}
];
customLinks.forEach(linkConfig => {
if (
this.config[linkConfig.name] !== false &&
!this.linkExists(linkConfig.name) &&
this.mediaInfo[linkConfig.requiredData] &&
linkConfig.condition()
) {
this.createLink(linkConfig.name, linkConfig.url());
}
});
}
// ======================
// Collection Link Handling
// ======================
handleCollectionLinks() {
if (!this.config.Mediux) return;
const tmdbCollectionLinks = $('.text.readmore a[href*="themoviedb.org/collection/"]');
tmdbCollectionLinks.each((index, element) => {
const $tmdbLink = $(element);
const tmdbUrl = $tmdbLink.attr('href');
const collectionId = tmdbUrl.match(/collection\/(\d+)/)?.[1];
if (collectionId) {
const mediuxUrl = `https://mediux.pro/collections/${collectionId}`;
const mediuxLink = `<p><a href="${mediuxUrl}" target="_blank" class="comment-link">Mediux Collection</a></p>`;
if (!$tmdbLink.next(`a[href="${mediuxUrl}"]`).length) {
$tmdbLink.after(`${mediuxLink}`);
}
}
});
}
// ======================
// Cache Management
// ======================
isCacheValid(cache) {
return cache &&
!this.config.debugging &&
(Date.now() - cache.time) < CONSTANTS.CACHE_DURATION;
}
linkExists(site) {
return $(`#external-link-${site.toLowerCase().replace(/\s/g, '-')}`).length > 0;
}
async clearExpiredCache() {
// Clear expired cache entries
const values = await GMC.listValues();
for (const value of values) {
if (value === CONSTANTS.CONFIG_KEY) continue;
const cache = await GMC.getValue(value);
if (cache?.time && (Date.now() - cache.time) > CONSTANTS.CACHE_DURATION) {
await GMC.deleteValue(value);
}
}
}
// ======================
// Settings UI
// ======================
addSettingsMenu() {
// Add settings menu item to user navigation
const menuItem = `<li class="${CONSTANTS.SCRIPT_ID}"><a href="javascript:void(0)">EL Settings</a></li>`;
$('div.user-wrapper ul.menu li.divider').last().after(menuItem);
$(`.${CONSTANTS.SCRIPT_ID}`).click(() => this.openSettingsModal());
}
openSettingsModal() {
const modalHTML = this.generateSettingsModalHTML();
$(modalHTML).appendTo('body');
this.addModalStyles();
this.setupModalEventListeners();
}
generateSettingsModalHTML() {
const generateSection = (title, sites) => `
<div class="settings-section">
<h3><i class="fas fa-link"></i> ${title}</h3>
<div class="link-settings-grid">
${sites.map(site => {
const id = site.name.toLowerCase().replace(/\s+/g, '_');
return `
<div class="setting-item">
<div class="setting-info">
<label for="${id}" title="${site.desc}">${site.name}</label>
</div>
<label class="switch">
<input type="checkbox" id="${id}" ${this.config[site.name] ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
`;
}).join('')}
</div>
</div>
`;
return `
<div id="${CONSTANTS.SCRIPT_ID}-config">
<div class="modal-content">
<div class="modal-header">
<h2>${CONSTANTS.TITLE}</h2>
<button class="close-button">×</button>
</div>
<div class="settings-sections">
<div class="settings-section">
<h3><i class="fas fa-cog"></i> General Settings</h3>
<div class="setting-item">
<div class="setting-info">
<label for="logging">Enable Logging</label>
<div class="description">Show basic logs (info, warnings, errors) in console</div>
</div>
<label class="switch">
<input type="checkbox" id="logging" ${this.config.logging ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<label for="debugging">Enable Debugging</label>
<div class="description">Show detailed debug information in console</div>
</div>
<label class="switch">
<input type="checkbox" id="debugging" ${this.config.debugging ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</div>
${generateSection('Metadata Sites', CONSTANTS.METADATA_SITES)}
${generateSection('Streaming Sites', CONSTANTS.STREAMING_SITES)}
</div>
<div class="modal-footer">
<button class="btn save" id="save-config">Save & Reload</button>
<button class="btn warning" id="clear-cache">Clear Cache</button>
</div>
</div>
</div>
`;
}
addModalStyles() {
const styles = `#${CONSTANTS.SCRIPT_ID}-config{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;justify-content:center;align-items:center}#${CONSTANTS.SCRIPT_ID}-config .modal-content{background:#2b2b2b;color:#fff;border-radius:8px;width:450px;max-width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3)}#${CONSTANTS.SCRIPT_ID}-config .modal-header{padding:1.5rem;border-bottom:1px solid #404040;position:relative;display:flex;justify-content:space-between;align-items:center}#${CONSTANTS.SCRIPT_ID}-config .modal-header h2{margin:0;font-size:1.4rem;color:#fff}#${CONSTANTS.SCRIPT_ID}-config .close-button{background:0;border:0;color:#fff;font-size:1.5rem;cursor:pointer;padding:0 .5rem}#${CONSTANTS.SCRIPT_ID}-config .settings-sections{padding:1.5rem;max-height:60vh;overflow-y:auto}#${CONSTANTS.SCRIPT_ID}-config .settings-section{margin-bottom:2rem}#${CONSTANTS.SCRIPT_ID}-config .settings-section h3{font-size:1.1rem;margin:0 0 1.2rem;color:#fff;display:flex;align-items:center;gap:.5rem}#${CONSTANTS.SCRIPT_ID}-config .setting-item{display:flex;justify-content:space-between;align-items:center;padding:.8rem 0;border-bottom:1px solid #404040}#${CONSTANTS.SCRIPT_ID}-config .setting-info{flex-grow:1;margin-right:1.5rem}#${CONSTANTS.SCRIPT_ID}-config .setting-info label{display:block;font-weight:500;margin-bottom:.3rem;cursor:help}#${CONSTANTS.SCRIPT_ID}-config .description{color:#a0a0a0;font-size:.9rem;line-height:1.4}#${CONSTANTS.SCRIPT_ID}-config .switch{flex-shrink:0}#${CONSTANTS.SCRIPT_ID}-config .modal-footer{padding:1.5rem;border-top:1px solid #404040;display:flex;gap:.8rem;justify-content:flex-end}#${CONSTANTS.SCRIPT_ID}-config .btn{padding:.6rem 1.2rem;border-radius:4px;border:0;cursor:pointer;font-weight:500;transition:all .2s ease}#${CONSTANTS.SCRIPT_ID}-config .btn.save{background:#4CAF50;color:#fff}#${CONSTANTS.SCRIPT_ID}-config .btn.warning{background:#f44336;color:#fff}#${CONSTANTS.SCRIPT_ID}-config .btn:hover{opacity:.9}#${CONSTANTS.SCRIPT_ID}-config .link-settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:.8rem}#${CONSTANTS.SCRIPT_ID}-config .link-settings-grid .setting-item{background:rgba(255,255,255,.05);border-radius:4px;padding:.8rem;border:1px solid #404040;margin:0}#${CONSTANTS.SCRIPT_ID}-config .link-settings-grid .setting-item:hover{background:rgba(255,255,255,.08)}`;
$('<style>').prop('type', 'text/css').html(styles).appendTo('head');
}
setupModalEventListeners() {
$('.close-button').click(() => $(`#${CONSTANTS.SCRIPT_ID}-config`).remove());
$('#save-config').click(async () => {
this.config.logging = $('#logging').is(':checked');
this.config.debugging = $('#debugging').is(':checked');
[...CONSTANTS.METADATA_SITES, ...CONSTANTS.STREAMING_SITES].forEach(site => {
const checkboxId = site.name.toLowerCase().replace(/\s+/g, '_');
this.config[site.name] = $(`#${checkboxId}`).is(':checked');
});
await GMC.setValue(CONSTANTS.CONFIG_KEY, this.config);
$(`#${CONSTANTS.SCRIPT_ID}-config`).remove();
window.location.reload();
});
$('#clear-cache').click(async () => {
const values = await GMC.listValues();
for (const value of values) {
if (value === CONSTANTS.CONFIG_KEY) continue;
await GMC.deleteValue(value);
}
this.info('Cache cleared (excluding config)');
$(`#${CONSTANTS.SCRIPT_ID}-config`).remove();
window.location.reload();
});
}
}
// ======================
// Script Initialization
// ======================
$(document).ready(async () => {
// Start the main application
const traktLinks = new TraktExternalLinks();
await traktLinks.init();
});
})();