Greasy Fork is available in English.

Plex GUID Grabber

Grab the GUID of a Plex entry on demand

// ==UserScript==
// @name        Plex GUID Grabber
// @namespace   @soitora/plex-guid-grabber
// @description Grab the GUID of a Plex entry on demand
// @version     3.5.0
// @license     MPL-2.0
// @icon        https://app.plex.tv/desktop/favicon.ico
// @homepageURL https://soitora.com/Plex-GUID-Grabber/
// @include     *:32400/*
// @include     *://plex.*/*
// @include     https://app.plex.tv/*
// @require     https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.11/clipboard.min.js
// @require     https://cdn.jsdelivr.net/npm/sweetalert2@11
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @grant       GM_xmlhttpRequest
// @grant       GM_getResourceText
// @grant       GM_setClipboard
// @run-at      document-end
// ==/UserScript==

GM_addStyle(`button[id$="-guid-button"],
button[id$="-yaml-button"] {
    margin-right: 4px;
}

button[id$="-guid-button"]:not([id="imdb-guid-button"]):hover img,
button[id$="-yaml-button"]:not([id="imdb-yaml-button"]):hover img {
    filter: invert(100%) grayscale(100%) contrast(120%);
}

button[id="imdb-guid-button"]:hover img,
button[id="imdb-yaml-button"]:hover img {
    filter: grayscale(100%) contrast(120%);
}

button[id="imdb-guid-button"] img,
button[id="imdb-yaml-button"] img {
    width: 30px !important;
    height: 30px !important;
}

.pgg-toast-container {
    min-width: 400px !important;
    max-width: 800px !important;
}

.pgg-toast-yaml {
    white-space: pre-wrap;
    font-family: monospace;
}
`);

// Initialize GM values if they don't exist
function initializeGMValues() {
    if (GM_getValue("USE_SOCIAL_BUTTONS") === undefined) {
        GM_setValue("USE_SOCIAL_BUTTONS", true);
        console.log(LOG_PREFIX, LOG_STYLE, "Created USE_SOCIAL_BUTTONS storage");
    }

    if (GM_getValue("SOCIAL_BUTTON_SEPARATION") === undefined) {
        GM_setValue("SOCIAL_BUTTON_SEPARATION", true);
        console.log(LOG_PREFIX, LOG_STYLE, "Created SOCIAL_BUTTON_SEPARATION storage");
    }

    if (GM_getValue("USE_PAS") === undefined) {
        GM_setValue("USE_PAS", false);
        console.log(LOG_PREFIX, LOG_STYLE, "Created USE_PAS storage");
    }

    if (GM_getValue("TMDB_API_READ_ACCESS_TOKEN") === undefined) {
        GM_setValue("TMDB_API_READ_ACCESS_TOKEN", "");
        console.log(LOG_PREFIX, LOG_STYLE, "Created TMDB_API_READ_ACCESS_TOKEN storage");
    }

    if (GM_getValue("TMDB_LANGUAGE") === undefined) {
        GM_setValue("TMDB_LANGUAGE", "en-US");
        console.log(LOG_PREFIX, LOG_STYLE, "Created TMDB_LANGUAGE storage");
    }

    if (GM_getValue("TVDB_API_KEY") === undefined) {
        GM_setValue("TVDB_API_KEY", "");
        console.log(LOG_PREFIX, LOG_STYLE, "Created TVDB_API_KEY storage");
    }

    if (GM_getValue("TVDB_SUBSCRIBER_PIN") === undefined) {
        GM_setValue("TVDB_SUBSCRIBER_PIN", "");
        console.log(LOG_PREFIX, LOG_STYLE, "Created TVDB_SUBSCRIBER_PIN storage");
    }

    if (GM_getValue("TVDB_LANGUAGE") === undefined) {
        GM_setValue("TVDB_LANGUAGE", "eng");
        console.log(LOG_PREFIX, LOG_STYLE, "Created TVDB_LANGUAGE storage");
    }
}

