Plex Letterboxd link and rating

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

当前为 2024-01-12 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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=plex.tv
// @license 	 MIT
// @grant        GM_xmlhttpRequest
// @connect      letterboxd.com
// @version 	 2.4.5
// ==/UserScript==
(function() {
    'use strict';
    const letterboxdImg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAI1klEQVR4nO2bW3CTxxXH/2d1tSzJMrZFC8VcrBoSGyjFTmpoG0oNlEyHMjQyhelMiZtxzQMtfaB56Esf2mkb0gHS6eA4HTdtEwasNNNhQhJsBzN0nASwi3GwxxBjLsYEfL9JtiX5O30QFroif5Is25Tf07dnd8+es9pvtXt2P8I0s9W6L0Mi17cl5hwGLydQNgjpzDAQ2AAADBomwjAYPQy+RqCrgqhZsOrcB7Y/d0+nfTQdSjdb965lSLsZvAlAbgztMIArBKomiGNVtqMN8bPSQ9w6YFvxrwyjIwMlzFQM8NPx0usPtRBxRZLeVH6y4pXhuGiMVcH2PftNDsfYfgb/nJlT42FUJIion0Cv6XTaw/9+8/BATLqircjMtOlHpXtI4j8ykBGLEdFCQDcLern6eNmbRMRR6pBPobUkk4iOMfP6aOrHGyKqY+bdNbby27Lryq2wqah0PZjfZbBZbt3phEBdINpRXVlWJ6eekFN4c1FpMVg6M9ucBwAGm8HSmc1FpcVy6k1pBFitlYp+qjkIxi+jMy/BEA6lcuEBm61oInLRCFitlYoB1LzLwLb4WJcYCDhpQuGOSJ0Q8RXop5qDc815AGBgWz/VHIxUTvGozM1FpcXM/Pv4mZVwCiy5+R3Xm+svhSsQ9hXwzPbSGQbU02NbYiDACRIbw/07hOyAQmtJJoEuzsbZPhoI1MXg/FDrhKA5gJmJiI49Ls4Dnr/IBwu3oB88aA6oa733Ipj3Jca0hJL5z3+dutXe3NDoK/Trke179pvsdse1mVrbTzcEdCcn67J9N1B+r4BnV/d4Og8ADGQ4HGP7fWXeEeDZzw/eStSWdqYgov4kfcriyXiCdwR4ghmPt/MAwMypoyMDJZNp8TCDZG0i5jK+vhLgieFJmKiPVFGQhGXaPmQoR6BXODHg1uK+y4Db4/IGjlPHsH/ZjXGjJ4ahGSIkf6GE2iFzd546H6xPBesMwPgoaGQA1NcJSJFjIwKKvCrb0QYlADCk3Y8qvEAziF1pjVhnvAmjYjwo/75Lj9pBCyp7V2HYrQ2pgwF0rXaiY904Bpa6gcAADhNMN5RY9LEG5svq8EtUrQ7S6o1gyxqwPrjjacwOutkM0VgDDPaE9emBzw0EAIXWkiYAKwMLCZLworkeL6Q1QUlSWGWT2CU1jn5RgNMDy/3kjnkSrvx4BEOL3BF1AICxQ4nct/TQ9fmv03j5M5DWbQerQ3eyH5IE0XQW4uKpcCPisxpb+Sraat2X4cL4fQSsCdTCjd8sqkK+/s6UjPblnd6VeP1eAQBgYLEbl4uH4dLJC9mpHITVFQaYbik9/hRsg7Rqg2xbqKMViqq/AW5XYBaroJmvyMr9+lYG7wzMfXlhLdYbb8luEACe1nVhjJWo15rx371Dsp0HAEkFdOc6YW7SQLF8I6S1W6KyBSnp4JR0iPamwBwi4vNCYs4JzPleaiu+k3I9ugYf8JL5AjJ3uaJyfhKXjiH9NBvSM9+PyRbOWgNpxbNBcok5RzDY74XVCDd+khHxDyEiwrIFRxa9ELOe3y17CWvTHhm2mBJS3lZA6b+zZ/ByQaBsX+F6402kqxwxN4hv7Id56fP47mhy1CqeknKwYZ4JOzNVsduTbAQv9Z/nCZQtQEj3FRbob8bemNoAWLYAJPALKegNmzK71M9DECE/TQmtrPh1aKTFAbYQ0gUzDL4yi7Y39pZSs7yPKxTzo1aTrVzgfV6oi0MPpC30SzLDICaPqL1lVPbYG5pneagvOTNqNZkao/d5oS72c1zWG/3SBDbEoVvnNoJBfsfMva7oJy0vfZ8/1GeXfVzn5eb4kPe50xH93+kkNDLkl2bQsCCCXwe0jaXF3BD62ryPrRP3o1bzubvT+9zpiLwUj0hvp1+SCMMCDL8dwycjS2JvyGUH2k4DLOGIaI5azQnnh5hgxsVeN8bi4L+4FWALo0fJ4GsA8iZldUNL0GPWxb4W+PhVdAkXPkqKflJtEVdwprcfpzuSYrMFAOxDoBuf+YkYfE0Q6KqvcFxS4u/deYgVqb0G+25Xxqzn1+1voKE34hlnRET9B4Db6Scj0FUhiILG6If9K1A7mBUolsVfu57FneMaqOQGOXxQOQiqijaIC+/FZAtdvwTRej5ILoiahWDVOXjiFX68evc5XBz5SlQNvtO7Erae1dD1CayuMETVCZPbYV2fgGishWg6G5Ut1NEKxdnjobJYsOqcoq3lgmNZztofAvBbsk2wQO1QFlQkYUVSF8QUruDYJTVeu/tNnOj5mlemHRQwN2kwuMSN8ZSpzWTGDiXWvGGE4d7DTRDduQoa6QcWWACFMrISSYK4XAvFuePARMhX6EqV7eifFACQlZNnAVAQWIJBuGRfiNqhLCSRG2b1CDQiWNl9lx7v9T+F394pRIvjS0H5qlHCgvMa6LsUcOoZYyYOPpVkgumGCl89pUP2SR3Uo8Gjhno7IVo/8ST0JkAdPDnSmB2irRGKj/4But4YYmw/KAd6u72loSqmoOjghBb3nDMcFDXMAyfpow6KelsstP6sefouOM42qKXG9noO4HMuQMQVM2dQYvH11dsBSXpTORH1z4xJicNzNGYqn0x7p9mrl+qclpz8JAZvmBHLEoQg8cr7bx2p8qZ9M3U67WECpvV6+kxCQLdOpz3sK/OLNrY2fjq2bGVeDxg/SKxpCULQvvff/sunvqKg/x1mps07S/8zW+4Bxwsiqqs6UfatwEvVQREhImJm3k2grsSZN70QqIuZd4e6UR4yJFZjK78Noh0EOEPlzyU81+RoR7ib5GFPHNqb6zssufl3GTyn5wNBoqS6suxkuPxHHrlcb66/tCx3bQpC7BPmBIRD1ZWv/+FRRSJGhVO58AABYXtwtkLAyVQuPBCpXMQOsNmKJkwo3AHCofiYlgAIh6ZyU9xTVAaey9PS0dl6f5gAJ5HYW1VZNuV9zZNPZuQ2VF1ZVsfgfCKS1dB0QkR1DM6X6zzw5LO5Jx9OPvl0Nh5KAvm//Hg6HLP98/n/AbIVpUXHBEvwAAAAAElFTkSuQmCC';
    const globalParser = new DOMParser();
    var lastTitle = undefined;
    var lastYear = undefined;
    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) {
        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; // ? rating : "Director's Page"; That was neat for director's pages, but shows up with films that don't have ratings
            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(/[^\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(html) {
        const doc = globalParser.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;
        }
    }

    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;
        }
    }

    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) {
            const isAlbumPage = document.querySelector('[class^="AlbumDisc"]');
            // My attempt to remove series that aren't miniseries, because letterboxd only have those.
            const isFullSeries = document.querySelector('[title*="Season 4"], [title*="Season 5"], [title*="Season 6"]');
            const hasOneSeason = document.querySelector('[title*="Season 1"]');
            if (isAlbumPage || isFullSeries) {
                //console.log('Detected an album or series page, not proceeding with Letterboxd icon creation.');
                return;
            }
            extractData();
            if (lastTitle !== lastProcessedTitle || lastYear !== lastProcessedYear) {
                lastProcessedTitle = lastTitle;
                lastProcessedYear = lastYear;

                if (lastTitle && lastYear) {
                    buildLetterboxdUrl(lastTitle, lastYear).then(url => {
                        if (!url){
                            return;
                        }
                        fetchLetterboxdPage(url).then(html => {
                            //console.log(html);
                            const doc = globalParser.parseFromString(html, "text/html");
                            const yearInHtml = extractYearFromMeta(doc);
                            if (yearInHtml !== lastYear){
                                console.log(`Url doesn't point to the right movie. Icon creation aborted.`);
                                return;
                            }
                            if (hasOneSeason){
                                const isATvShow = doc.querySelector('a[href*="themoviedb.org/tv/"]');
                                if (!isATvShow) {
                                    console.log(`It got a movie or director on a tv show page. Icon creation aborted.`);
                                    return;
                                }
                            }
                            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
    });
}
})();