IMDb TMDB Letterboxd Linker

Opens the corresponding IMDb, TMDB, or Letterboxd page for movies, TV shows and people with a single click. Additionally, it also displays IMDb ratings on both TMDB and Letterboxd pages.

// ==UserScript==
// @name         IMDb TMDB Letterboxd Linker
// @description  Opens the corresponding IMDb, TMDB, or Letterboxd page for movies, TV shows and people with a single click. Additionally, it also displays IMDb ratings on both TMDB and Letterboxd pages.
// @author       Tetrax-10
// @namespace    https://github.com/Tetrax-10/imdb-tmdb-letterboxd-linker
// @version      2.3
// @license      MIT
// @match        *://*.imdb.com/title/tt*
// @match        *://*.imdb.com/name/nm*
// @match        *://*.themoviedb.org/movie/*
// @match        *://*.themoviedb.org/tv/*
// @match        *://*.themoviedb.org/person/*
// @match        *://*.letterboxd.com/film/*
// @include      /^https?:\/\/(?:www\.)?letterboxd\.com\/(actor|additional-photography|camera-operator|cinematography|composer|costume-design|director|editor|executive-producer|hairstyling|makeup|original-writer|producer|set-decoration|sound|story|visual-effects|writer)\/.*$/
// @connect      imdb.com
// @connect      themoviedb.org
// @homepageURL  https://github.com/Tetrax-10/imdb-tmdb-letterboxd-linker
// @supportURL   https://github.com/Tetrax-10/imdb-tmdb-letterboxd-linker/issues
// @icon         https://tetrax-10.github.io/imdb-tmdb-letterboxd-linker/assets/icon.png
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