// SweetAlert2 Toast
const Toast = Swal.mixin({
    toast: true,
    position: "bottom-right",
    showConfirmButton: false,
    timer: 5000,
    timerProgressBar: true,
    width: "auto",
    customClass: {
        container: "pgg-toast-container",
    },
});

// Variables
let rightButtonContainer = null;

// Constants
const LOG_PREFIX = "%c🔍 PGG";
const DEBUG_PREFIX = "%c🔍 PGG %cDebug";
const ERROR_PREFIX = "%c🔍 PGG %cError";
const LOG_STYLE = "color: cyan;";
const COLOR_GREEN = "color: lime; font-weight: bold;";
const COLOR_CYAN = "color: cyan; font-weight: bold;";
const ERROR_STYLE = "color: cyan; font-weight: bold; background-color: red;";
const DEBOUNCE_DELAY = 100;
const BUTTON_FADE_DELAY = 50;
const BUTTON_MARGIN = "8px";

// User configuration - Set these values in your userscript manager
const USE_SOCIAL_BUTTONS = GM_getValue("USE_SOCIAL_BUTTONS", true);
const SOCIAL_BUTTON_SEPARATION = GM_getValue("SOCIAL_BUTTON_SEPARATION", true);
const USE_PAS = GM_getValue("USE_PAS", false);
const TMDB_API_READ_ACCESS_TOKEN = GM_getValue("TMDB_API_READ_ACCESS_TOKEN", "");
const TMDB_LANGUAGE = GM_getValue("TMDB_LANGUAGE", "en-US");
const TVDB_API_KEY = GM_getValue("TVDB_API_KEY", "");
const TVDB_SUBSCRIBER_PIN = GM_getValue("TVDB_SUBSCRIBER_PIN", "");
const TVDB_LANGUAGE = GM_getValue("TVDB_LANGUAGE", "eng");

// Initialize
console.log(LOG_PREFIX, LOG_STYLE, "Plex GUID Grabber v3.5.0");
initializeGMValues();

const siteConfig = {
    plex: {
        id: "plex-guid-button",
        name: "Plex",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/plex.webp",
        buttonLabel: "Copy Plex GUID",
        visible: ["album", "artist", "movie", "season", "episode", "show"],
        isYamlButton: false,
        isSocialButton: false,
    },
    imdb: {
        id: "imdb-social-button",
        name: "IMDb",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/imdb.webp",
        buttonLabel: "Open IMDB",
        visible: ["movie", "show"],
        isYamlButton: false,
        isSocialButton: true,
    },
    tmdb: {
        id: "tmdb-social-button",
        name: "TMDB",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tmdb-small.webp",
        buttonLabel: "Open TMDB",
        visible: ["movie", "show"],
        isYamlButton: false,
        isSocialButton: true,
    },
    tvdb: {
        id: "tvdb-social-button",
        name: "TVDB",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tvdb.webp",
        buttonLabel: "Open TVDB",
        visible: ["movie", "show"],
        isYamlButton: false,
        isSocialButton: true,
    },
    mbid: {
        id: "musicbrainz-social-button",
        name: "MusicBrainz",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/musicbrainz.webp",
        buttonLabel: "Open MusicBrainz",
        visible: ["album", "artist"],
        isYamlButton: false,
        isSocialButton: true,
    },
    anidb: {
        id: "anidb-social-button",
        name: "AniDB",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/anidb.webp",
        buttonLabel: "Open AniDB",
        visible: ["show", "movie"],
        isYamlButton: false,
        isSocialButton: true,
    },
    youtube: {
        id: "youtube-social-button",
        name: "YouTube",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/youtube.webp",
        buttonLabel: "Open YouTube",
        visible: ["movie", "show", "episode"],
        isYamlButton: false,
        isSocialButton: true,
    },
    tmdbYaml: {
        id: "tmdb-yaml-button",
        name: "TMDB YAML",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tmdb-pas.webp",
        buttonLabel: "Copy TMDB YAML",
        visible: ["movie", "show"],
        isYamlButton: true,
        isSocialButton: false,
    },
    tvdbYaml: {
        id: "tvdb-yaml-button",
        name: "TVDB YAML",
        icon: "https://raw.githubusercontent.com/Soitora/Plex-GUID-Grabber/main/.github/images/tvdb-pas.webp",
        buttonLabel: "Copy TVDB YAML",
        visible: ["movie", "show"],
        isYamlButton: true,
        isSocialButton: false,
    },
};

