Greasy Fork is available in English.

PlexBoxd-Letterboxd Integration for Plex

Add Letterboxd link and rating to its corresponding Plex film's page

// ==UserScript==
// @name         PlexBoxd-Letterboxd Integration for Plex
// @namespace    http://tampermonkey.net/
// @description  Add Letterboxd link and rating to its corresponding Plex film's page
// @author       CarnivalHipster
// @match        https://app.plex.tv/*
// @match        http://localhost:32400/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com
// @license 	 MIT
// @grant        GM_xmlhttpRequest
// @connect      letterboxd.com
// @version 	 2.11.0

// ==/UserScript==
//2.11.0 Changes: Added functionnality to go to actors and producers pages
//TODO: Account for alternative titles in letterboxd. It should look in those if the plex title is included.
//Edge cases:
//Unstoppable Family : has an alternative title on Letterboxd
//Vietnam: A Television History is a tv show logged as movie in Tmdb so doesn't get an icon
//Todd McFarlane's Spawn is the same, so I should not rely on tmdb
//Directors that are very unknown such as Clive Gordon don't get the icon, don't know why, maybe because they dont have birthdays
//Also those directors pages makes the script stop after getting the title for unknown reasons
//Pluto 2023 tv show doesn't get matched because there is already a film called pluto 2023. the url is pluto-2023-1, Same for Swarm 2023
//Awaken from Tom Lowe is 2018 on Letterboxd but 2021 on Plex so causes infinite loop
//HALFSOLVED - South from hell show (2015) causes infinite loop and has the line This is not a tv show page. Changed the hasSeries to check for Series
//SOLVED - Ennio Morricone has a movie named after him so it gets matched instead of the director page and causes infinite loop
//SOLVED - I never thought about actors, so only director's page are considered, which gives actors a link to their director's pages
//SOLVED - Films that have both same year and name and one of them has no directors like Cargo 2006 and Cargo 2006 by Clive Gordon
//SOLVED - The Shining has a bug on letterboxd where the-shining-1980 links to the-shining-1997
//SOLVED - Mob Psycho 100 has 2 tv shows by the same director so one gets wrongly matched even tho they are 2016 and 2018.
//Letterboxd api will make all this obsolete so its not really worth the time.

