JAVDB/JAVBUS/JAVLIBRARY to Jable.tv Linker (Class Refactor)

Add Jable.tv link button to JAVDB, JAVBUS, JAVLIBRARY, AV01 and AVJOY pages, and cross-site navigation buttons, plus subtitle site links. Refactored as JableLinker class for maintainability.

// ==UserScript==
// @name        JAVDB/JAVBUS/JAVLIBRARY to Jable.tv Linker (Class Refactor)
// @namespace   https://tampermonkey.net/
// @version     2025.10.04
// @description Add Jable.tv link button to JAVDB, JAVBUS, JAVLIBRARY, AV01 and AVJOY pages, and cross-site navigation buttons, plus subtitle site links. Refactored as JableLinker class for maintainability.
// @author      庄引X@https://x.com/zhuangyin8
// @match       https://javdb.com/*
// @match       https://www.javbus.com/*
// @match       https://www.javlibrary.com/*
// @match       https://jable.tv/*
// @match       https://avjoy.me/video/*
// @match       https://www.av01.tv/*
// @match       https://91md.me/*
// @match       https://missav.ws/*
// @match       https://*.btdig.com/*
// @match       https://sukebei.nyaa.si/*
// @match       https://btdig.com/*
// @match       https://en.btdig.com/*
// @match       https://btsow.pics/*
// @grant       GM_addStyle
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @run-at      document-end
// @connect     jable.tv
// @connect     javdb.com
// @connect     javbus.com
// @connect     avjoy.me
// @connect     av01.tv
// @license     MIT
// ==/UserScript==