function handleButtons(metadata, pageType, guid) {
    const leftButtonContainer = $(document).find(".PageHeaderLeft-pageHeaderLeft-GB_cUK");
    const rightButtonContainer = $(document).find(".PageHeaderRight-pageHeaderRight-j9Yjqh");
    console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Button container found:", rightButtonContainer.length > 0);

    if (!rightButtonContainer.length || $("#" + siteConfig.plex.id).length) return;

    const $directory = $(metadata).find("Directory, Video").first();
    const title = $directory.attr("parentTitle") || $directory.attr("title");

    const buttons = createButtonsConfig(guid, pageType, metadata);

    Object.entries(buttons).forEach(([site, { handler, config }]) => {
        if (config.visible.includes(pageType)) {
            if (config.isYamlButton && !USE_PAS) return;

            let shouldShow = true;
            if (config.isYamlButton) {
                const apiSite = site === "tmdbYaml" ? "tmdb" : "tvdb";
                shouldShow = !!guid[apiSite];
            }

            const $button = createButtonElement(config, shouldShow, guid[site], title);

            if ($button) {
                if (site === "plex") {
                    $button.on("click", () => handlePlexButtonClick(guid[site], config, title));
                } else if (config.isYamlButton) {
                    $button.on("click", async () => handleYamlButtonClick(metadata, site, pageType, guid, title));
                } else {
                    $button.on("click", (e) => handler(e));
                }

                appendButtonToContainer($button, config, rightButtonContainer, leftButtonContainer);

                setTimeout(() => {
                    $button.css("opacity", 1);
                }, BUTTON_FADE_DELAY);
            }
        }
    });
}

function createButtonsConfig(guid, pageType, metadata) {
    return Object.keys(siteConfig).reduce((acc, site) => {
        acc[site] = {
            handler: (event) => handleButtonClick(event, site, guid[site], pageType, metadata),
            config: siteConfig[site],
        };
        return acc;
    }, {});
}

function createButtonElement(config, shouldShow, guid, title) {
    if (!USE_SOCIAL_BUTTONS && config.isSocialButton) {
        return null;
    }

    const buttonClasses = ["_1v4h9jl0", "_76v8d62", "_76v8d61", "_76v8d68", "tvbry61", "_76v8d6g", "_76v8d6h", "_1v25wbq1g", "_1v25wbq18"].join(" ");

    const imageContainerClasses = ["_1h4p3k00", "_1v25wbq8", "_1v25wbq1w", "_1v25wbq1g", "_1v25wbq1c", "_1v25wbq14", "_1v25wbq3g", "_1v25wbq2g"].join(" ");

    return $("<button>", {
        id: config.id,
        "aria-label": config.buttonLabel,
        class: buttonClasses,
        css: {
            marginRight: BUTTON_MARGIN,
            display: (config.isYamlButton ? shouldShow : guid) ? "block" : "none",
            opacity: 0,
            transition: "opacity 0.3s ease-in-out",
        },
        html: `
            <div class="${imageContainerClasses}">
                <img src="${config.icon}" alt="${config.buttonLabel}" title="${config.buttonLabel}" style="width: 32px; height: 32px;">
            </div>
        `,
    });
}

