// ==UserScript== // @name Plex Letterboxd link and rating // @namespace http://tampermonkey.net/ // @description Add Letterboxd link and rating to its corresponding Plex film's page // @author CarnivalHipster // @match https://app.plex.tv/* // @icon https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com // @license MIT // @grant GM_xmlhttpRequest // @connect letterboxd.com // @version 2.7.1 // @downloadURL none // ==/UserScript== //Edge cases: //Vietnam: A Television History is a tv show logged as movie in Tmdb so doesn't get an icon //Films that have both same year and name and one of them has no directors like Cargo 2006 and Cargo 2006 by Clive Gordon //Directors that are very unknown don't get the icon, don't know why //Also Clive Gordon's director's page makes the script stop after getting the title for unknown reasons //The Shining has a bug on letterboxd where the-shining-1980 links to the-shining-1997 //I did a fall back to remove the year from the url for a last try for stupid cases like The Shining, which may cause problems elsewhere, but probably not. //Letterboxd api will make all this obsolete so its not really worth the time. (function() { 'use strict'; //const letterboxdImg = 'data:image/png;base64,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'; const letterboxdImg = 'https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com'; const globalParser = new DOMParser(); var lastTitle = undefined; var lastYear = undefined; var lastDirector = undefined; function extractTitleAndYear() { const titleElement = document.querySelector('h1[data-testid="metadata-title"]'); const yearElement = document.querySelector('span[data-testid="metadata-line1"]'); if (titleElement) { const title = titleElement.textContent.trim() || titleElement.innerText.trim(); if (title !== lastTitle) { lastTitle = title; console.log('The title is:', lastTitle); } } else { lastTitle = ''; // Reset if no title is found } if (yearElement) { const text = yearElement.textContent.trim() || yearElement.innerText.trim(); const match = text.match(/\b\d{4}\b/); if (match && match[0] !== lastYear) { lastYear = match[0]; console.log('The year is:', lastYear); } } else { lastYear = ''; // Reset if no year is found } } function extractDirectorFromPage() { const directedByText = 'Directed by'; const spans = Array.from(document.querySelectorAll('span')); const directorSpan = spans.find(span => span.textContent.includes(directedByText)); if (directorSpan) { const directorLink = directorSpan.parentElement.querySelector('a'); if (directorLink) { const directorName = directorLink.textContent.trim(); if (directorName && directorName !== lastDirector) { lastDirector = directorName; console.log('Director in Plex: ', lastDirector); } } } else { if (lastDirector !== undefined) { lastDirector = undefined; console.log('The director has been reset.'); } } } function checkLink(url) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'HEAD', url: url, onload: function(response) { if (response.status >= 200 && response.status < 300) { resolve({url: url, status: response.status, accessible: true}); } else { resolve({url: url, status: response.status, accessible: false}); } }, onerror: function() { reject(new Error(url + ' could not be reached or is blocked by CORS policy.')); } }); }); } function updateOrCreateLetterboxdIcon(link, rating) { let metadataElement = document.querySelector('div[data-testid="metadata-ratings"]'); if (!metadataElement) { metadataElement = document.querySelector('div[data-testid="metadata-children"]'); } const existingContainer = document.querySelector('.letterboxd-container'); if (existingContainer) { existingContainer.querySelector('a').href = link; const ratingElement = existingContainer.querySelector('.letterboxd-rating'); if (ratingElement) { ratingElement.textContent = rating; } } else if (metadataElement) { const container = document.createElement('div'); container.classList.add('letterboxd-container'); container.style.cssText = 'display: flex; align-items: center; gap: 8px;'; const icon = document.createElement('img'); icon.src = letterboxdImg; icon.alt = 'Letterboxd Icon'; icon.style.cssText = 'width: 24px; height: 24px; cursor: pointer;'; const ratingText = document.createElement('span'); ratingText.classList.add('letterboxd-rating'); ratingText.textContent = rating; ratingText.style.cssText = 'font-size: 14px;'; // Style as needed const linkElement = document.createElement('a'); linkElement.href = link; linkElement.appendChild(icon); container.appendChild(linkElement); container.appendChild(ratingText); metadataElement.insertAdjacentElement('afterend', container); } } function buildDefaultLetterboxdUrl(title, year) { const normalizedTitle = title.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); const titleSlug = normalizedTitle.trim().toLowerCase() .replace(/&/g, 'and') .replace(/-/g, ' ') .replace(/[^\w\s]/g, '') .replace(/\s+/g, '-'); const letterboxdBaseUrl = 'https://letterboxd.com/film/'; return `${letterboxdBaseUrl}${titleSlug}-${year}/`; } function removeYearFromUrl(url) { const yearPattern = /-\d{4}(?=\/$)/; return url.replace(yearPattern, ''); } function replaceFilmWithDirector(url) { return url.replace('film','director'); } function buildLetterboxdUrl(title, year) { let defaultUrl = buildDefaultLetterboxdUrl(title, year); return checkLink(defaultUrl).then(result => { if (result.accessible) { console.log(result.url, 'is accessible, status:', result.status); return result.url; } else { console.log(result.url, 'is not accessible, status:', result.status); let yearRemovedUrl = removeYearFromUrl(result.url); console.log('Trying URL without year:', yearRemovedUrl); return checkLink(yearRemovedUrl).then(yearRemovedResult => { if (yearRemovedResult.accessible) { console.log(yearRemovedUrl, 'is accessible, status:', yearRemovedResult.status); return yearRemovedUrl; } else { console.log(yearRemovedUrl, 'is not accessible, status:', yearRemovedResult.status); let directorUrl = replaceFilmWithDirector(yearRemovedUrl); console.log('Trying director URL:', directorUrl); return checkLink(directorUrl).then(result =>{ if (result.accessible){ return directorUrl; }else{ console.log(result.url, 'is not accessible, status:', result.status); } }); } }); } }).catch(error => { console.error('Error after checking both film and year:', error.message); let newUrl = removeYearFromUrl(defaultUrl); return newUrl; }); } function fetchLetterboxdPage(url) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'GET', url: url, onload: function(response) { if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { reject(new Error('Failed to load Letterboxd page')); } }, onerror: function() { reject(new Error('Network error while fetching Letterboxd page')); } }); }); } function roundToOneDecimal(numberString) { const number = parseFloat(numberString); return isNaN(number) ? null : (Math.round(number * 10) / 10).toFixed(1); } function extractRating(doc) { const ratingElement = doc.querySelector('meta[name="twitter:data2"]'); if (ratingElement && ratingElement.content) { const match = ratingElement.getAttribute('content').match(/\b\d+\.\d{1,2}\b/); if (match) { return roundToOneDecimal(match[0]); } } else { console.log('Rating element not found.'); return null; } } function extractYearFromMeta(doc) { const metaTag = doc.querySelector('meta[property="og:title"]'); if (metaTag) { const content = metaTag.getAttribute('content'); const yearMatch = content.match(/\b\d{4}\b/); if (yearMatch) { return yearMatch[0]; } else { console.log('Year not found in the html'); return null; } } else { console.log('Meta tag not found in the html'); return null; } } function extractDirectorFromMeta(doc) { const directorMetaTag = doc.querySelector('meta[name="twitter:data1"]'); return directorMetaTag ? directorMetaTag.content : null; } function subtractYearFromUrl(url, lastYear) { const yearPattern = /-(\d{4})\/$/; const match = url.match(yearPattern); if (match) { const year = parseInt(match[1], 10) - 1; return url.replace(yearPattern, `-${year}/`); } else { const previousYear = parseInt(lastYear, 10) - 1; return url.replace(/\/$/, `-${previousYear}/`); } } async function processLetterboxdUrl(initialUrl) { let url = initialUrl; let shouldContinue = true; const hasOneSeason = document.querySelector('[title*="Season 1"]'); while (shouldContinue) { try { const html = await fetchLetterboxdPage(url); const doc = globalParser.parseFromString(html, "text/html"); if (hasOneSeason && !doc.querySelector('a[href*="themoviedb.org/tv/"]')) { console.log(`Plex got a tv show but Letterboxd is on a movie or director page.`); console.log(`Icon creation aborted.`); break; } if (url.startsWith('https://letterboxd.com/director')) { updateOrCreateLetterboxdIcon(url, 'Letterboxd'); break; } else { const yearInHtml = extractYearFromMeta(doc); console.log('The year in the html is : ' + yearInHtml); if (yearInHtml != lastYear) { const directorInHtml = extractDirectorFromMeta(doc); console.log('Director in Html: ' + directorInHtml); if (!directorInHtml.includes(lastDirector)) { if (lastYear != NaN) { break; } let subtractedYearUrl = subtractYearFromUrl(url, lastYear); console.log('Trying substracted year url: ' + subtractedYearUrl); let result = await checkLink(subtractedYearUrl); if (result.accessible) { console.log(subtractedYearUrl, 'Url with substracted year is accessible, status:', result.status); const newHtml = await fetchLetterboxdPage(subtractedYearUrl); const newDoc = globalParser.parseFromString(newHtml, "text/html"); const newDirectorInHtml = extractDirectorFromMeta(newDoc); if (newDirectorInHtml.includes(lastDirector)) { url = subtractedYearUrl; continue; } else { console.log(`Director in Plex [${lastDirector}] doesn't match director in html [${newDirectorInHtml}]`); } } else { console.log('Url with substracted year is inaccessible'); } // Fallback to URL without year let urlWithoutYear = removeYearFromUrl(url); result = await checkLink(urlWithoutYear); if (result.accessible) { console.log(result.url, 'is accessible, status:', result.status); console.log('Going back to url without year as fallback'); url = urlWithoutYear; // Update URL to the one without year continue; // Check again with the updated URL } else { console.log(result.url, 'is not accessible, status:', result.status); console.log(`Icon creation aborted`); break; } } else { console.log('Movie has different year but same director and name, probably due to differing metadata. Icon created'); const rating = extractRating(doc); updateOrCreateLetterboxdIcon(url, rating); break; } } else { const rating = extractRating(doc); updateOrCreateLetterboxdIcon(url, rating); break; } } } catch (error) { console.error('Error fetching or parsing Letterboxd page:', error); break; } } } if(document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') { main(); } else { document.addEventListener('DOMContentLoaded', main); } function main() { var lastProcessedTitle = undefined; var lastProcessedYear = undefined; var lastProcessedDirector = undefined; function observerCallback(mutationsList, observer) { const isAlbumPage = document.querySelector('[class^="AlbumDisc"]'); const isFullSeries = document.querySelector('[title*="Season 4"], [title*="Season 5"], [title*="Season 6"]'); if (isAlbumPage || isFullSeries) { return; } extractTitleAndYear(); extractDirectorFromPage(); if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear || lastDirector !== lastProcessedDirector ) { lastProcessedTitle = lastTitle; lastProcessedYear = lastYear; lastProcessedDirector = lastDirector; if (lastTitle && lastYear ) { buildLetterboxdUrl(lastTitle, lastYear).then(url => { if (!url){ return; } processLetterboxdUrl(url, lastYear, lastDirector); }).catch(error => { console.error('Error building Letterboxd URL:', error); }); } } } const observer = new MutationObserver(observerCallback); observer.observe(document.body, { childList: true, characterData: true, subtree: true }); } })();