// ==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=plex.tv // @license MIT // @grant GM_xmlhttpRequest // @connect letterboxd.com // @version 1.4 // @downloadURL none // ==/UserScript== (function() { 'use strict'; const letterboxdImg = 'https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com'; var lastTitle = undefined; var lastYear = undefined; //Function Definitions function extractData() { 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 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) { const existingContainer = document.querySelector('.letterboxd-container'); const metadataElement = document.querySelector('div[data-testid="metadata-ratings"]'); 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;'; const linkElement = document.createElement('a'); linkElement.href = link; // linkElement.target = '_blank'; // Uncomment if you want to open in a new tab linkElement.appendChild(icon); container.appendChild(linkElement); container.appendChild(ratingText); metadataElement.insertAdjacentElement('afterend', container); } } function buildDefaultLetterboxdUrl(title, year) { const titleSlug = title.trim().toLowerCase() .replace(/&/g, 'and') .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 directorUrl; } }); } }).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(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); 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; } } // Main if(document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') { main(); } else { document.addEventListener('DOMContentLoaded', main); } function main() { var lastProcessedTitle = undefined; var lastProcessedYear = undefined; function observerCallback(mutationsList, observer) { extractData(); if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear) { lastProcessedTitle = lastTitle; lastProcessedYear = lastYear; if (lastTitle && lastYear) { buildLetterboxdUrl(lastTitle, lastYear).then(url => { fetchLetterboxdPage(url).then(html => { const rating = extractRating(html); updateOrCreateLetterboxdIcon(url, rating); }).catch(error => { console.error('Error fetching or parsing Letterboxd page:', error); }); }).catch(error => { console.error('Error building Letterboxd URL:', error); }); } } } const observer = new MutationObserver(observerCallback); observer.observe(document.body, { childList: true, characterData: true, subtree: true }); } })();