// Utility function for clipboard operations
function copyToClipboard(text, successMessage, errorMessage) {
    const formattedText = text.replace(/\n/g, "<br>");

    // Attempt to use clipboard.js
    const tempButton = document.createElement("button");
    const clipboard = new ClipboardJS(tempButton, {
        text: () => text,
    });

    clipboard.on("success", () => {
        Toast.fire({
            icon: "success",
            title: successMessage,
            html: `<span class="pgg-toast-yaml"><strong>Copied Content:</strong><br>${formattedText}</span>`,
        });
        clipboard.destroy();
    });

    clipboard.on("error", () => {
        // Fallback to GM_setClipboard
        try {
            GM_setClipboard(text);
            Toast.fire({
                icon: "success",
                title: successMessage,
                html: `<span class="pgg-toast-yaml"><strong>Copied Content:</strong><br>${formattedText}</span>`,
            });
        } catch (error) {
            console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to copy with GM_setClipboard:", error);
            // Fallback to native clipboard API
            navigator.clipboard
                .writeText(text)
                .then(() => {
                    Toast.fire({
                        icon: "success",
                        title: successMessage,
                        html: `<span class="pgg-toast-yaml"><strong>Copied Content:</strong><br>${formattedText}</span>`,
                    });
                })
                .catch((err) => {
                    console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to copy with native clipboard API:", err);
                    Toast.fire({
                        icon: "error",
                        title: errorMessage,
                        html: err.message,
                    });
                });
        }
    });

    // Trigger the clipboard.js copy action
    tempButton.click();
}

function handlePlexButtonClick(guid, config, title) {
    console.log(LOG_PREFIX, LOG_STYLE, "GUID Output:", guid);
    const successMessage = `Copied ${config.name} guid to clipboard.`;
    const errorMessage = "Failed to copy guid";
    copyToClipboard(guid, successMessage, errorMessage);
}

async function handleYamlButtonClick(metadata, site, pageType, guid, title) {
    try {
        const yamlOutput = await generateYamlOutput(metadata, site, pageType, guid);
        console.log(LOG_PREFIX, LOG_STYLE, "YAML Output:\n", yamlOutput);
        if (yamlOutput) {
            const successMessage = `Copied ${siteConfig[site].name} output to clipboard.`;
            const errorMessage = "Failed to copy YAML output";
            copyToClipboard(yamlOutput, successMessage, errorMessage);
        }
    } catch (error) {
        console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to generate YAML:", error);
        Toast.fire({
            icon: "error",
            title: "Failed to generate YAML",
            html: error.message,
        });
    }
}

function appendButtonToContainer($button, config, rightButtonContainer, leftButtonContainer) {
    if (config.isYamlButton || config.id === siteConfig.plex.id) {
        rightButtonContainer.prepend($button);
    } else {
        if (SOCIAL_BUTTON_SEPARATION) {
            leftButtonContainer.append($button);
        } else {
            rightButtonContainer.prepend($button);
        }
    }
}

function checkApiKeys(site) {
    if (site === "tmdb" && !TMDB_API_READ_ACCESS_TOKEN) {
        Toast.fire({
            icon: "error",
            title: "TMDB Read Access Token Missing",
            html: "Please set your TMDB Read Access Token in the userscript settings",
        });
        return false;
    }
    if (site === "tvdb" && !TVDB_API_KEY) {
        Toast.fire({
            icon: "error",
            title: "TVDB API Key Missing",
            html: "Please set your TVDB API key in the userscript settings",
        });
        return false;
    }
    return true;
}

async function handleButtonClick(event, site, guid, pageType, metadata) {
    console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Button clicked:", site, guid, pageType);

    let title = $(metadata).find("Directory, Video").first();
    title = title.attr("parentTitle") || title.attr("title");

    const urlMap = {
        imdb: `https://www.imdb.com/title/${guid}/`,
        tmdb: pageType === "movie" ? `https://www.themoviedb.org/movie/${guid}` : `https://www.themoviedb.org/tv/${guid}`,
        tvdb: pageType === "movie" ? `https://www.thetvdb.com/dereferrer/movie/${guid}` : `https://www.thetvdb.com/dereferrer/series/${guid}`,
        mbid: pageType === "album" ? `https://musicbrainz.org/album/${guid}` : `https://musicbrainz.org/artist/${guid}`,
        anidb: `https://anidb.net/anime/${guid}`,
        youtube: `https://www.youtube.com/watch?v=${guid}`,
    };

    const url = urlMap[site];

    if (!siteConfig[site].visible.includes(pageType)) {
        Toast.fire({
            icon: "warning",
            title: `${siteConfig[site].name} links are not available for ${pageType} pages.`,
        });
        return;
    }

    if (!guid) {
        Toast.fire({
            icon: "warning",
            title: `No ${siteConfig[site].name} GUID found for this item.`,
        });
        return;
    }

    if (url) {
        const ctrlClick = event.ctrlKey || event.metaKey;
        const newTab = window.open(url, "_blank");

        if (!ctrlClick) {
            newTab.focus();
        }

        Toast.fire({
            icon: "success",
            title: `Opened ${siteConfig[site].name} in a new tab.`,
        });
    }
}