class JableLinker {
    constructor() {
        this.SITES = {
            JAVDB: "javdb.com",
            JAVBUS: "javbus.com",
            JAVLIBRARY: "javlibrary.com",
            JABLE: "jable.tv",
            AVJOY: "avjoy.me",
            AV01: "av01.tv",
            MDME: "91md.me",
            MISSAV: "missav.ws",
            SUKEBEI: "sukebei.nyaa.si",
            BTDIG: "btdig.com",
            BTSOW: "btsow.pics"
        };

        this.SELECTORS = {
            // JAV Site Selectors
            JAVDB_CODE: ".value", JAVDB_TITLE: ".title", JAVDB_PREVIEW_IMAGES: ".preview-images .tile-item", JAVBUS_SAMPLE_BOX: ".sample-box", JABLE_TITLE: "h4", JAVBUS_TITLE: ".container h3", JAVLIBRARY_TITLE: "h3.text", AVJOY_TITLE: ".video-title h1", AV01_TITLE: "h1", MISSAV_TITLE: ".mt-4 h1",
        };

        this.STYLES = {
            SUKEBEI: `.container .ad,.hdr-link,tr td:nth-child(1),tr td:nth-child(3),tr td:nth-child(6),tr td:nth-child(7),.text-center{display:none !important;} .table {width: 100%;} .torrent-list>tbody>tr>td { white-space: normal; max-width:90vw;} .navbar-form .input-group {position: fixed; left:0;width: 100vw;}`,
            BTDIG: ``,
            BTSOW: `.search {position: sticky !important;top: 80px !important;} .form-inline .input-group {width: 100%;} .hidden-xs:not(.tags-box,.text-right,.search,.search-container)/*,.data-list:not(.detail) .size,.data-list:not(.detail) .date*/{ display: none !important;} .q-container {max-width: 50vw !important;margin-left: 0 !important;}`,
            JAVBUS: `.row > #waterfall, .container { width: 100vw !important; } .masonry #waterfall,.mb20 { display: grid; grid-template-columns: repeat(3, 1fr); } .movie-box { width: auto !important; height: 100% !important; margin: 0 !important; } #waterfall .masonry-brick { position: relative !important; top: 0px !important; left: 0 !important; } .screencap img {width:auto  !important; }.movie-box .photo-frame, .movie-box img { width: 100% !important; height: auto !important; margin: 0 !important;} iframe,.banner728,.banner300,.bcpic2{display:none}`,
            JAVDB: `.container:not(.is-max-desktop):not(.is-max-widescreen) { max-width: 100vw; } .movie-list .item .video-title { white-space: normal; } .moj-content{display:none}`,
            JAVLIBRARY: `#leftmenu > table:last-child,#topbanner11{display:none}`,
            JABLE: `.video-img-box .title,.title{white-space:normal;max-height:auto}.container {max-width: 100vw !important;} .right-sidebar {display: contents;max-width: 500px;} .right-sidebar .video-img-box {display:inline-block !important;} .right-sidebar>.gutter-20>.col-lg-12 {flex: 0 0 25% !important;} .justify-content-center,.h5,iframe,.asg-interstitial,.root--ujvuu ,.text-sponsor,.pb-3 .row .col-6:nth-child(2),.right-sidebar .row .col-6:nth-child(1){display:none}.right-sidebar .video-img-box .img-box {min-width: 100%; max-width: 100%;}.plyr--video {width: 80%;margin: auto;}`,
            AV01: `.group:has(> div.overflow-hidden){display:none} .gap-8 > div{grid-column:span 3 / span 3} .max-w-7xl{max-width: 90vw;}.space-y-4 > div{width: 33vw;display: -webkit-box;}`,
            MDME: `.detail_left ol li {height: 115px; }.detail_left { writing-mode: tb;}.width1200>div{float:none;width:100vw;} .width1200 {width: 100vw; min-width: 100vw; }.detail_right_div ul,.sugetVideo ul {display: grid;grid-template-columns: repeat(4, 1fr);}`,
            AVJOY: `.related-video .thumb-overlay,.related-video .content-info {width: 100%;}.container {max-width: 100vw;} .content-right{ width: 100%;}.content-left {;max-width: 70%;margin:0 auto;} .content-right{display: grid;grid: auto-flow /repeat(4, 1fr) ;} .ad-content-bot,.ad-content-side{display:none}`,
            MISSAV: `.sm\\:container {max-width: 100vw !important;}.order-first{width:100vw !important;} .video-player-container {width: 100%;} 
            .xl\\:grid-cols-4 {gap: 10px;}
            .-mt-6 {width: 80%;height: 80%;margin: 0 auto !important;}.order-last,.order-last >div {max-width: 100% !important;min-width: 100% !important;} 
            .related-videos,.order-last > div  {display: grid;grid-template-columns: repeat(4, 1fr);gap: 10px;} 
            .ad-banner,.advertisement,.ads,.list-none,.space-y-6{display:none !important;} 
            .content-without-search > .flex, .order-last > div >.flex {flex-direction: column;}
            .ml-6 {margin-left: 0 !important;}.mb-6>div {width:100% !important}
            .truncate {white-space: normal;}`
        };


        const commonButtons = {
            javdb: true, javbus: true, javlibrary: true, jable: true, av01: true, avjoy: true, avsubtitles: true, subtitlecat: true, missav: true, btdig: true, btsow: true, hayav: true
        };

        this.SITE_CONFIG = {
            [this.SITES.JAVDB]: { titleSelector: this.SELECTORS.JAVDB_TITLE, codeSelector: this.SELECTORS.JAVDB_CODE, style: this.STYLES.JAVDB, pathCheck: "/v/", buttons: { ...commonButtons, javdb: false } },
            [this.SITES.JAVBUS]: { titleSelector: this.SELECTORS.JAVBUS_TITLE, style: this.STYLES.JAVBUS, pathCheck: /[A-Za-z]+-\d+/, buttons: { ...commonButtons, javbus: false } },
            [this.SITES.JAVLIBRARY]: { titleSelector: this.SELECTORS.JAVLIBRARY_TITLE, style: this.STYLES.JAVLIBRARY, pathCheck: "/?v=", buttons: { ...commonButtons } },
            [this.SITES.JABLE]: { titleSelector: this.SELECTORS.JABLE_TITLE, style: this.STYLES.JABLE, pathCheck: "/videos/", buttons: { ...commonButtons, jable: false }, isVideoSite: true },
            [this.SITES.AVJOY]: { titleSelector: this.SELECTORS.AVJOY_TITLE, style: this.STYLES.AVJOY, pathCheck: "/video/", buttons: { ...commonButtons, avjoy: false }, isVideoSite: true },
            [this.SITES.AV01]: { titleSelector: this.SELECTORS.AV01_TITLE, style: this.STYLES.AV01, pathCheck: "/video/", buttons: { ...commonButtons, av01: false }, isVideoSite: true },
            [this.SITES.MDME]: { style: this.STYLES.MDME, pathCheck: "/", buttons: { ...commonButtons, javdb: false, javbus: false } },
            [this.SITES.MISSAV]: { titleSelector: this.SELECTORS.MISSAV_TITLE, style: this.STYLES.MISSAV, pathCheck: "/cn/", buttons: { ...commonButtons, missav: false }, isVideoSite: true },
            // Magnet Site Configs
            [this.SITES.SUKEBEI]: { style: this.STYLES.SUKEBEI, isMagnetSite: true, magnetSelectors: { list: "tbody tr", hashLink: "td:nth-child(3) a:last-child", title: "td:nth-child(2) a", size: "td:nth-child(4)", date: "td:nth-child(5)", insertPoint: "td:nth-child(3)" } },
            [this.SITES.BTDIG]: { style: this.STYLES.BTDIG, isMagnetSite: true, magnetSelectors: { list: ".one_result", hashLink: ".torrent_name a", title: ".torrent_name a", size: ".torrent_size", date: ".torrent_age", insertPoint: ".torrent_magnet" } },
            [this.SITES.BTSOW]: { style: this.STYLES.BTSOW, isMagnetSite: true, magnetSelectors: { list: ".data-list .row", hashLink: "a", title: "a", size: ".size", date: ".date", insertPoint: "a" } },
        };
    }

