Plex Letterboxd link and rating

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

目前為 2024-01-14 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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