async function getGuid(metadata) {
    if (!metadata) return null;

    const $directory = $(metadata).find("Directory, Video").first();
    console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Directory/Video:", $directory[0]);
    //console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Directory/Video outerHTML:", $directory[0]?.outerHTML);
    //console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Directory/Video innerHTML:", $directory[0]?.innerHTML);

    if (!$directory.length) {
        console.error(ERROR_PREFIX, ERROR_STYLE, "Main element not found in XML");
        return null;
    }

    const guid = initializeGuid($directory);

    if (guid.plex?.startsWith("com.plexapp.agents.hama://")) {
        extractHamaGuid(guid, guid.plex);
    }

    $directory.find("Guid").each(function () {
        const guidId = $(this).attr("id");
        if (guidId) {
            const [service, value] = guidId.split("://");
            if (service && value) {
                extractGuid(guid, service, value);
            }
        }
    });

    return guid;
}

function initializeGuid($directory) {
    return {
        plex: $directory.attr("guid"),
        imdb: null,
        tmdb: null,
        tvdb: null,
        mbid: null,
        anidb: null,
        youtube: null,
    };
}

function extractHamaGuid(guid, plexGuid) {
    const match = plexGuid.match(/com\.plexapp\.agents\.hama:\/\/(\w+)-(\d+)/);
    if (match) {
        extractGuid(guid, match[1], match[2]);
    }
}

function extractGuid(guid, service, value) {
    const normalizedService = service.toLowerCase();
    if (normalizedService.startsWith("tsdb")) {
        guid.tmdb = value;
    } else if (guid.hasOwnProperty(normalizedService)) {
        guid[normalizedService] = value;
    }
}

async function getLibraryMetadata(metadataPoster) {
    const img = metadataPoster.find("img").first();
    if (!img?.length) {
        console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "No image found in metadata poster");
        return null;
    }

    const imgSrc = img.attr("src");
    if (!imgSrc) {
        console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "No src attribute found in image");
        return null;
    }

    const url = new URL(imgSrc);
    const serverUrl = `${url.protocol}//${url.host}`;
    const plexToken = url.searchParams.get("X-Plex-Token");
    const urlParam = url.searchParams.get("url");
    const metadataKey = urlParam?.match(/\/library\/metadata\/(\d+)/)?.[1];

    if (!plexToken || !metadataKey) {
        console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Missing plexToken or metadataKey", { plexToken: !!plexToken, metadataKey: !!metadataKey });
        return null;
    }

    try {
        const response = await fetch(`${serverUrl}/library/metadata/${metadataKey}?X-Plex-Token=${plexToken}`);
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        return new DOMParser().parseFromString(await response.text(), "text/xml");
    } catch (error) {
        console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to fetch metadata:", error.message);
        return null;
    }
}

