I am working on a user script (I'm using Tampermoney as my user script manager) to append a dynamic Rotten Tomatoes link to a Jellyfin movies/series page.
It works when I manually reload on the title's page, but I want it work when I navigate to the page without a need for a manual reload.
Here's the effect (when reloaded currently):
Here is my script:
// ==UserScript==
// @name Rotten Tomatoes Links for Jellyfin
// @namespace http://stpettersen.xyz
// @version 2025-09-07
// @description This script adds a Rotten Tomatoes link for TV and movies on Jellyfin.
// @author Sam Saint-Pettersen
// @match https://jfdemo.stpettersen.xyz/*
// @icon https://www.rottentomatoes.com/assets/pizza-pie/images/favicon.ico
// @grant none
// ==/UserScript==
(function() {
// Replace @match with your Jellyfin server URL.
'use strict';
function waitForElement(selector, targetNode = document.body) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element) {
return resolve(element);
}
const observer = new MutationObserver(() => {
const foundElement = document.querySelector(selector);
if (foundElement) {
observer.disconnect();
resolve(foundElement);
}
});
observer.observe(targetNode, {
childList: true,
subtree: true
});
});
}
function injectRTLink() {
console.log("RTLfJF: injectRTLink invoked; inserting link.");
let targetEl = null;
let links = document.getElementsByClassName('button-link')
for (let i = 0; i < links.length; i++) {
if (links[i].href.startsWith("https://www.themoviedb.org")) {
targetEl = links[i];
break;
}
if (targetEl != null) {
break;
}
}
let genre = "m";
let year = "";
let title = document.getElementsByTagName("bdi")[0].innerHTML.toLowerCase()
.replace(/ /g,"_").replace(/'/g,"").replace(/_&/g, "").replace(/:/g, "").replace(/,/g, "").replace(/-/g, "_");
let otm = document.getElementsByClassName("originalTitle")[0];
if (otm) {
let ot = document.getElementsByClassName("originalTitle")[0].innerHTML;
if (ot.length > 0) year = "_" + ot;
}
let ott = document.getElementsByClassName("originalTitle")[0];
if (ott) {
let ot = document.getElementsByClassName("originalTitle")[0].innerHTML;
if (ot == "TV") genre = "tv";
}
targetEl.insertAdjacentHTML("afterend",
`, <a id="rt-link" target="_blank" class="button-link emby-button" href="https://www.rottentomatoes.com/${genre}/${title}${year}">Rotten Tomatoes</a>`
.replace("_TV", ""));
}
// Wait for bottom cards to load before attempting to get/insert links.
waitForElement('.card').then(element => {
console.log("RTLfJF: Title cards loaded...");
injectRTLink();
});
})();
Any pointers would be great. Thank you.
EDIT 2025-09-09: I have deployed a demo Jellyfin instance at:
https://jfdemo.stpettersen.xyz
Username is "public" (no quotes) and there is no password.
My user script is also a gist: https://gist.github.com/stpettersens/ecd6c9038170f4cb22eb0bfd1b23cd7a
Installable to Tampermonkey/Greasemonkey via direct link: https://gist.github.com/stpettersens/ecd6c9038170f4cb22eb0bfd1b23cd7a/raw/cddccf42d83482aaf3d5682888087629a6aecde4/Rotten%2520Tomatoes%2520Links-2025-09-07.user.js
There are multiple issues with your approach:
movie_name_year as a rotten tomatoes link is not reliable.You could use/reuse the mutation observer or the Navigation API to detect re-renders. But a much simpler solution is to intercept the metadata fetch request to the jellyfin-server and add an extra entry to ExternalUrls.
To get the correct Rotten Tomatoes url you could either use an api like OMDB (which can have incorrect urls). Or search Rotten Tomatoes for the movie and scrape the url from there.
Here's the script:
// ==UserScript==
// @name Jellyfin Rotten Tomatoes
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Adds Rotten Tomatoes link to ExternalUrls
// @author GTK
// @match https://jfdemo.stpettersen.xyz/*
// @icon https://www.rottentomatoes.com/assets/pizza-pie/images/favicon.ico
// @grant unsafeWindow
// @grant GM.xmlHttpRequest
// @connect www.rottentomatoes.com
// @run-at document-start
// ==/UserScript==
/* globals URLPattern */
const pattern = new URLPattern({
pathname: "/Users/:user/Items/:item",
search: "",
})
const _fetch = unsafeWindow.fetch;
unsafeWindow.fetch = function(url, options) {
if (location.hash.startsWith("#/details") && pattern.test(url)) {
return patch(url, options);
}
return _fetch(url, options);
}
async function getRottenTomatoesLink(title, director, year, type = "Movie") {
console.log(`[RT] Searching for ${type}: ${title} (${year}) - ${director}`)
let response = await GM.xmlHttpRequest({
url: `https://www.rottentomatoes.com/search?search=${title} ${director}`,
timeout: 3000,
}).catch(e => e);
let [shortType, searchType] = type === "Movie" ? ["m", "movie"] : ["tv", "tvSeries"];
let _default = `https://www.rottentomatoes.com/${shortType}/${title.split(" ").join("_").toLowerCase()}`;
if (response.status !== 200) {
console.warn(`[RT] Search failed! (${response.status || "TIMEOUT"})`);
return _default;
}
let doc = response.responseXML;
let results = Array.from(doc.querySelectorAll(`search-page-result[type=${searchType}] > ul > search-page-media-row`), item => {
return {
year: item.getAttribute("releaseyear") || item.getAttribute("startyear"),
title: item.innerText.trim(),
link: item.querySelector("a[slot=title]").href,
}
});
let found = results.find(r => r.title === title && r.year === String(year));
return found ? found.link : _default;
}
async function patch(url, options) {
let response = await _fetch(url, options);
let data = await response.json();
let { Name: title, ProductionYear: year, Type: type, People: crew } = data;
if (type === "Movie" || type === "Series") {
let director = crew.find(member => member.Role === "Director")?.Name || crew[0].Name;
let link = await getRottenTomatoesLink(title, director, year, type);
data.ExternalUrls.push({
Name: "Rotten Tomatoes",
Url: link
})
}
return Response.json(data, {
status: response.status,
statusText: response.statusText,
headers: response.headers
})
}
The patch function is only called on the movie/show details page (/#/details?id=:id) and only on the specific metadata endpoint (/Users/:user/Items/:item)
The getRottenTomatoesLink function will return a default of movie_name if the network request fails or if no match is found.
I found that using Movie Name + Director as a search query is more reliable than Movie Name + year.