Stream on option to open in Infuse/Plex

The latest updates to the tvOS Trakt app have started to make it so useful. For my family the single most important feature addition would be to be able to press play on a movie and have it open it in Infuse. Is such an integration possible?

5 Likes

It should be possible now with the latest update:

I’d love to have this integrated into Trakt so I can open Infuse movies and tv shows from the Trakt apps across all my Apple platforms.

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 :slight_smile:

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.");

})();

1 Like

Resolved all problems. The play buttons work everywhere on Trakt, at least with the Safari browser on macOS & iOS.

It doesn’t work on Trakt Lite found under app.trakt.tv

// ==UserScript==
// @name         Trakt.tv to Infuse Links (New API)
// @namespace    http://tampermonkey.net/
// @version      6.5
// @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.5`);

    /**
     * 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.5). Event listeners attached.");

})();

1 Like