async function observeMetadataPoster() {
    let isObserving = true;

    const observer = new MutationObserver(
        debounce(async () => {
            if (!isObserving) return;

            if (!window.location.href.includes("%2Flibrary%2Fmetadata%2")) {
                isObserving = false;
                console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Not a metadata page.");
                return;
            }

            const $metadataPoster = $("div[data-testid='metadata-poster']");
            //console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Metadata poster found:", $metadataPoster.length > 0);

            if (!$metadataPoster.length) return;

            isObserving = false;
            const metadata = await getLibraryMetadata($metadataPoster);
            console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Metadata retrieved:", !!metadata);

            const pageType = $(metadata).find("Directory, Video").first().attr("type");
            let title = $(metadata).find("Directory, Video").first();
            title = title.attr("parentTitle") || title.attr("title");

            console.log(LOG_PREFIX, LOG_STYLE, "Type:", pageType);
            console.log(LOG_PREFIX, LOG_STYLE, "Title:", title);

            if (pageType) {
                const guid = await getGuid(metadata);
                console.log(LOG_PREFIX, LOG_STYLE, "Guid:", guid);

                if (guid) {
                    handleButtons(metadata, pageType, guid);
                }
            }
        }, DEBOUNCE_DELAY)
    );

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["data-page-type"],
    });

    const handleNavigation = debounce(() => {
        isObserving = true;
        console.debug(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Navigation detected - resuming observation.");
    }, DEBOUNCE_DELAY);

    $(window).on("hashchange popstate", handleNavigation);
}

function debounce(func, wait) {
    let timeout;
    return function (...args) {
        const context = this;
        clearTimeout(timeout);
        timeout = setTimeout(() => func.apply(context, args), wait);
    };
}

async function getTVDBToken() {
    const LOGIN_URL = "https://api4.thetvdb.com/v4/login";
    const API_KEY = TVDB_API_KEY;
    const PIN = TVDB_SUBSCRIBER_PIN;

    try {
        const response = await fetch(LOGIN_URL, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                Accept: "application/json",
            },
            body: JSON.stringify({ apikey: API_KEY, pin: PIN }),
        });

        //console.log(DEBUG_PREFIX, "TVDB Token Response:", response);

        if (!response.ok) {
            throw new Error(`Login failed: ${response.status} ${response.statusText}`);
        }

        const data = await response.json();
        //console.log(DEBUG_PREFIX, "TVDB Token Data:", data.data);

        return data.data.token;
    } catch (error) {
        console.error(DEBUG_PREFIX, COLOR_CYAN, COLOR_GREEN, "Authentication error:", error);
        return null;
    }
}

async function fetchApiData(url, headers = {}) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            headers: {
                ...headers,
            },
            onload: function (response) {
                if (response.status >= 200 && response.status < 300) {
                    try {
                        const data = JSON.parse(response.responseText);
                        resolve(data);
                    } catch (error) {
                        console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to parse JSON response:", error);
                        Toast.fire({
                            icon: "error",
                            title: "API Error",
                            html: "Failed to parse JSON response",
                        });
                        reject(new Error("Failed to parse JSON response"));
                    }
                } else {
                    console.error(ERROR_PREFIX, ERROR_STYLE, `API error: ${response.status} - ${response.responseText}`);
                    Toast.fire({
                        icon: "error",
                        title: "API Error",
                        html: `Status: ${response.status} - ${response.responseText}`,
                    });
                    reject(new Error(`API error: ${response.status} - ${response.responseText}`));
                }
            },
            onerror: function (error) {
                console.error(ERROR_PREFIX, ERROR_STYLE, "Network error:", error);
                Toast.fire({
                    icon: "error",
                    title: "Network Error",
                    html: error.message,
                });
                reject(new Error(`Network error: ${error}`));
            },
        });
    });
}

async function generateYamlOutput(metadata, site, pageType, guid) {
    const apiSite = site === "tmdbYaml" ? "tmdb" : "tvdb";

    if (!checkApiKeys(apiSite)) return "";

    const mediaType = pageType === "movie" ? "movie" : "tv";
    const $directory = $(metadata).find("Directory, Video").first();
    const plex_guid = $directory.attr("guid");

    try {
        const { title, numberOfSeasons } = await fetchTitleAndSeasons(apiSite, mediaType, guid);
        return constructYamlOutput(title, plex_guid, numberOfSeasons, guid, mediaType);
    } catch (error) {
        console.error(ERROR_PREFIX, ERROR_STYLE, "Error generating YAML output:", error);
        return "";
    }
}

