PlexBoxd-Letterboxd Integration for Plex

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
    });
}
})();