;(() => {
    const TMDB_API_KEY = GM_getValue("TMDB_API_KEY", null)?.trim()

    GM_registerMenuCommand("Settings", showPopup)

    function showPopup() {
        GM_addStyle(`
#linker-settings-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 10000;
}
#linker-settings-popup {
    background-color: rgb(32, 36, 44);
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    z-index: 10001;
    font-family: Source Sans Pro, Arial, sans-serif;
    font-feature-settings: normal;
    font-variation-settings: normal;
    font-size: 100%;
    font-weight: inherit;
    line-height: 1.5;
    letter-spacing: normal;
    width: 60%;
    max-height: 80vh;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    -webkit-overflow-scrolling: touch;
}
#linker-settings-popup input {
    color: #cfcfcf;
}
#linker-settings-popup label {
    color: rgb(207, 207, 207);
    font-weight: bold;
    font-size: 1.2em;
    margin-bottom: 10px;
}
#linker-settings-popup input {
    background-color: rgb(32, 36, 44);
    border: 1px solid rgb(207, 207, 207);
    color: rgb(207, 207, 207);
    padding: 10px;
    border-radius: 8px;
    margin-bottom: 10px;
}
`)

        // Create overlay
        const overlay = document.createElement("div")
        overlay.id = "linker-settings-overlay"
        overlay.onclick = (e) => {
            if (e.target === overlay) closePopup(overlay)
        }

        // popup element
        const popup = document.createElement("div")
        popup.id = "linker-settings-popup"

        // popup content
        const label = document.createElement("label")
        label.textContent = "Enter your TMDB API key:"

        // input element
        const input = document.createElement("input")
        input.type = "text"
        input.value = GM_getValue("TMDB_API_KEY", "")
        input.oninput = (e) => {
            try {
                GM_setValue("TMDB_API_KEY", e.target?.value?.trim())
            } catch (error) {
                console.error("Failed to set TMDB API key", error)
            }
        }

        // inject popup
        popup.appendChild(label)
        popup.appendChild(input)
        overlay.appendChild(popup)
        document.body.appendChild(overlay)

        input.focus()
    }

    function closePopup(overlay) {
        document.body.removeChild(overlay)
    }

    const imdbPageCss = `
#linker-parent {
    display: flex;
    align-self: center;
}
#linker-letterboxd-link {
    align-self: center;
}
#linker-letterboxd {
	height: 27px;
	width: 53px;
	margin-top: 7px;
}
#linker-divider {
    border-left: 3px solid rgba(232, 230, 227, 0.5) !important;
    height: 25px;
    border-radius: 10px;
    margin-left: 10px;
    align-self: center;
}
#linker-loading {
    height: 20px;
    align-self: center;
    text-align: center;
    margin-left: 10px;
    margin-right: 40px;
}
#linker-tmdb-link {
    height: 27px;
    width: 60px;
    background: #022036 !important;
    color: #51b4ad !important;
    border: solid #51b4ad 2px !important;
    border-radius: 6px;
    align-self: center;
    margin-left: 10px;
    margin-right: 20px;
    font-weight: bold;
    text-align: center;
    align-content: center;
}
@media only screen and (max-width: 767px) {
    #linker-loading {
        margin-right: 6px;
    }
    #linker-tmdb-link {
        width: 48px;
        margin-left: 10px;
        margin-right: 10px;
        font-size: smaller;
    }
}    
`
    const tmdbTitlePageCss = `
#linker-parent {
    margin-top: 20px;
    display: flex;
    align-items: flex-start;
}
#linker-imdb-svg-bg {
    fill: #c59f00 !important;
}
#linker-divider {
    border-left: 2px solid rgba(232, 230, 227, 0.5) !important;
    height: 23px;
    border-radius: 10px;
    margin-left: 10px;
}
#linker-loading {
    height: 20px;
    margin-left: 10px;
}
#linker-imdb-container {
    display: flex;
    align-items: center;
    margin-left: 10px;
}
#linker-imdb-rating {
    margin-left: 10px;
}
html.k-mobile #linker-parent {
    margin-top: unset;
    margin-left: auto;
    margin-right: auto;
}    
`

    const tmdbPersonPageCss = `
#linker-imdb-svg,
#linker-letterboxd-svg path {
    --darkreader-inline-fill: #d0d0d0 !important;
}
`

    const letterboxdTitlePageCss = `
#linker-loading {
    border: 2px solid rgba(255, 255, 255, 0.3) !important;
    border-top: 2px solid #cfcfcf !important;
    height: 8px !important;
    width: 8px !important;
    margin-left: 4px;
}
`

    const commonUtils = (() => {
        const ImdbSvg = `<svg id="linker-imdb-svg" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="40" height="20"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0H23.32C10.11 2.17 0 14.16 0 28.61v232.25c0 16 12.37 28.97 27.64 28.97h519.95c14.06 0 25.67-11.01 27.41-25.26z" id="d1pwhf9wy2"/><path d="M69.35 58.24h45.63v175.65H69.35z" id="g5jjnq26yS"/><path d="M201.2 139.15c-3.92-26.77-6.1-41.65-6.53-44.62-1.91-14.33-3.73-26.8-5.47-37.44h-59.16v175.65h39.97l.14-115.98 16.82 115.98h28.47l15.95-118.56.15 118.56h39.84V57.09h-59.61z" id="i3Prh1JpXt"/><path d="M346.71 93.63c.5 2.24.76 7.32.76 15.26v68.1c0 11.69-.76 18.85-2.27 21.49-1.52 2.64-5.56 3.95-12.11 3.95V87.13c4.97 0 8.36.53 10.16 1.57 1.8 1.05 2.96 2.69 3.46 4.93m20.61 137.32c5.43-1.19 9.99-3.29 13.69-6.28 3.69-3 6.28-7.15 7.76-12.46 1.49-5.3 2.37-15.83 2.37-31.58v-61.68c0-16.62-.65-27.76-1.66-33.42-1.02-5.67-3.55-10.82-7.6-15.44q-6.09-6.93-17.76-9.96c-7.79-2.02-20.49-3.04-42.58-3.04H287.5v175.65h55.28c12.74-.4 20.92-.99 24.54-1.79" id="a4ov9rRGQm"/><path d="M464.76 204.7c-.84 2.23-4.52 3.36-7.3 3.36-2.72 0-4.53-1.08-5.45-3.25-.92-2.16-1.37-7.09-1.37-14.81v-46.42c0-8 .4-12.99 1.21-14.98.8-1.97 2.56-2.97 5.28-2.97 2.78 0 6.51 1.13 7.47 3.4.95 2.27 1.43 7.12 1.43 14.55v45.01c-.29 9.25-.71 14.62-1.27 16.11m-58.08 26.51h41.08c1.71-6.71 2.65-10.44 2.84-11.19 3.72 4.5 7.81 7.88 12.3 10.12 4.47 2.25 11.16 3.37 16.34 3.37 7.21 0 13.43-1.89 18.68-5.68 5.24-3.78 8.58-8.26 10-13.41 1.42-5.16 2.13-13 2.13-23.54V141.6c0-10.6-.24-17.52-.71-20.77s-1.87-6.56-4.2-9.95-5.72-6.02-10.16-7.9q-6.66-2.82-15.72-2.82c-5.25 0-11.97 1.05-16.45 3.12-4.47 2.07-8.53 5.21-12.17 9.42V55.56h-43.96z" id="fk968BpsX"/></defs><use id="linker-imdb-svg-bg" xlink:href="#d1pwhf9wy2" opacity="1" fill="#c59f00" fill-opacity="1"/><use xlink:href="#d1pwhf9wy2" fill-opacity="0" stroke="#000" stroke-opacity="0"/><use xlink:href="#g5jjnq26yS" fill="#000000 !important"/><use xlink:href="#g5jjnq26yS" fill-opacity="0" stroke="#000" stroke-opacity="0"/><use xlink:href="#i3Prh1JpXt" fill="#000000 !important"/><use xlink:href="#i3Prh1JpXt" fill-opacity="0" stroke="#000" stroke-opacity="0"/><use xlink:href="#a4ov9rRGQm" fill="#000000 !important"/><use xlink:href="#a4ov9rRGQm" fill-opacity="0" stroke="#000" stroke-opacity="0"/><use xlink:href="#fk968BpsX" fill="#000000 !important"/><use xlink:href="#fk968BpsX" fill-opacity="0" stroke="#000" stroke-opacity="0"/></svg>`
        const ImdbSvgWithoutBg = `<svg id="linker-imdb-svg" fill="#262626" width="50" height="50" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="M8.4 21.1H5.9V9.9h3.8l.7 4.7h.1l.5-4.7h3.8v11.2h-2.5v-6.7h-.1l-.9 6.7H9.4l-1-6.7zm7.4-11.3c.4 0 3.2-.1 4.7.1 1.2.1 1.8 1.1 1.9 2.3.1 2.2.1 4.4.1 6.6 0 .2 0 .5-.1.8-.2.9-.7 1.4-1.9 1.5-1.5.1-3 .1-4.4.1h-.2V9.8zm3 2.1v7.2c.5 0 .8-.2.8-.7v-5.9c0-.5-.2-.7-.8-.6M2 21.1V9.9h2.9v11.2zm27.9-7c-.1-.8-.6-1.2-1.4-1.4-.8-.1-1.6 0-2.3.7V9.9h-2.8v11.2H26c.1-.2.1-.4.2-.5h.1l.3.3c.7.5 1.5.6 2.3.3.7-.3 1-.9 1-1.6 0-.8.1-1.7.1-2.6 0-1 0-2-.1-2.9m-2.8 5c0 .2-.2.4-.4.4s-.4-.2-.4-.4v-4.3c0-.2.2-.4.4-.4s.4.2.4.4z"/></svg>`

        const letterboxdSvg = `<svg id="linker-letterboxd" width="43" height="23" viewBox="0 0 500 250" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M0 0h129.847v141.389H0z"/><path id="c" d="M0 0h129.847v141.389H0z"/></defs><g fill="none" fill-rule="evenodd"><rect width="500" height="250" fill="#202830" rx="40" ry="40"/><g transform="translate(61 50)"><ellipse fill="#00E054" cx="189" cy="69.973" rx="70.079" ry="69.973"/><g transform="translate(248.153)"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g/><ellipse fill="#40BCF4" mask="url(#b)" cx="59.769" cy="69.973" rx="70.079" ry="69.973"/></g><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><g/><ellipse fill="#FF8000" mask="url(#d)" cx="70.079" cy="69.973" rx="70.079" ry="69.973"/><path d="M129.54 107.022c-6.73-10.744-10.619-23.443-10.619-37.049s3.89-26.305 10.618-37.049c6.73 10.744 10.618 23.443 10.618 37.05 0 13.605-3.889 26.304-10.618 37.048M248.46 32.924c6.73 10.744 10.619 23.443 10.619 37.05 0 13.605-3.89 26.304-10.618 37.048-6.73-10.744-10.618-23.443-10.618-37.049s3.889-26.305 10.618-37.049" fill="#FFF"/></g></g></svg>`
        const LetterboxdSvgWithoutBg = `<svg id="linker-letterboxd-svg" width="50" height="50" viewBox="0 0 550 550" xmlns="http://www.w3.org/2000/svg"><path fill="#262626" d="M165 103v280h189.156v-69.9H237.724V103z" fill-rule="evenodd"/></svg>`

        function isMobile() {
            const data = navigator.userAgent || navigator.vendor || window.opera

            // Check for userAgentData mobile status (newer browsers)
            // prettier-ignore
            if (navigator.userAgentData?.mobile || /Mobi/i.test(navigator.userAgent) || 'ontouchstart' in document.documentElement || /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(data) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(data.substr(0, 4))) {
                return true
            } else {
                return false
            }
        }

        async function waitForElement(selector, timeout = null, nthElement = 1) {
            // wait till document body loads
            while (!document.body) {
                await new Promise((resolve) => setTimeout(resolve, 10))
            }

            nthElement -= 1

            return new Promise((resolve) => {
                if (document.querySelectorAll(selector)?.[nthElement]) {
                    return resolve(document.querySelectorAll(selector)?.[nthElement])
                }

                const observer = new MutationObserver(async () => {
                    if (document.querySelectorAll(selector)?.[nthElement]) {
                        resolve(document.querySelectorAll(selector)?.[nthElement])
                        observer.disconnect()
                    } else {
                        if (timeout) {
                            async function timeOver() {
                                return new Promise((resolve) => {
                                    setTimeout(() => {
                                        observer.disconnect()
                                        resolve(undefined)
                                    }, timeout)
                                })
                            }
                            resolve(await timeOver())
                        }
                    }
                })

                observer.observe(document.body, {
                    childList: true,
                    subtree: true,
                })
            })
        }

        async function getImdbRating(imdbId) {
            if (!imdbId) return [undefined, undefined]

            return new Promise((resolve) => {
                try {
                    GM_xmlhttpRequest({
                        method: "GET",
                        url: `https://www.imdb.com/title/${imdbId}/ratings`,
                        onload: function (response) {
                            try {
                                const parser = new DOMParser()
                                const dom = parser.parseFromString(response.responseText, "text/html")

                                const rating = dom.querySelector(`div[data-testid="rating-button__aggregate-rating__score"] > span`)?.innerText
                                const numRating = dom.querySelector(`div[data-testid="rating-button__aggregate-rating__score"] + div`)?.innerText

                                resolve([rating, numRating])
                            } catch (parsingError) {
                                console.error("Error parsing IMDb rating data", parsingError)
                                resolve([undefined, undefined])
                            }
                        },
                        onerror: function (error) {
                            console.error(`Can't scrape IMDb: ${imdbId}`, error)
                            resolve([undefined, undefined])
                        },
                    })
                } catch (requestError) {
                    console.error("Failed to initiate IMDb request", requestError)
                    resolve([undefined, undefined])
                }
            })
        }

        function createDividerElement() {
            const divider = document.createElement("div")
            divider.id = "linker-divider"

            return divider
        }

        function createParentContainer() {
            const parentContainer = document.createElement("div")
            parentContainer.id = "linker-parent"

            return parentContainer
        }

        function createLoadingElement() {
            const loadingElement = document.createElement("div")
            loadingElement.id = "linker-loading"

            // Add loading animation CSS
            try {
                GM_addStyle(`
                #linker-loading {
                    border: 4px solid rgba(255, 255, 255, 0.3);
                    border-radius: 50%;
                    border-top: 4px solid #cfcfcf;
                    width: 22px;
                    height: 22px;
                    animation: spin 1s linear infinite;
                }
        
                @keyframes spin {
                    0% { transform: rotate(0deg); }
                    100% { transform: rotate(360deg); }
                }
            `)
            } catch (styleError) {
                console.error("Failed to add styles for loading element", styleError)
            }

            return loadingElement
        }

        return {
            isMobile: isMobile,
            waitForElement: waitForElement,
            getImdbRating: getImdbRating,
            svg: {
                ImdbSvg: ImdbSvg,
                ImdbSvgWithoutBg: ImdbSvgWithoutBg,
                letterboxdSvg: letterboxdSvg,
                LetterboxdSvgWithoutBg: LetterboxdSvgWithoutBg,
            },
            element: {
                createDividerElement: createDividerElement,
                createParentContainer: createParentContainer,
                createLoadingElement: createLoadingElement,
            },
        }
    })()

    const imdbPageUtils = (() => {
        function createLetterboxdElement(imdbId) {
            const linkElement = document.createElement("a")
            linkElement.id = "linker-letterboxd-link"
            linkElement.href = imdbId.startsWith("https") ? imdbId : `https://letterboxd.com/imdb/${imdbId}/`
            linkElement.target = "_blank"

            linkElement.innerHTML = commonUtils.svg.letterboxdSvg

            return linkElement
        }

        function createTmdbElement(tmdbData) {
            const linkElement = document.createElement("a")
            linkElement.id = "linker-tmdb-link"
            linkElement.target = "_blank"
            linkElement.innerText = "TMDB"

            try {
                if (tmdbData["media_type"] === "tv_episode") {
                    linkElement.href = `https://www.themoviedb.org/tv/${tmdbData["show_id"]}/season/${tmdbData["season_number"]}/episode/${tmdbData["episode_number"]}`
                } else if (typeof tmdbData === "object") {
                    linkElement.href = `https://www.themoviedb.org/${tmdbData["media_type"]}/${tmdbData.id}`
                } else if (typeof tmdbData === "string") {
                    linkElement.href = tmdbData
                }
            } catch (error) {
                console.error("Failed to create TMDB element", error)
            }

            return linkElement
        }

        function mirrorElements(parentContainer, isMobile, rootElementSelector) {
            const observer = new MutationObserver(() => {
                try {
                    const clonedContainer = parentContainer?.cloneNode(true)

                    commonUtils.waitForElement(rootElementSelector, 10000, !isMobile ? 2 : 1).then((element) => {
                        if (!element) return
                        for (const parentEle of element.querySelectorAll("#linker-parent")) {
                            parentEle?.remove()
                        }
                        element.insertBefore(clonedContainer, element.firstChild)
                    })
                } catch (error) {
                    console.error("Error while mirroring elements", error)
                }
            })

            observer.observe(parentContainer, { childList: true, subtree: true, attributes: true })
        }

        return {
            element: {
                createLetterboxdElement: createLetterboxdElement,
                createTmdbElement: createTmdbElement,
                mirrorElements: mirrorElements,
            },
        }
    })()

    async function imdbTitlePageInjector() {
        const isMobile = location.host.includes("m.imdb")

        const path = location.pathname.split("/")
        const imdbId = path[2] || null

        const parentContainer = commonUtils.element.createParentContainer()
        const letterboxdElement = imdbPageUtils.element.createLetterboxdElement(imdbId)
        const dividerElement = commonUtils.element.createDividerElement()
        const loadingElement = commonUtils.element.createLoadingElement()

        const rootElementSelector = "div:has( > div[data-testid='hero-rating-bar__user-rating'])"

        window.addEventListener("load", () => {
            try {
                commonUtils.waitForElement(rootElementSelector, 10000, isMobile ? 2 : 1).then((element) => {
                    element.insertBefore(parentContainer, element.firstChild)
                    imdbPageUtils.element.mirrorElements(parentContainer, isMobile, rootElementSelector)

                    parentContainer.appendChild(letterboxdElement)
                    parentContainer.appendChild(dividerElement)
                    if (!TMDB_API_KEY) return
                    parentContainer.appendChild(loadingElement)
                })
            } catch (error) {
                console.error("Error during element injection on IMDb title page", error)
            }
        })

        // inject parent element if not present
        function injectParentElement() {
            try {
                if (!document.querySelectorAll("#linker-parent")[isMobile ? 2 : 1]) {
                    commonUtils.waitForElement(rootElementSelector, 10000, isMobile ? 2 : 1).then((element) => {
                        element.insertBefore(parentContainer, element.firstChild)
                    })
                }
                if (!document.querySelectorAll("#linker-parent")[!isMobile ? 2 : 1]) {
                    imdbPageUtils.element.mirrorElements(parentContainer, isMobile, rootElementSelector)
                }
            } catch (error) {
                console.error("Failed to inject parent element", error)
            }
        }

        // inject the parent element every 100ms. Since IMDb sometimes re-renders its components, the parent element may occasionally be removed.
        const intervalId = setInterval(injectParentElement, 100)

        setTimeout(() => {
            clearInterval(intervalId)
        }, 5000)

        if (!TMDB_API_KEY) {
            await commonUtils.waitForElement("#linker-divider")

            try {
                const tmdbElement = imdbPageUtils.element.createTmdbElement(`https://www.themoviedb.org/redirect?external_source=imdb_id&external_id=${imdbId}`)
                parentContainer.appendChild(tmdbElement)
            } catch (error) {
                console.error("Failed to create TMDB element for title page", error)
            }

            return
        }

        try {
            const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/find/${imdbId}?api_key=${TMDB_API_KEY}&external_source=imdb_id`)
            const tmdbRes = await tmdbRawRes.json()
            const tmdbData = tmdbRes["movie_results"]?.[0] || tmdbRes["tv_results"]?.[0] || tmdbRes["tv_episode_results"]?.[0]

            if (tmdbData && (await commonUtils.waitForElement("#linker-loading", 10000))) {
                const tmdbElement = imdbPageUtils.element.createTmdbElement(tmdbData)
                parentContainer.removeChild(loadingElement)
                parentContainer.appendChild(tmdbElement)
            } else {
                parentContainer.removeChild(dividerElement)
                parentContainer.removeChild(loadingElement)
            }
        } catch (error) {
            console.error("Failed to fetch or process TMDB data for title page", error)
            parentContainer.removeChild(dividerElement)
            parentContainer.removeChild(loadingElement)
        }
    }

    async function imdbPersonPageInjector() {
        const isMobile = location.host.includes("m.imdb")

        const path = location.pathname.split("/")
        const imdbId = path[2] || null

        const parentContainer = commonUtils.element.createParentContainer()
        const loadingElement = commonUtils.element.createLoadingElement()

        const rootElementSelector = "div:has( > .starmeter-logo)"

        window.addEventListener("load", () => {
            try {
                commonUtils.waitForElement(rootElementSelector, 10000, isMobile ? 2 : 1).then((element) => {
                    element.insertBefore(parentContainer, element.firstChild)
                    imdbPageUtils.element.mirrorElements(parentContainer, isMobile, rootElementSelector)

                    parentContainer.appendChild(loadingElement)
                })
            } catch (error) {
                console.error("Error during element injection on IMDb person page", error)
            }
        })

        // inject parent element if not present
        function injectParentElement() {
            try {
                if (!document.querySelector("#linker-parent")) {
                    commonUtils.waitForElement(rootElementSelector, 10000, isMobile ? 2 : 1).then((element) => {
                        element.insertBefore(parentContainer, element.firstChild)
                    })
                }
                if (!document.querySelectorAll("#linker-parent")[!isMobile ? 2 : 1]) {
                    imdbPageUtils.element.mirrorElements(parentContainer, isMobile, rootElementSelector)
                }
            } catch (error) {
                console.error("Failed to inject parent element on person page", error)
            }
        }

        // inject the parent element every 100ms. Since IMDb sometimes re-renders its components, the parent element may occasionally be removed.
        const intervalId = setInterval(injectParentElement, 100)

        setTimeout(() => {
            clearInterval(intervalId)
        }, 5000)

        if (!TMDB_API_KEY) {
            await commonUtils.waitForElement("#linker-loading")

            try {
                const tmdbElement = imdbPageUtils.element.createTmdbElement(`https://www.themoviedb.org/redirect?external_source=imdb_id&external_id=${imdbId}`)
                parentContainer.removeChild(loadingElement)
                parentContainer.appendChild(tmdbElement)
            } catch (error) {
                console.error("Failed to create TMDB element for person page", error)
            }

            return
        }

        try {
            const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/find/${imdbId}?api_key=${TMDB_API_KEY}&external_source=imdb_id`)
            const tmdbRes = await tmdbRawRes.json()
            const tmdbData = tmdbRes["movie_results"]?.[0] || tmdbRes["tv_results"]?.[0] || tmdbRes["tv_episode_results"]?.[0] || tmdbRes["person_results"]?.[0]

            if (tmdbData && (await commonUtils.waitForElement("#linker-loading", 10000))) {
                const tmdbElement = imdbPageUtils.element.createTmdbElement(tmdbData)
                const letterboxdElement = imdbPageUtils.element.createLetterboxdElement(`https://letterboxd.com/tmdb/${tmdbData.id}/person`)
                const dividerElement = commonUtils.element.createDividerElement()

                parentContainer.removeChild(loadingElement)

                parentContainer.appendChild(letterboxdElement)
                parentContainer.appendChild(dividerElement)
                parentContainer.appendChild(tmdbElement)
            } else {
                parentContainer.removeChild(loadingElement)
            }
        } catch (error) {
            console.error("Failed to fetch or process TMDB data for person page", error)
            parentContainer.removeChild(loadingElement)
        }
    }

    const tmdbTitlePageUtils = (() => {
        function createLetterboxdElement(tmdbId, type) {
            try {
                const linkElement = document.createElement("a")
                linkElement.href = `https://letterboxd.com/tmdb/${tmdbId}/${type === "movie" ? "" : type}`
                linkElement.target = "_blank"
                linkElement.innerHTML = commonUtils.svg.letterboxdSvg
                return linkElement
            } catch (error) {
                console.error("Failed to create Letterboxd element:", error)
                return null
            }
        }

        function createImdbContainer() {
            try {
                const imdbContainer = document.createElement("div")
                imdbContainer.id = "linker-imdb-container"
                return imdbContainer
            } catch (error) {
                console.error("Failed to create IMDb container:", error)
                return null
            }
        }

        function createImdbElement(imdbId) {
            try {
                const linkElement = document.createElement("a")
                linkElement.href = `https://imdb.com/title/${imdbId}`
                linkElement.target = "_blank"
                linkElement.innerHTML = commonUtils.svg.ImdbSvg
                return linkElement
            } catch (error) {
                console.error("Failed to create IMDb element:", error)
                return null
            }
        }

        function createImdbRatingElement(rating, numRatings) {
            try {
                const text = rating !== undefined ? `${rating}${numRatings !== undefined ? ` ( ${numRatings} )` : ""}` : null
                const ratingElement = document.createElement("div")
                ratingElement.id = "linker-imdb-rating"
                ratingElement.innerText = text
                return text ? ratingElement : null
            } catch (error) {
                console.error("Failed to create IMDb rating element:", error)
                return null
            }
        }

        return {
            element: {
                createLetterboxdElement: createLetterboxdElement,
                createImdbContainer: createImdbContainer,
                createImdbElement: createImdbElement,
                createImdbRatingElement: createImdbRatingElement,
            },
        }
    })()

    async function tmdbTitlePageInjector() {
        try {
            const isMobile = commonUtils.isMobile()

            const path = location.pathname.split("/")
            const tmdbId = path[2].match(/\d+/)?.[0] || null
            if (!tmdbId) throw new Error("TMDB ID could not be extracted from the URL")

            const parentContainer = commonUtils.element.createParentContainer()
            const letterboxdElement = tmdbTitlePageUtils.element.createLetterboxdElement(tmdbId, path[1])
            const dividerElement = commonUtils.element.createDividerElement()
            const imdbContainer = tmdbTitlePageUtils.element.createImdbContainer()
            const loadingElement = commonUtils.element.createLoadingElement()

            commonUtils.waitForElement(`.header.poster${isMobile ? " > .title" : ""}`, 10000).then((element) => {
                try {
                    if (isMobile) {
                        element.insertBefore(parentContainer, element?.firstChild?.nextSibling?.nextSibling)
                    } else {
                        element.appendChild(parentContainer)
                    }

                    parentContainer.appendChild(letterboxdElement)
                    if (!TMDB_API_KEY) return
                    parentContainer.appendChild(dividerElement)
                    parentContainer.appendChild(imdbContainer)
                    imdbContainer.appendChild(loadingElement)
                } catch (error) {
                    console.error("Error during element injection on TMDB title page:", error)
                }
            })

            if (!TMDB_API_KEY) return

            // Fetch IMDb ID
            const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/${path[1]}/${tmdbId}/external_ids?api_key=${TMDB_API_KEY}`).catch((error) => {
                console.error("Failed to fetch external IDs from TMDB:", error)
            })
            if (!tmdbRawRes) return

            const tmdbRes = await tmdbRawRes.json()
            const imdbId = tmdbRes["imdb_id"] || null

            if (!imdbId) {
                parentContainer.removeChild(dividerElement)
                parentContainer.removeChild(imdbContainer)
                return
            }

            // Inject IMDb element
            const imdbElement = tmdbTitlePageUtils.element.createImdbElement(imdbId)
            commonUtils.waitForElement(`.header.poster${isMobile ? " > .title" : ""}`, 10000).then(async () => {
                try {
                    await commonUtils.waitForElement("#linker-imdb-container", 5000)
                    imdbContainer.insertBefore(imdbElement, loadingElement)
                } catch (error) {
                    console.error("Error while waiting to inject IMDb element:", error)
                }
            })

            // Scrape IMDb ratings
            const [imdbRating, imdbNumRating] = await commonUtils.getImdbRating(imdbId).catch((error) => {
                console.error("Failed to fetch IMDb rating:", error)
            })

            // Inject IMDb rating element
            const imdbRatingElement = tmdbTitlePageUtils.element.createImdbRatingElement(imdbRating, imdbNumRating)
            await commonUtils.waitForElement("#linker-loading", 10000).catch((error) => {
                console.error("Failed to wait for linker loading:", error)
            })
            try {
                imdbContainer.removeChild(loadingElement)
                if (imdbRatingElement) imdbContainer.appendChild(imdbRatingElement)
            } catch (error) {
                console.error("Failed to inject IMDb rating element:", error)
            }
        } catch (error) {
            console.error("Error in tmdbTitlePageInjector:", error)
        }
    }

    const tmdbPersonPageUtils = (() => {
        function createLogoElement(id, type = "imdb") {
            try {
                const linkContainer = document.createElement("div")

                const linkElement = document.createElement("a")
                linkElement.className = "social_link"
                linkElement.href = type === "imdb" ? `https://www.imdb.com/name/${id}` : `https://letterboxd.com/tmdb/${id}/person`
                linkElement.target = "_blank"
                linkElement.title = `Visit ${type === "imdb" ? "IMDb" : "Letterboxd"}`
                linkElement.rel = "noopener"
                if (type !== "imdb") linkElement.style.width = "38px"

                const svgContainer = document.createElement("div")
                svgContainer.className = "glyphicons_v2"
                svgContainer.style.width = "50px"
                svgContainer.innerHTML = type === "imdb" ? commonUtils.svg.ImdbSvgWithoutBg : commonUtils.svg.LetterboxdSvgWithoutBg

                linkElement.appendChild(svgContainer)
                linkContainer.appendChild(linkElement)

                return linkContainer
            } catch (error) {
                console.error("Failed to create logo element:", error)
                return null
            }
        }

        return {
            element: {
                createLogoElement: createLogoElement,
            },
        }
    })()

    async function tmdbPersonPageInjector() {
        try {
            // Extract TMDB ID from URL
            const path = location.pathname.split("/")
            const tmdbId = path[2].match(/\d+/)?.[0] || null
            if (!tmdbId) throw new Error("TMDB ID could not be extracted from the URL")

            // Create and inject Letterboxd element
            const letterboxdElement = tmdbPersonPageUtils.element.createLogoElement(tmdbId, "letterboxd")

            commonUtils.waitForElement(".social_links", 10000).then((element) => {
                try {
                    element.insertBefore(letterboxdElement, element.firstChild)
                } catch (error) {
                    console.error("Failed to inject Letterboxd element:", error)
                }
            })

            if (!TMDB_API_KEY) return

            // Fetch IMDb ID
            const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/${path[1]}/${tmdbId}/external_ids?api_key=${TMDB_API_KEY}`).catch((error) => {
                console.error("Failed to fetch external IDs from TMDB:", error)
            })
            if (!tmdbRawRes) return

            const tmdbRes = await tmdbRawRes.json()
            const imdbId = tmdbRes["imdb_id"] || null

            // Inject IMDb element
            if (imdbId) {
                const imdbElement = tmdbPersonPageUtils.element.createLogoElement(imdbId)

                commonUtils.waitForElement(`.social_links`, 10000).then(async (element) => {
                    try {
                        await commonUtils.waitForElement("#linker-letterboxd-svg")
                        element.insertBefore(imdbElement, letterboxdElement.nextElementSibling)
                    } catch (error) {
                        console.error("Failed to inject IMDb element:", error)
                    }
                })
            }
        } catch (error) {
            console.error("Error in tmdbPersonPageInjector:", error)
        }
    }

    function letterboxdTitlePageInjector() {
        commonUtils.waitForElement(`.micro-button.track-event[data-track-action="IMDb"]`, 10000).then(async (element) => {
            try {
                // Preserve original display style
                const originalDisplayStyle = element.style.display

                // Inject loading element
                const loadingElement = commonUtils.element.createLoadingElement()
                element.style.display = "inline-flex"
                element.appendChild(loadingElement)

                // Fetch IMDb ID and get ratings
                const imdbId = element.href?.match(/\/title\/(tt\d+)\/?/)?.[1] ?? null
                if (!imdbId) throw new Error("IMDb ID could not be extracted from the element href")

                const [imdbRating, imdbNumRating] = await commonUtils.getImdbRating(imdbId).catch((error) => {
                    console.error("Failed to fetch IMDb ratings:", error)
                    return [null, null]
                })

                // Remove loading element
                await commonUtils.waitForElement("#linker-loading", 10000)
                element.removeChild(loadingElement)
                element.style.display = originalDisplayStyle

                // Update IMDb button with fetched rating information
                element.innerText = `IMDb${imdbRating ? ` | ${imdbRating}` : ""}${imdbNumRating !== undefined ? ` (${imdbNumRating})` : ""}`
            } catch (error) {
                console.error("Error in letterboxdTitlePageInjector:", error)
            }
        })
    }

    function letterboxdPersonPageInjector() {
        commonUtils.waitForElement(`.micro-button[href^="https://www.themoviedb.org/person/"]`, 10000).then(async (element) => {
            try {
                // open tmdb link in new tab
                element.target = "_blank"

                // To make sure other scripts didn't inject imdb link
                if (document.querySelector(`.micro-button[href^="https://www.imdb.com/name/nm"]`)) return

                // Fetch TMDB ID
                const tmdbId = element.href?.match(/\/person\/(\d+)\/?/)?.[1] ?? null

                if (tmdbId) {
                    // Fetch external IDs
                    const tmdbRawRes = await fetch(`https://api.themoviedb.org/3/person/${tmdbId}?api_key=${TMDB_API_KEY}&append_to_response=external_ids`).catch((error) => {
                        console.error("Failed to fetch external IDs from TMDB:", error)
                    })
                    if (!tmdbRawRes) return

                    // Extract IMDb ID
                    const tmdbRes = await tmdbRawRes.json()
                    const imdbId = tmdbRes["external_ids"]["imdb_id"] || null

                    if (imdbId && !document.querySelector(`.micro-button[href^="https://www.imdb.com/name/nm"]`)) {
                        // create IMDb element
                        const imdbElement = element.cloneNode(true)
                        imdbElement.href = `https://www.imdb.com/name/${imdbId}`
                        imdbElement.innerText = "IMDB"
                        imdbElement.target = "_blank"
                        imdbElement.setAttribute("data-track-action", "IMDb")
                        imdbElement.style.marginRight = "5px"

                        // inject IMDb element
                        element.parentElement.insertBefore(imdbElement, element)
                    }
                }
            } catch (error) {
                console.error("Error in letterboxdPersonPageInjector:", error)
            }
        })
    }

    const currentURL = location.protocol + "//" + location.hostname + location.pathname

    if (/^(https?:\/\/[^.]+\.imdb\.com\/title\/tt[^\/]+(?:\/\?.*)?\/?)$/.test(currentURL)) {
        // IMDb title page
        GM_addStyle(imdbPageCss)
        imdbTitlePageInjector()
    } else if (/^(https?:\/\/[^.]+\.imdb\.com\/name\/nm[^\/]+(?:\/\?.*)?\/?)$/.test(currentURL)) {
        // IMDb person page
        GM_addStyle(imdbPageCss)
        imdbPersonPageInjector()
    } else if (/^(https?:\/\/[^.]+\.themoviedb\.org\/(movie|tv)\/\d[^\/]+(?:\/\?.*)?\/?)$/.test(currentURL)) {
        // TMDB title page
        GM_addStyle(tmdbTitlePageCss)
        tmdbTitlePageInjector()
    } else if (/^(https?:\/\/[^.]+\.themoviedb\.org\/person\/\d[^\/]+(?:\/\?.*)?\/?)$/.test(currentURL)) {
        // TMDB person page
        GM_addStyle(tmdbPersonPageCss)
        tmdbPersonPageInjector()
    } else if (/^(https?:\/\/letterboxd\.com\/film\/[^\/]+\/?(crew|details|releases|genres)?\/)$/.test(currentURL)) {
        // Letterboxd title page
        GM_addStyle(letterboxdTitlePageCss)
        letterboxdTitlePageInjector()
    } else if (
        /^(https?:\/\/letterboxd\.com\/(actor|additional-photography|camera-operator|cinematography|composer|costume-design|director|editor|executive-producer|hairstyling|makeup|original-writer|producer|set-decoration|sound|story|visual-effects|writer)\/[A-Za-z0-9-_]+(?:\/(by|language|country|decade|genre|on|year)\/[A-Za-z0-9-_\/]+)?\/(?:page\/\d+\/?)?)$/.test(
            currentURL
        )
    ) {
        // Letterboxd person page
        letterboxdPersonPageInjector()
    }
})()