async function fetchTitleAndSeasons(apiSite, mediaType, guid) {
    if (apiSite === "tmdb") {
        return fetchTmdbData(mediaType, guid[apiSite]);
    } else if (apiSite === "tvdb") {
        return fetchTvdbData(mediaType, guid[apiSite]);
    }
}

async function fetchTmdbData(mediaType, tmdbId) {
    const url =
        mediaType === "tv" ? `https://api.themoviedb.org/3/tv/${tmdbId}?language=${TMDB_LANGUAGE}` : `https://api.themoviedb.org/3/movie/${tmdbId}?language=${TMDB_LANGUAGE}`;

    const data = await fetchApiData(url, {
        Accept: "application/json",
        Authorization: `Bearer ${TMDB_API_READ_ACCESS_TOKEN}`,
    });

    const title = mediaType === "tv" ? data.name : data.title;
    const numberOfSeasons = mediaType === "tv" ? data.number_of_seasons || 1 : 1;

    return { title, numberOfSeasons };
}

async function fetchTvdbData(mediaType, tvdbId) {
    const tvdbBearerToken = await getTVDBToken();
    if (!tvdbBearerToken) {
        console.error(ERROR_PREFIX, ERROR_STYLE, "Failed to retrieve TVDB token.");
        return { title: "", numberOfSeasons: 1 };
    }

    const url =
        mediaType === "tv"
            ? `https://api4.thetvdb.com/v4/series/${tvdbId}/translations/${TVDB_LANGUAGE}`
            : `https://api4.thetvdb.com/v4/movies/${tvdbId}/translations/${TVDB_LANGUAGE}`;

    const data = await fetchApiData(url, {
        Accept: "application/json",
        Authorization: `Bearer ${tvdbBearerToken}`,
    });

    const title = data.data.name;
    const numberOfSeasons = mediaType === "tv" ? await fetchTvdbSeasons(tvdbId, tvdbBearerToken) : 1;

    return { title, numberOfSeasons };
}

async function fetchTvdbSeasons(tvdbId, tvdbBearerToken) {
    const episodesData = await fetchApiData(`https://api4.thetvdb.com/v4/series/${tvdbId}/episodes/default/${TVDB_LANGUAGE}`, {
        Accept: "application/json",
        Authorization: `Bearer ${tvdbBearerToken}`,
    });

    const seriesSeasons = new Set();
    episodesData.data.episodes.forEach((episode) => {
        if (episode.seasonNumber !== 0) {
            seriesSeasons.add(episode.seasonNumber);
        }
    });

    return seriesSeasons.size || 1;
}

function constructYamlOutput(title, plex_guid, numberOfSeasons, guid, mediaType) {
    const data = [
        {
            title: title,
            guid: plex_guid,
            seasons: Array.from({ length: numberOfSeasons }, (_, i) => ({
                season: i + 1,
                "anilist-id": 0,
            })),
        },
    ];

    let yamlOutput = jsyaml.dump(data, {
        quotingType: `"`,
        forceQuotes: { title: true },
        indent: 2,
    });

    yamlOutput = yamlOutput.replace(/^(\s*guid: )"([^"]+)"$/gm, "$1$2").trim();

    const url_IMDB = guid.imdb ? `\n  # imdb: https://www.imdb.com/title/${guid.imdb}/` : "";
    const url_TMDB = guid.tmdb ? `\n  # tmdb: https://www.themoviedb.org/${mediaType}/${guid.tmdb}` : "";
    const url_TVDB = guid.tvdb ? `\n  # tvdb: https://www.thetvdb.com/dereferrer/${mediaType === "tv" ? "series" : "movie"}/${guid.tvdb}` : "";

    const guidRegex = /^(\s*guid:.*)$/m;
    return yamlOutput
        .replace(guidRegex, `$1${url_IMDB}${url_TMDB}${url_TVDB}`)
        .replace(/^/gm, "  ")
        .replace(/^\s\s$/gm, "\n");
}

$(document).ready(observeMetadataPoster);