I’ve vibe coded a userscript version that works but it would be great to have it work within the Trakt apps.
If anyone can improve on my script, please do share ![]()
Updated 24/07/2025
The play buttons work everywhere except in some where it says “Up Next” and “Recently Aired” tv show episodes. So far I haven’t been able to get it to work there.
// ==UserScript==
// @name Trakt.tv to Infuse Links (New API)
// @namespace http://tampermonkey.net/
// @version 6.4
// @description A robust script that reliably replaces 'Watch Now' buttons to open directly in Infuse on both desktop and iOS.
// @author Your Name Here
// @match https://trakt.tv/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=trakt.tv
// @grant GM.xmlHttpRequest
// @connect trakt.tv
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const PROCESSED_FLAG = 'data-infuse-processed';
// This selector targets any link that is designed to open the "Watch Now" modal.
const WATCH_NOW_SELECTOR = 'a[data-target="#watch-now-modal"]';
// --- State Management ---
let mainPageTmdbId = null;
let isFetchingMainId = false;
let itemUrlCache = new Map(); // Caches TMDB IDs for grid/list items to avoid re-fetching
console.log(`Trakt to Infuse: Initializing v6.4`);
/**
* Fetches the TMDB ID from a given Trakt URL, with caching.
* @param {string} traktUrl - The relative URL of the Trakt page (e.g., /movies/superman-2025).
* @returns {Promise<string|null>} A promise that resolves with the TMDB ID.
*/
function fetchTmdbIdFromUrl(traktUrl) {
const fullUrl = new URL(traktUrl, window.location.origin).href;
if (itemUrlCache.has(fullUrl)) {
return Promise.resolve(itemUrlCache.get(fullUrl));
}
return new Promise((resolve) => {
console.log(`Trakt to Infuse: Fetching TMDB ID from ${fullUrl}`);
GM.xmlHttpRequest({
method: "GET",
url: fullUrl,
onload: function(response) {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
const tmdbLink = doc.querySelector('li.tmdb a[href*="themoviedb.org/"], a#external-link-tmdb[href*="themoviedb.org/"]');
if (tmdbLink) {
const match = tmdbLink.href.match(/themoviedb\.org\/(movie|tv)\/(\d+)/);
if (match && match[2]) {
itemUrlCache.set(fullUrl, match[2]);
resolve(match[2]);
} else { resolve(null); }
} else { resolve(null); }
},
onerror: function(error) {
console.error(`Trakt to Infuse: Fetch for ${fullUrl} failed.`, error);
resolve(null);
}
});
});
}
/**
* Gets the TMDB ID for the current main detail page (movie or show). Caches the result for the current page view.
* @returns {Promise<string|null>}
*/
function getMainPageTmdbId() {
return new Promise((resolve) => {
if (mainPageTmdbId) {
resolve(mainPageTmdbId);
return;
}
const tmdbLink = document.querySelector('li.tmdb a[href*="themoviedb.org/"], a#external-link-tmdb[href*="themoviedb.org/"]');
if (tmdbLink) {
const match = tmdbLink.href.match(/themoviedb\.org\/(movie|tv)\/(\d+)/);
if (match && match[2]) {
mainPageTmdbId = match[2];
resolve(mainPageTmdbId);
return;
}
}
const path = window.location.pathname;
const showMatch = path.match(/(\/shows\/[^\/]+)/);
if (showMatch && showMatch[1] && !isFetchingMainId) {
isFetchingMainId = true;
const showUrl = showMatch[1];
fetchTmdbIdFromUrl(showUrl).then(id => {
if (id) mainPageTmdbId = id;
isFetchingMainId = false;
resolve(id);
});
} else {
resolve(null);
}
});
}
/**
* Extracts season and episode numbers from a given URL path.
* @param {string} path - The URL path to parse.
* @returns {object|null}
*/
function getEpisodeDetails(path) {
const episodeMatch = path.match(/\/seasons\/(\d+)\/episodes\/(\d+)/);
if (episodeMatch) return { season: episodeMatch[1], episode: episodeMatch[2] };
if (path.includes('/seasons/')) {
const seasonMatch = path.match(/\/seasons\/(\d+)/);
if (seasonMatch) return { season: seasonMatch[1], episode: null };
}
return null;
}
/**
* The core function. Finds a button, gets its data, generates the Infuse URL,
* and replaces the button with a clean, functional clone.
* @param {HTMLElement} button - The button element to process.
*/
async function processButton(button) {
if (button.getAttribute(PROCESSED_FLAG)) return;
button.setAttribute(PROCESSED_FLAG, 'processing');
const container = button.closest('[data-url]');
const isListItem = container !== null;
const itemUrl = isListItem ? container.dataset.url : window.location.pathname;
let id;
// **REVISED LOGIC**: Determine HOW to get the ID based on context.
if (isListItem) {
let urlToFetchIdFrom = itemUrl;
// If the item URL is for a season or episode, we must find the main show's URL to get the TMDB ID.
const showMatch = itemUrl.match(/(\/shows\/[^\/]+)/);
if (showMatch && showMatch[1]) {
// This handles URLs like /shows/rick-and-morty/seasons/7/episodes/1
urlToFetchIdFrom = showMatch[1];
} else if (itemUrl.startsWith('/seasons') || itemUrl.startsWith('/episodes')) {
// This handles URLs like /seasons/12345 or /episodes/12345 from a show's main page
const pageShowMatch = window.location.pathname.match(/(\/shows\/[^\/]+)/);
if (pageShowMatch && pageShowMatch[1]) {
urlToFetchIdFrom = pageShowMatch[1];
}
}
id = await fetchTmdbIdFromUrl(urlToFetchIdFrom);
} else {
// This is a main detail page button (e.g., /shows/rick-and-morty).
id = await getMainPageTmdbId();
}
if (!id) {
console.warn("Trakt to Infuse: Could not get TMDB ID for", itemUrl);
button.removeAttribute(PROCESSED_FLAG);
return;
}
let infuseUrl = '';
if (itemUrl.includes('/movies/')) {
infuseUrl = `infuse://movie/${id}`;
} else if (itemUrl.includes('/shows/') || itemUrl.includes('/seasons/') || itemUrl.includes('/episodes/')) {
const details = getEpisodeDetails(itemUrl);
if (details && details.episode) {
infuseUrl = `infuse://series/${id}-${details.season}-${details.episode}`;
} else if (details && details.season) {
infuseUrl = `infuse://series/${id}-${details.season}`;
} else {
infuseUrl = `infuse://series/${id}`;
}
}
if (!infuseUrl) return;
console.log(`Trakt to Infuse: Replacing button for ${itemUrl} with URL ${infuseUrl}`);
const buttonClone = button.cloneNode(true);
buttonClone.href = infuseUrl;
buttonClone.removeAttribute('data-toggle');
buttonClone.removeAttribute('data-target');
buttonClone.removeAttribute('data-url');
buttonClone.setAttribute(PROCESSED_FLAG, 'true');
if (isListItem) {
buttonClone.style.backgroundColor = 'rgba(255, 111, 0, 0.8)';
buttonClone.style.color = 'white';
} else {
const mainInfo = buttonClone.querySelector('.main-info');
if (mainInfo) mainInfo.textContent = 'Open in Infuse';
const underInfo = buttonClone.querySelector('.under-info');
if (underInfo) underInfo.textContent = 'Direct Link';
buttonClone.style.outline = '2px solid #FF6F00';
buttonClone.style.outlineOffset = '2px';
buttonClone.style.backgroundColor = '#333';
}
if (button.parentNode) {
button.parentNode.replaceChild(buttonClone, button);
}
}
/**
* Scans the page for any unprocessed buttons.
*/
function processAllButtonsOnPage() {
const buttons = document.querySelectorAll(`${WATCH_NOW_SELECTOR}:not([${PROCESSED_FLAG}])`);
buttons.forEach(processButton);
}
function resetStateAndRun() {
console.log("Trakt to Infuse: Page loaded or changed. Initializing script.");
mainPageTmdbId = null;
isFetchingMainId = false;
itemUrlCache.clear();
processAllButtonsOnPage();
}
// --- Main Execution ---
const observer = new MutationObserver(processAllButtonsOnPage);
observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener('turbo:load', resetStateAndRun);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
resetStateAndRun();
} else {
document.addEventListener('DOMContentLoaded', resetStateAndRun, { once: true });
}
console.log("Trakt to Infuse: Script loaded (v6.3). Event listeners attached.");
})();