    // ==================== 2. UTILITY FUNCTIONS ====================
    utils = {
        isIncludes: (domain) => window.location.hostname.includes(domain),
        isValidPath: (pathCheck) => typeof pathCheck === "string" ? window.location.href.includes(pathCheck) : window.location.href.match(pathCheck),
        waitForElement: (selector, timeoutMs = 3000) => new Promise((resolve, reject) => {
            const existing = document.querySelector(selector);
            if (existing) return resolve(existing);
            const observer = new MutationObserver(() => {
                const el = document.querySelector(selector);
                if (el) { observer.disconnect(); resolve(el); }
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            if (timeoutMs > 0) setTimeout(() => { observer.disconnect(); reject(new Error(`waitForElement timeout: ${selector}`)); }, timeoutMs);
        }),
        moveElementToNextSiblingFirstChild: (selector) => {
            const element = document.querySelector(selector);

            if (!element) {
                console.error("The provided element is invalid.");
                return;
            }
            const nextSibling = element.nextElementSibling;

            if (!nextSibling) {
                console.warn("The element has no next sibling to move to.");
                return;
            }

            nextSibling.insertBefore(element, nextSibling.firstElementChild);
        },
        getCodeFromCurrentSite: (domain, selector) => {
            if (!this.utils.isIncludes(domain)) return null;
            const titleElement = document.querySelector(selector);
            const title = titleElement ? titleElement.textContent.trim() : "";
            let codeMatch = title.match(/[A-Za-z]+-\d+/) || document.title.match(/[A-Za-z]+-\d+/);
            if (codeMatch && codeMatch[0]) return codeMatch[0];
            const lastSegment = decodeURIComponent(window.location.pathname.split("/").filter(Boolean).pop() || "");
            codeMatch = lastSegment.match(/[A-Za-z]+-\d+/);
            return codeMatch ? codeMatch[0] : null;
        },
        gmFetch: (url, options = {}) => new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url,
                ...options,
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response);
                    } else {
                        reject(new Error(`Request failed with status ${response.status}`));
                    }
                },
                onerror: (error) => reject(new Error("Network error during request", { cause: error })),
            });
        }),
        NewElement: (tagName = "div", config = {}) => {
            const element = document.createElement(tagName);
            if (config.textContent) element.textContent = config.textContent;
            if (config.className) element.className = config.className;
            if (config.id) element.id = config.id;
            if (config.attributes) Object.entries(config.attributes).forEach(([key, value]) => element.setAttribute(key, value));
            if (config.events) Object.entries(config.events).forEach(([event, handler]) => element.addEventListener(event, handler));
            if (config.style) Object.assign(element.style, config.style);
            return element;
        },

    };

    // ==================== 3. DOM & UI MANIPULATION ====================
    dom = {
        injectStyleOnce: (style, idKey) => {
            if (!style || document.getElementById(idKey)) return;
            const styleElement = this.utils.NewElement("style", { id: idKey, textContent: style });
            document.head.appendChild(styleElement);
        },

        linkButtonDefs: {
            javbus: { text: "JAVBUS", color: "#3498db", url: (code) => `https://www.javbus.com/${code.replace("DSVR", "3DSVR")}` },
            javlibrary: { text: "Javlibrary", color: "#6027ae", url: (code) => `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${code}` },
            jable: { text: "Jable", color: "#27ae60", url: (code) => `https://jable.tv/videos/${code}/` },
            av01: { text: "AV01", color: "#9b59b6", url: (code) => `https://www.av01.tv/cn/search?q=${code}` },
            avjoy: { text: "AVJOY", color: "#f39c12", url: (code) => `https://avjoy.me/search/videos/${code}` },
            avsubtitles: { text: "AVSubtitles", color: "#e91e63", url: (code) => `https://www.avsubtitles.com/search_results.php?search=${code}` },
            subtitlecat: { text: "SubtitleCat", color: "#ff9800", url: (code) => `https://www.subtitlecat.com/index.php?search=${code}` },
            missav: { text: "MISSAV", color: "#3f51b5", url: (code) => `https://missav.ws/cn/search/${code}` },
            btdig: { text: "BTDIG", color: "#795548", url: (code) => `https://btdig.com/search?q=${code}` },
            btsow: { text: "BTSOW", color: "#607d8b", url: (code) => `https://btsow.pics/#/search/${code}` },
            hayav: { text: "HAYAV", color: "#07d8b6", url: (code) => `https://hayav.com/search/${code}` }
        },

        addLinkButtons: async (code, selector, buttonsConfig) => {
            const titleElement = await this.utils.waitForElement(selector).catch(() => document.querySelector(selector));
            if (!code || !titleElement) return;

            const buttonContainer = this.utils.NewElement("div", {
                id: "cross-site-button-container",
                style: { display: "inline-block", marginLeft: "10px" }
            });

            // JAVDB special handling (async search)
            if (buttonsConfig?.javdb) {
                try {
                    const response = await this.utils.gmFetch(`https://javdb.com/search?q=${encodeURIComponent(code)}`);
                    const doc = new DOMParser().parseFromString(response.responseText, "text/html");
                    const videoLink = doc.querySelector('a[href*="/v/"]');
                    const url = videoLink ? `https://javdb.com${videoLink.getAttribute("href")}` : `https://javdb.com/search?q=${encodeURIComponent(code)}`;
                    const btn = this.dom.createSingleButton("JAVDB", videoLink ? "#e74c3c" : "#e67e22", url);
                    buttonContainer.appendChild(btn);
                } catch (error) {
                    console.error("JAVDB search failed:", error);
                    const btn = this.dom.createSingleButton("搜JAVDB", "#e67e22", `https://javdb.com/search?q=${encodeURIComponent(code)}`);
                    buttonContainer.appendChild(btn);
                }
            }

            // Other sites
            Object.entries(this.dom.linkButtonDefs).forEach(([key, def]) => {
                if (buttonsConfig?.[key]) {
                    const btn = this.dom.createSingleButton(def.text, def.color, def.url(code));
                    buttonContainer.appendChild(btn);
                }
            });

            titleElement.parentNode.insertBefore(buttonContainer, titleElement.nextSibling);
        },

        createSingleButton: (text, bgColor, url) => {
            return this.utils.NewElement("a", {
                textContent: text,
                attributes: { href: url, target: "_blank" },
                style: { marginRight: "8px", padding: "4px 8px", color: "#fff", background: bgColor, border: "none", borderRadius: "3px", cursor: "pointer", textDecoration: "none", fontSize: "14px" }
            });
        },

        enhanceMagnetLinks: (config) => {
            const processNode = (node) => {
                if (!node || node.__tm_magnet_enhanced__) return;

                const selectors = config.magnetSelectors;
                const hashLinkEl = node.querySelector(selectors.hashLink);
                if (!hashLinkEl) return;

                const hashMatch = hashLinkEl.href.match(/[0-9a-fA-F]{40}/);
                if (!hashMatch) return;
                const hash = hashMatch[0].toLowerCase();

                const title = node.querySelector(selectors.title)?.innerText || `magnet-${hash.substring(0, 8)}`;
                const size = node.querySelector(selectors.size)?.innerText || "";
                const date = node.querySelector(selectors.date)?.innerText || "";
                const magnetUrl = `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(title)} ${size} ${date}`;

                const insertPoint = node.querySelector(selectors.insertPoint);
                if (!insertPoint) return;

                const parent = insertPoint.parentElement;

                // Copy button
                const copyBtn = this.utils.NewElement("a", {
                    textContent: "📋",
                    attributes: { href: "#", title: "Copy Magnet Link" },
                    style: { marginLeft: "8px", cursor: "pointer", textDecoration: "none" },
                    events: {
                        click: async (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            await navigator.clipboard.writeText(decodeURIComponent(magnetUrl));
                            e.currentTarget.textContent = "✅";
                            setTimeout(() => { e.currentTarget.textContent = "📋"; }, 2000);
                        }
                    }
                });

                // Preview button
                const previewBtn = this.utils.NewElement("a", {
                    textContent: "👁️",
                    attributes: { href: "#", title: "Preview Torrent Content" },
                    style: { marginLeft: "6px", cursor: "pointer", textDecoration: "none" },
                    events: {
                        click: (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            this.drawer.open(`https://magnet.pics/m/${hash}`, title);
                        }
                    }
                });

                parent.append(copyBtn, previewBtn);
                node.__tm_magnet_enhanced__ = true;
            };

            const run = () => document.querySelectorAll(config.magnetSelectors.list).forEach(processNode);
            run(); // Initial run

            // Observe for dynamically added nodes
            const observer = new MutationObserver((mutations) => {
                mutations.forEach(m => {
                    if (m.addedNodes.length) run();
                });
            });
            observer.observe(document.body, { childList: true, subtree: true });
        },
        replacePreviewImagesFromJAVBUS: async function () {
            try {
                const code = document.querySelector(this.SELECTORS.JAVDB_CODE)?.textContent.trim();
                if (!code) {
                    console.log("JableLinker: JAVDB code not found for image replacement.");
                    return;
                }

                const javbusUrl = `https://www.javbus.com/${code}`;
                const userCookie = await GM_getValue("javbus_cookie", "");
                const headers = {};
                if (userCookie) {
                    headers["Cookie"] = userCookie;
                }

                const response = await this.utils.gmFetch(javbusUrl, { headers });
                const doc = new DOMParser().parseFromString(response.responseText, "text/html");
                const anchorElements = Array.from(doc.querySelectorAll(this.SELECTORS.JAVBUS_SAMPLE_BOX));
                const javbusHrefs = anchorElements.map(a => a.getAttribute("href")).filter(Boolean);

                if (javbusHrefs.length > 0) {
                    if ((javbusHrefs[0] || "").includes("javdb")) {
                        console.log("JableLinker: JAVBUS links back to JAVDB, skipping image replacement.");
                        return;
                    }

                    const previewImagesContainer = document.querySelector(".preview-images");
                    if (previewImagesContainer) {
                        Array.from(document.querySelectorAll(this.SELECTORS.JAVDB_PREVIEW_IMAGES)).forEach(item => item.remove());

                        javbusHrefs.forEach(href => {
                            const a = this.utils.NewElement("a", {
                                className: "tile-item",
                                attributes: {
                                    href: href,
                                    "data-fancybox": "gallery",
                                    "data-caption": ""
                                }
                            });
                            const img = this.utils.NewElement("img", {
                                attributes: {
                                    src: href,
                                    alt: "",
                                    loading: "lazy"
                                }
                            });
                            a.appendChild(img);
                            previewImagesContainer.appendChild(a);
                        });
                        console.log(`JableLinker: Replaced preview images with ${javbusHrefs.length} items from JAVBUS for code: ${code}`);
                    }
                } else {
                    console.log(`JableLinker: No preview images found on JAVBUS for code: ${code}`);
                }
            } catch (error) {
                console.error("JableLinker: Error during replacePreviewImagesFromJAVBUS:", error);
            }
        }
    };

    // ==================== 4. DRAWER CLASS FOR PREVIEWS ====================
    drawer = (() => {
        let drawerInstance = null;

        const create = (options) => {
            const { direction = 'right', width = '40vw', title = 'Preview' } = options;

            if (drawerInstance) {
                try { document.body.removeChild(drawerInstance.element); } catch (e) { }
            }

            const element = this.utils.NewElement('div', {
                style: {
                    position: 'fixed', top: '0', [direction]: '0', width, height: '100vh', background: 'white',
                    boxShadow: '0 0 10px rgba(0,0,0,0.1)', transform: `translateX(${direction === 'left' ? '-100%' : '100%'})`,
                    transition: 'transform 0.3s ease', zIndex: '2147483647'
                }
            });

            const header = this.utils.NewElement('div', {
                style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderBottom: '1px solid #eee' }
            });
            const titleEl = this.utils.NewElement('h5', { textContent: title, style: { margin: '0' } });
            const closeBtn = this.utils.NewElement('button', {
                textContent: '×',
                style: { background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: '#666' }
            });
            const content = this.utils.NewElement('div', {
                style: { padding: '0', height: 'calc(100vh - 60px)', overflowY: 'auto' }
            });

            closeBtn.onclick = () => close();

            header.append(titleEl, closeBtn);
            element.append(header, content);
            document.body.appendChild(element);

            drawerInstance = { element, content };
            return drawerInstance;
        };

        const open = (url, title, width, direction) => {
            const instance = create({ title, width, direction });
            instance.content.innerHTML = `<iframe width='100%' height='100%' src='${url}' style='border:none;'></iframe>`;
            requestAnimationFrame(() => instance.element.style.transform = 'translateX(0)');
        };

        const close = () => {
            if (!drawerInstance) return;
            const { element } = drawerInstance;
            const direction = element.style.left === '0px' ? 'left' : 'right';
            element.style.transform = `translateX(${direction === 'left' ? '-100%' : '100%'})`;
            setTimeout(() => {
                if (drawerInstance && drawerInstance.element === element) {
                    drawerInstance.content.innerHTML = '';
                }
            }, 300);
        };

        return { open };
    })();

    // ==================== NEW SUBTITLES MODULE ====================
    subtitles = {
        _inited: false,
        _state: {
            vttText: null,
            subtitleLabel: null,
            objectUrlsByVideo: new WeakMap(),
            hasLoadedSubtitle: false
        },
        _isLikelyVtt: (text) => {
            const firstLine = (text || "").split(/\r?\n/)[0]?.trim();
            return firstLine === "WEBVTT" || (firstLine || "").startsWith("WEBVTT");
        },
        _convertSrtToVtt: (srtText) => {
            let text = (srtText || "").replace(/\r\n?/g, "\n");
            text = text.replace(/^\uFEFF/, "");
            text = text.replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g, "$1.$2");
            text = text.replace(/\s+-->\s+/g, " --> ");
            text = text.replace(/\n\d+\n(?=\d\d:\d\d:\d\d\.\d{3}\s+-->)/g, "\n");
            text = text.replace(/\n{3,}/g, "\n\n");
            return "WEBVTT\n\n" + text.trim() + "\n";
        },
        _convertAssToVtt: (assText) => {
            let text = (assText || "").replace(/\r\n?/g, "\n");
            text = text.replace(/^\uFEFF/, "");
            const eventsMatch = text.match(/\[Events\][\s\S]*?(?=\[|$)/);
            if (!eventsMatch) return "WEBVTT\n\n";
            const eventsSection = eventsMatch[0];
            const dialogueLines = eventsSection.match(/^Dialogue:.*$/gm) || [];
            let vttContent = "WEBVTT\n\n";
            dialogueLines.forEach((line) => {
                const parts = line.split(",");
                if (parts.length < 10) return;
                const startTime = parts[1];
                const endTime = parts[2];
                const textContent = parts.slice(9).join(",");
                const formatTime = (timeStr) => {
                    const timeMatch = timeStr.match(/(\d+):(\d{2}):(\d{2})\.(\d{2})/);
                    if (!timeMatch) return timeStr;
                    const [, h, m, s, cs] = timeMatch;
                    return `${h.padStart(2, "0")}:${m.padStart(2, "0")}:${s.padStart(2, "0")}.${(parseInt(cs) * 10).toString().padStart(3, "0")}`;
                };
                let cleanText = textContent.replace(/\{[^}]*\}/g, "").replace(/\\N/g, "\n").replace(/\\n/g, "\n").replace(/\\h/g, " ").trim();
                if (cleanText) vttContent += `${formatTime(startTime)} --> ${formatTime(endTime)}\n${cleanText}\n\n`;
            });
            return vttContent.trim() + "\n";
        },
        _isLikelyAss: (text) => {
            return (text || "").split(/\r?\n/)[0]?.trim() === "[Script Info]" || text.includes("[Events]") || text.includes("Dialogue:");
        },
        _toVtt: (text) => {
            if (this.subtitles._isLikelyVtt(text)) return text;
            if (this.subtitles._isLikelyAss(text)) return this.subtitles._convertAssToVtt(text);
            return this.subtitles._convertSrtToVtt(text);
        },
        _revokeExistingObjectUrl: (video) => {
            try {
                const url = this.subtitles._state.objectUrlsByVideo.get(video);
                if (url) {
                    URL.revokeObjectURL(url);
                    this.subtitles._state.objectUrlsByVideo.delete(video);
                }
            } catch (_) { }
        },
        _removeExistingLocalTracks: (video) => {
            video.querySelectorAll('track[data-local-subtitle="1"]').forEach((t) => t.remove());
        },
        _attachSubtitleToVideo: (video) => {
            if (!this.subtitles._state.vttText) return;
            this.subtitles._revokeExistingObjectUrl(video);
            this.subtitles._removeExistingLocalTracks(video);
            const blob = new Blob([this.subtitles._state.vttText], { type: "text/vtt" });
            const url = URL.createObjectURL(blob);
            this.subtitles._state.objectUrlsByVideo.set(video, url);
            const track = this.utils.NewElement("track", {
                attributes: { kind: "subtitles", label: this.subtitles._state.subtitleLabel || "Local Subtitle", srclang: "en", src: url, default: true, "data-local-subtitle": "1" },
                events: {
                    load: () => {
                        if (track.track) track.track.mode = "showing";
                    }
                }
            });
            video.appendChild(track);
            try {
                for (const tt of video.textTracks) {
                    tt.mode = (tt.label === track.label || tt.language === "en") ? "showing" : "disabled";
                }
            } catch (e) { }
        },
        _attachToAllVideos: () => {
            const videoSelectors = ["video", 'iframe[src*="player"]', 'iframe[src*="video"]', ".video-player video", ".player video", "#player video", ".jwplayer video", ".video-js video", ".plyr video"];
            let allVideos = [];
            videoSelectors.forEach((selector) => {
                allVideos = allVideos.concat(Array.from(document.querySelectorAll(selector)));
            });
            const uniqueVideos = [...new Set(allVideos)];
            if (uniqueVideos.length === 0) return;
            let maxWidthVideo = uniqueVideos.reduce((max, vid) => (vid.getBoundingClientRect().width > max.getBoundingClientRect().width ? vid : max), uniqueVideos[0]);
            if (maxWidthVideo) this.subtitles._attachSubtitleToVideo(maxWidthVideo);
        },
        _ensureUi: () => {
            if (document.getElementById("tm-add-local-subtitle-btn")) return;
            const container = this.utils.NewElement("div", {
                id: "tm-add-local-subtitle-container",
                style: { position: "fixed", zIndex: "2147483647", bottom: "16px", left: "16px", display: "flex", gap: "8px", alignItems: "center" }
            });
            const input = this.utils.NewElement("input", { attributes: { type: "file", accept: ".vtt,.srt,.ass,text/vtt,text/plain,.sub" }, style: { display: "none" } });
            const label = this.utils.NewElement("span", { id: "tm-add-local-subtitle-label", style: { fontSize: "12px", color: "#fff", textShadow: "0 1px 2px rgba(0,0,0,0.6)" } });
            const button = this.utils.NewElement("button", {
                id: "tm-add-local-subtitle-btn", textContent: "本地字幕", attributes: { title: "为页面视频加载本地字幕 (.srt/.vtt/.ass)" },
                style: { padding: "6px 10px", fontSize: "12px", color: "#fff", background: "rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.2)", borderRadius: "6px", cursor: "pointer", backdropFilter: "saturate(150%) blur(6px)", userSelect: "none" },
                events: { click: () => input.click() }
            });
            const retryButton = this.utils.NewElement("button", {
                id: "tm-retry-subtitle-btn", textContent: "重新附加", attributes: { title: "重新尝试附加字幕到视频" },
                style: { padding: "6px 10px", fontSize: "12px", color: "#fff", background: "rgba(255,165,0,0.8)", border: "1px solid rgba(255,255,255,0.2)", borderRadius: "6px", cursor: "pointer", backdropFilter: "saturate(150%) blur(6px)", userSelect: "none" },
                events: { click: () => this.subtitles._attachToAllVideos() }
            });
            input.addEventListener("change", async () => {
                const file = input.files?.[0];
                if (!file) return;
                this.subtitles._state.subtitleLabel = file.name;
                try {
                    const text = await file.text();
                    this.subtitles._state.vttText = this.subtitles._toVtt(text);
                    this.subtitles._state.hasLoadedSubtitle = true;
                    label.textContent = file.name;
                    this.subtitles._attachToAllVideos();
                    setTimeout(() => this.subtitles._attachToAllVideos(), 3000);
                } catch (err) { alert("读取字幕文件失败,请重试。"); }
                finally { input.value = ""; }
            });
            container.append(button, retryButton, label, input);
            document.documentElement.appendChild(container);
        },
        _ensureCueStyle: () => {
            if (document.getElementById("tm-local-subtitle-cue-style")) return;
            const style = this.utils.NewElement("style", { id: "tm-local-subtitle-cue-style", textContent: `\nvideo::cue {\n  -webkit-text-stroke: 2px #000000;\n  text-shadow: 1px 0 0 #000000, -1px 0 0 #000000, 0 1px 0 #000000, 0 -1px 0 #000000, 1px 1px 0 #000000, -1px -1px 0 #000000, 1px -1px 0 #000000, -1px 1px 0 #000000;\n}` });
            document.head.appendChild(style);
        },
        _observeNewVideos: () => {
            const observer = new MutationObserver((mutations) => {
                if (!this.subtitles._state.hasLoadedSubtitle) return;
                for (const m of mutations) {
                    if (m.type === "childList") {
                        m.addedNodes.forEach((node) => {
                            if (node?.nodeType === 1 && (node.nodeName.toLowerCase() === "video" || node.querySelector?.("video"))) {
                                this.subtitles._attachToAllVideos();
                            }
                        });
                    }
                }
            });
            observer.observe(document.documentElement || document.body, { childList: true, subtree: true });
        },
        init: () => {
            if (this.subtitles._inited) return;
            this.subtitles._inited = true;
            this.subtitles._ensureUi();
            this.subtitles._ensureCueStyle();
            this.subtitles._observeNewVideos();
        }
    };

    // ==================== 5. SITE HANDLERS ====================
    handlers = {
        handleGenericSite: (siteKey, hooks = {}) => {
            const config = this.SITE_CONFIG[siteKey];
            if (!config) return;

            if (config.style) this.dom.injectStyleOnce(config.style, siteKey.replace(/\./g, "-"));

            const isDetailPage = this.utils.isValidPath(config.pathCheck);

            if (siteKey === this.SITES.JAVBUS) {
                this.utils.moveElementToNextSiblingFirstChild(".masonry-brick:has(.avatar-box)");

                [...document.querySelectorAll('.photo-frame img')].forEach((img) => {
                    if (img.src.includes('/pics/thumb/')) {
                        img.src = img.src.replace('/pics/thumb/', '/pics/cover/').replace('.jpg', '_b.jpg');
                    }
                });
            } else if (siteKey === this.SITES.JAVDB) {
                if (isDetailPage) this.dom.replacePreviewImagesFromJAVBUS.call(this);
            }

            if (config.isMagnetSite) {
                this.dom.enhanceMagnetLinks(config);
                return;
            }

            if (config.isVideoSite && isDetailPage) {
                this.subtitles.init();
            }

            if (isDetailPage) {
                const code = this.utils.getCodeFromCurrentSite(siteKey, config.titleSelector);
                if (code) {
                    this.dom.addLinkButtons(code, config.titleSelector, config.buttons);
                }
            } else {
                if (typeof hooks.onListPage === "function") hooks.onListPage(config);
            }
        },
        setupCookieMenu: () => {
            GM_registerMenuCommand("设置JAVBUS Cookie", async () => {
                const current = await GM_getValue("javbus_cookie", "");
                const input = prompt("请输入JAVBUS的Cookie:", current || "");
                if (input !== null) await GM_setValue("javbus_cookie", input.trim());
            });
            GM_registerMenuCommand("设置JAVDB Cookie", async () => {
                const current = await GM_getValue("javdb_cookie", "");
                const input = prompt("请输入JAVDB的Cookie:", current || "");
                if (input !== null) await GM_setValue("javdb_cookie", input.trim());
            });
        }
    };

    // ==================== 6. INITIALIZATION ====================
    init() {
        this.handlers.setupCookieMenu();
        const currentSiteKey = Object.keys(this.SITE_CONFIG).find(site => this.utils.isIncludes(site));

        if (currentSiteKey) {
            this.handlers.handleGenericSite(currentSiteKey);
        } else {
            console.log("JableLinker: No handler for current site.");
        }
    }
}

// Launch the script
(function () {
    'use strict';
    new JableLinker().init();
})();