//README: The UI langauge should be english.
(function() {
    'use strict';
    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;
    var lastSubtitle = undefined;
    var currentUrl = window.location.href;

    function checkForPageChange() {
        if (window.location.href !== currentUrl) {
            currentUrl = window.location.href;
            lastTitle = undefined;
            lastYear = undefined;
            lastDirector = undefined;
            console.log('Page change detected, global state reset.');
            return true;
        }
        return false; 
    }

    function isPersonsPage() {
        return /^(director|actor|writer|cinematographer|producer|composer|editor)$/.test(lastSubtitle);
    }

    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 extractSubtitle() {
        const subtitleElement = document.querySelector('h2[data-testid="metadata-subtitle"]');
    
        if (subtitleElement) {
            const subtitle = subtitleElement.textContent.trim() || subtitleElement.innerText.trim();
            let firstWord = subtitle.split(' ')[0];
            firstWord = firstWord.replace(/[,;:.!?]/g, '');
            firstWord = firstWord.toLowerCase();
            //console.log('The subtitle is:', firstWord);
            lastSubtitle = firstWord;
            return firstWord;
        }else {
            lastSubtitle = undefined;
    
        }
    }

    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 = '';
                //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, '-');
        if (isPersonsPage()) {
            const letterboxdBaseUrl = 'https://letterboxd.com/'+ lastSubtitle + '/';
            return `${letterboxdBaseUrl}${titleSlug}/`;
        } else {
            const letterboxdBaseUrl = 'https://letterboxd.com/film/';
            return `${letterboxdBaseUrl}${titleSlug}-${year}/`;
        }
    }

    function removeYearFromUrl(url) {
        const yearPattern = /-\d{4}(?=\/$)/;
        return url.replace(yearPattern, '');
    }

    function replaceFilmWithWord(url, suffix) {
        return url.replace('film', suffix);
    }

    function addSuffixBeforeLastSlash(url, suffix) {
        return url.replace(/\/$/, `-${suffix}/`);
    }

    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 personUrl = replaceFilmWithWord(yearRemovedUrl, lastSubtitle);
                        console.log('Trying person URL:', personUrl);
                        return checkLink(personUrl).then(result =>{
                        if (result.accessible){
                            console.log(result.url, 'is accessible, status:', result.status);
                            return personUrl;
                        }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) {
                        try {
                            //console.log(response.responseText);
                            const doc = globalParser.parseFromString(response.responseText, "text/html");
                            resolve(doc);
                        } catch (parseError) {
                            reject(new Error('Error parsing Letterboxd page: ' + parseError.message));
                        }
                    } else {
                        reject(new Error('Failed to load Letterboxd page, status: ' + response.status));
                    }
                },
                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) {
                console.log('The year on Letterboxd is : ' + yearMatch[0]);
                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"]');
        if (directorMetaTag && directorMetaTag.content) {
            console.log('The Director on Letterboxd is: ' + directorMetaTag.content);
            return directorMetaTag.content;
        } else {
            console.log('Director not found.');
            return undefined;
        }
    }


    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 checkPlexLetterboxdSeriesMismatch(doc, hasSeries) {
        if (hasSeries) {
            const isLetterboxdTvShow = doc.querySelector('a[href*="themoviedb.org/tv/"]') != null;
            if (!isLetterboxdTvShow) {
                console.log(`Plex categorized as a TV show but Letterboxd is on a movie or director page.`);
                console.log(`Icon creation aborted.`);
                return false;
            }
        }
        return true;
    }

    function isPersonsLetterboxdPage(url) {
        return /^https:\/\/letterboxd\.com\/(director|actor|writer|cinematographer|producer|composer|editor)/.test(url);
    }

    
    async function handlePersonsPage(url) {
        if (isPersonsLetterboxdPage(url)) {
            updateOrCreateLetterboxdIcon(url, 'Letterboxd');
            return true;
        }
        return false;
    }

    async function handleMismatch(url, yearInHtml, directorInHtml, doc, hasSeries) {
        if (yearInHtml != lastYear || (!directorInHtml.includes(lastDirector) && !hasSeries)) {
            console.log(`Either the year on Plex [${lastYear}] is different from the year on Letterboxd [${yearInHtml}] or ` +
            `The director on Plex [${lastDirector}] isn't one of the directors from Letterboxd [${directorInHtml}]`);
    
            if (!hasSeries && !directorInHtml.includes(lastDirector) && directorInHtml != undefined) {
                console.log(`This is not a tv show page.`);
                console.log(`The director on Plex [${lastDirector}] is different from the director on Letterboxd [${directorInHtml}]`);
                let subtractedYearUrl = subtractYearFromUrl(url, lastYear);
                console.log('Trying subtracted year url: ' + subtractedYearUrl);
                let result = await checkLink(subtractedYearUrl);
                if (result.accessible) {
                    console.log(subtractedYearUrl, 'Url with subtracted year is accessible, status:', result.status);
                    const newDoc = await fetchLetterboxdPage(subtractedYearUrl);
                    const newDirectorInHtml = extractDirectorFromMeta(newDoc);
    
                    if (newDirectorInHtml.includes(lastDirector)) {
                        return subtractedYearUrl;
                    } else {
                        console.log(`Director on Plex [${lastDirector}] doesn't match director on html [${newDirectorInHtml}]`);
                    }
                } else {
                    console.log(`Url with subtracted year is inaccessible`);
                }
    
                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');
                    return urlWithoutYear;
                } else {
                    console.log(result.url, 'is not accessible, status:', result.status);
                    console.log('Icon creation aborted');
                    return null;
                }
            } else if ((parseInt(yearInHtml, 10) - 1 === parseInt(lastYear, 10)) || (parseInt(yearInHtml, 10) + 1 === parseInt(lastYear, 10))) {
                console.log(`Year from Plex [${lastYear}] has 1 year of difference with Letterboxd [${yearInHtml}]. Icon created`);
                const rating = extractRating(doc);
                updateOrCreateLetterboxdIcon(url, rating);
                return null;
            } else {
                console.log(`Year from Plex [${lastYear}] has more than 1 year of difference with Letterboxd [${yearInHtml}]. Icon creation aborted`);
                return null;
            }
        } else {
            if (hasSeries) {
                console.log(`Plex Tv show :[${lastTitle}], Year: [${lastYear}], and Letterboxd entry [${url}], Year: [${yearInHtml}] have the same year. Icon created.`);
            } else if (lastDirector == undefined || lastDirector == '') {
                console.log(`Plex film [${lastTitle}], Year: [${lastYear}], and Letterboxd entry [${url}], Year: [${yearInHtml}] have the same year. Icon created.`);
            } else {
                console.log(`Plex film [${lastTitle}], Year: [${lastYear}], Director: [${lastDirector}] and Letterboxd entry [${url}], Year: [${yearInHtml}], Director: [${directorInHtml}] have the same year and director. Icon created.`);
            }
            const rating = extractRating(doc);
            updateOrCreateLetterboxdIcon(url, rating);
            return null;
        }
    }
    
    async function processLetterboxdUrl(initialUrl) {
        let url = initialUrl;
        let shouldContinue = true;

        const hasSeries = false;//QUICKFIX BECAUSE THE LINE BELOW DOESNT WORK ANYMORE FOR SOME REASON
        //Add more checks for Series keyword in other languages here:
        //const hasSeries = document.querySelectorAll('[title*="Series"], [title*="Seasons"], [title*="Saisons"]');

        while (shouldContinue) {
            try {
                if (checkForPageChange()) {
                    break;
                }
                const doc = await fetchLetterboxdPage(url);
                //console.log(doc);
                if (!await checkPlexLetterboxdSeriesMismatch(doc, hasSeries)) break;
                if (await handlePersonsPage(url)) break;

                const yearInHtml = extractYearFromMeta(doc);
                const directorInHtml = extractDirectorFromMeta(doc);
                console.log('Director on Plex: ' + lastDirector);
                
                let newUrl = await handleMismatch(url, yearInHtml, directorInHtml, doc, hasSeries);
                if (newUrl === null) break;
                if (newUrl) {
                    url = newUrl;
                    continue;
                }
    
                console.log(`No conditions were matched. Icon creation aborted`);
                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, lastProcessedYear, lastProcessedDirector, lastProcessedSubtitle;

    
        let debounceTimeout = null;

        function observerCallback(mutationsList, observer) {
            clearTimeout(debounceTimeout);
        
            debounceTimeout = setTimeout(() => {
                if (checkForPageChange()) {
                    return ;
                }
        
                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();
                extractSubtitle();

                if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear || lastDirector !== lastProcessedDirector || lastSubtitle !== lastProcessedSubtitle) {
                    lastProcessedTitle = lastTitle;
                    lastProcessedYear = lastYear;
                    lastProcessedDirector = lastDirector;
                    lastProcessedSubtitle = lastSubtitle;
        
                    if (lastTitle && lastYear) {
                        buildLetterboxdUrl(lastTitle, lastYear).then(url => {
                            if (!url) {
                                return;
                            }
                            processLetterboxdUrl(url, lastYear, lastDirector);
        
                        }).catch(error => {
                            console.error('Error building Letterboxd URL:', error);
                        });
                    }
                }
            }, 3);
        }
        

    const observer = new MutationObserver(observerCallback);
    observer.observe(document.body, {
        childList: true,
        characterData: true,
        subtree: true
    });
}
})();