Jellyfin番号过滤

调用jellyfin API,突出显示本地不存在的影片

As of 30. 12. 2024. See the latest version.

// ==UserScript==
// @name         Jellyfin番号过滤
// @namespace    http://tampermonkey.net/
// @version      2.2.0
// @description  调用jellyfin API,突出显示本地不存在的影片
// @author       Squirtle
// @license      MIT
// @match        https://www.javbus.com/*
// @match        https://www.javlibrary.com/*
// @match        https://javdb.com/*
// @match        https://jinjier.art/*
// @match        https://fc2ppvdb.com/*
// @match        https://www.youtube.com/*
// @match        https://missav.com/*
// @match        https://sukebei.nyaa.si/*
// @match        https://115.com/*
// @match        https://v.anxia.com/*
// @match        https://sehuatang.net/*
// @match        http://localhost:3000/*
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsEAAA7BAbiRa+0AAAX+SURBVFhHzVdpbFVFFD4z994WBcQSQNQALhEwBWQt8CiBhmisSiSsSrqAYbEEDKuoQF6KSGkhLhFFoFWJLNJKFEzUxLjWBy00UUDhh8FAAIGIIEGxfXcZv5k7975X2tKHmuhJJvfO+c6c891zzsybR/+1MP28bnm7oDrT42ytw5lwLWNJ0cZhRzR0XfK3CGwt+CbHZWKPY/B2NmcU53TZNmjsog2Rr7RJysL1M2XZVlg9SBDt8Thv54K+P1h7l/iHJfNiA7RZynJdBLY/tu8W8mi3x0kHZ/6AF4z2cWHsjj4Z66LNU5KUCUSjgnPL2Ya63x4GliRkcD0H1s1ON9+JRqMp+03Z8N5j1Ys9xsYk0i6DJxFRA+8Gf+Dy5Ufm62WtCpa1Lu/mfdFHMLMOHZ8eR1DbwGB03OaU9ydw1+LbHIP1kA2pmpJRvc2dgRvKhh/1PbQsrWagclKlQWRU4OvT1RfKlJM4wywxeuHGSGwZhmuI0S7RmSAbKEUbl1vlsmzaTYvSqoFIv7VIMJalG40cItvzxIS5r484oU2o+JXhx5GVidiatioLBkhETjjfzdQmLco1CciuFyRWJddaML5i3pvZ+7RJKGUvZu11iEeDfnBgb3Ox+vFoXSdt0qxck4BhxEuw3zsETl1iNZ0vnVyn4Sby89BBZbDbL209ac9Zxzg3V2m4WWmRQFV+bABSX+gHZyr1DqdZk6smo9y+rJh/YMSyhfsjekpVk5lrMzYTto6fNZkJmpG78mBfbdJEWiTgCW+tyzn3CWBOtH7e5shhDdOKuTUjPfI+s4k+X7z4wAitpoqSQYewFV8LiKN5DWGwUg03kWYJ7Mz78n6kUO95fD2j8w1ufKWG6Zmi6gzibIfcGXLEGdsxa2ldBw2TZxvFWPOrWo8IGLmjSw+N0nAjaT4DzHgejlU3SwcoxQsLtuT8plFKt9LQG8GJqAJ0w6LVGqbta/pdtEmUJDcv6tZsLzQhsGNqdS7SPVQx9xeeOmc3vKFhWj635j5kZ4afHRlcB+E0O+/Z/X20Gd0sMmQZTvt2wA2WPWTdD2M0HEoTAoyL55KZe0yUFW/JqdcwMk8rgRl+UN9GEzE8wwzLtKX4znqclut8zCfhcLFMw6E0IlCVF4tg22WHTonOm7xNhYZp+ZxaeQkZqxwGGdJE5cCv5Ljx0bre2pxwWm6G/kL4MZzlZL58eIiGlTQi4DBvQcIpk7Uvn71p8BUNE5lIPWcswBOB8e4P5nFrhramT5f0/8PmvDz0CRvb4As0rCQksLNgL5qKxgVOHSYEs8zNGlaC2j+cHFgHlbtE6+RcPKTNlXgekwREmDHOJ/RY/31XDScI2J47HZ1vBk7xXjtnQ9ZPGlaCLz2I814EXxMQUTtGDSFAJjwrpNQuzfwRujofV2vSHNMs1HCCgOAsP9kpxicaCmXNS1mTLEt0xok4Er+IOBJxUhIVolEnYWRbrtXp4+X9p2jzUPAz/lHo14+RryH/PrC9YF8/m7sHg99zNZgYv2hj5H1l9Q+l16tHJ6B87+Hiigus79917MwLRX2OqAw4zH1Qp0ezhM7gd6jV/4IIJroHWzGIY6dZuRJTGXhreuwD1OlR9eXytoNnnAsXz63IRKV9gxUrLR18SdqmKn1LDmWItmYEP0xTcHOaihuT4fsNYohdV6b1mqgIVEyPyfT3kylSJPSI6zmuWALb5xTInICDs7bJL+KQ+R1bLB43YcN4GvqiPf4fZMCuK7A74Os2vDMEb+QzLANj3zZMu2egIrDpiVg1WGUnDLEILIN6hU5CXZLTMGNX6xL19nU+Huiwzb+2C3uOgpnsUmMJ6nIq0QOJWvkD70qXNFe6YB7o8Ax12ibU+XN1UWHiJC65T8vYisCc8mE1dlvqCeN8bKtd6IdfGjlJcurvebwnOfUxqbtqHurwkURn8dWVDuNT457V0ym4q1bGBtS8LH2qrrubJno3EN2N9HZHE3VBzTsivTdhfiPS2AbvFuqN9KIXOK7iBrsC/SWk/wJSfc4h7ySex+qZefRCUa/T2vX/SYj+Av6m66t3we/RAAAAAElFTkSuQmCC
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_setClipboard
// @grant        GM_info
// @grant        GM_cookie
// @grant        unsafeWindow
// @grant        GM_openInTab
// ==/UserScript==

;(function () {
    'use strict'
    if (unsafeWindow.top.jellyfinFilterScript) {
        console.log('该脚本已经在运行了, 本次直接返回')
        return
    }
    unsafeWindow.top.jellyfinFilterScript = true

    // 默认设置项
    const defaultSettings = {
        // 是否开启jellyfin/emby功能
        enable: true,
        // 是否开启本地番号填写功能
        localCodeEnable: false,
        // 从jellyfin/emby 控制台获取
        apiKey: '',
        // 服务器地址
        serverUrl: 'http://127.0.0.1:8096',
        // jellyfin用户保持为false,emby用户需设置为true
        isEmby: false,
        // 若为true,则在页面加载完成后自动触发一次过滤
        triggerOnload: false,
        // 自定义快捷键,可以是任意长度的字母或数字
        hotKeys: 'ee',
        // 脚本会改变页面的原有结构,此处定义可使页面恢复原状的快捷键
        recoverHotKeys: 'ss',
        // 点击番号时的默认跳转链接,${code}会被替换为真正的番号
        openSite: 'https://www.javbus.com/${code}',
        // 点击番号时按住shift键时的跳转链接
        secondarySite: 'https://javdb.com/search?q=${code}',
        // 若番号被识别为fc2,默认会跳转到的链接
        fc2Site: 'https://sukebei.nyaa.si/user/offkab?q=${code}',
        // 设为true时浏览器控制台会输出log
        debug: false,
        // 定义生成链接的默认颜色
        linkColor: '#236ED0FF',
        // 定义被访问过的链接颜色
        linkVisitedColor: '#424F5FFF',
        // 番号在jellyfin/115/本地中存在时显示的链接颜色
        linkExistColor: '#2A7B5FFF',
        // 定义磁力和ed2k链接的颜色
        magnetColor: 'orange',
        // 高亮卡片边框样式
        emphasisOutlineStyle: '2px solid red',
        // 默认会高亮不存在的番号,设置为true则反之
        reverseEmphasis: false,
        // 是否尽量复用窗口,可以加快打开速度
        openLinkInSameTab: false
    }

    // 默认115设置项
    const defaultOOFSettings = {
        // 是否开启115相关功能
        enable: false,
        // 115的cookie,可自行输入或点击自动获取,任选其一
        cookie: '',
        // 自定义cookie过期时间,单位为天
        expiresIn: '30',
        // 一个番号如果在jellyfin和115中都存在,默认只显示一个jellfyin图标,若设置为true,则也会显示115图标
        forceShowOOFBtn: false,
        // 115在线观看链接
        openSite: 'https://v.anxia.com/?pickcode=${code}',
        // 首次匹配115网盘文件时,需要批次获取全量数据
        // limit定义每次获取条数,根据实际情况谨慎填写,过大可能导致服务器返回缓慢,过小请求次数过多可能触发115风控
        limit: '1000'
    }

    const CONFIG = [
        {
            site: /^https:\/\/(www\.)?javbus\.com(?=\/?$|\/page\/\d+|\/search|\/genre|\/uncensored|\/star)/i,
            cb: () => findCode('a.movie-box', 'date')
        },
        {
            site: /^https:\/\/www\.javlibrary\.com\/cn(?!(\/tl_bestreviews.php|\/publicgroups.php|\/publictopic.php))/i,
            cb: () => findCode('.video', '.id')
        },
        {
            site: /^https:\/\/(www\.)?javdb\.com(?!\/v)/i,
            cb: () => findCode('.movie-list .item', '.video-title strong')
        },
        {
            site: /^https:\/\/jinjier\.art\/sql.*/i,
            cb: () =>
                findCode('tbody tr', box => {
                    const td = box.querySelector('td:nth-of-type(3)')
                    return td.textContent.split(' ')[0]
                })
        },
        {
            site: /https:\/\/fc2ppvdb\.com/i,
            cb: () =>
                findCode('.flex section .container .relative', box => {
                    const span = box.querySelector('a + span')
                    const text = span.textContent
                    if (!text.startsWith('fc2')) {
                        span.textContent = `fc2-${text}`
                    }
                    return text
                })
        }
    ]

    const REG = {
        magnet: /magnet:\?xt=urn:btih:[\da-f]{40}/i,
        ed2k: /ed2k:\/\/(?:\|.+)+\|\//i,
        fc2: /(fc2)(?:\s*[-_]?ppv)?[-_]?(\d+)/i,
        censored: /(?<![a-z\d])([a-z]{2,5})(?:[-_]|\s*|0*)?(\d{3,5})(?![a-z\d]|\.com)/i,
        numcensored: /((?<=200)gana|(?<=259)luxu|(?<=261)ara|(?<=300)maan|(?<=300)mium|(?<=300)ntk|(?<=428)suke)(?:[-_]|\s*|0*)?(\d{3,5})(?!\w*p|\d|\.com)/i,
        uncensored: /(?<![a-z\d])([nk])(\d{3,})/i
    }

    function isTypeMagnetLike(type) {
        return type === 'magnet' || type === 'ed2k'
    }

    function getCodeByRegType(match, type, fc2Prefix = false) {
        if (!match) return
        if (isTypeMagnetLike(type)) return match[0]
        if (type === 'fc2') return fc2Prefix ? `fc2-${match[2]}` : match[2]
        if (type === 'uncensored') return match[1] + match[2]
        return `${match[1]}-${match[2]}`
    }

    const textRegList = Object.entries(REG).map(([type, reg]) => ({ type, reg: new RegExp(reg, 'gi') }))

    const fileRegList = Object.entries(REG)
        .filter(([type]) => !isTypeMagnetLike(type))
        .map(([type, reg]) => ({ type, reg }))

    function getSettings() {
        return {
            ...defaultSettings,
            ...GM_getValue('settings')
        }
    }

    function getOOFSettings() {
        return {
            ...defaultOOFSettings,
            ...GM_getValue('oofSettings')
        }
    }

    function getLocalCodeList() {
        return GM_getValue('localCodeList', [])
    }

    let settings = getSettings()
    let oofSettings = getOOFSettings()
    const codeMap = new Map()
    const oofCodeMap = new Map()
    let localCodeList = getLocalCodeList()

    function noop() {}
    let log = noop

    let myPolicy
    if (window.trustedTypes && window.trustedTypes.createPolicy) {
        myPolicy = window.trustedTypes.createPolicy('jvJellyfinPolicy', {
            createHTML: string => string
        })
    }

    function setInnerHTML(element, html) {
        const escapeHtml = myPolicy ? myPolicy.createHTML(html) : html
        element.innerHTML = escapeHtml
    }

    function openTab(url, ctrlKey = false) {
        log(url)
        const { serverUrl, openLinkInSameTab } = settings
        const urlObj = new URL(url)
        if (ctrlKey || !openLinkInSameTab || new URL(serverUrl).origin === urlObj.origin) {
            GM_openInTab(url, { active: !ctrlKey, insert: true, setParent: true })
        } else {
            window.open(url, urlObj.origin)
        }
    }

    // 对a标签按住alt键时,click事件不会触发,用mousedown代替下
    function addClickEvent(element, handler, withCtrl = false) {
        element.addEventListener('click', e => {
            e.stopPropagation()
            e.preventDefault()
        })
        element.addEventListener('mousedown', e => {
            e.stopPropagation()
            e.preventDefault()
            if (e.button !== 0) return
            if (withCtrl && !e.ctrlKey) {
                notify('请按住ctrl键再点击', '此举是为了防止不小心点错')
                return
            }
            handler(e)
        })
    }

    const ICONS = {
        close: '<svg fill-rule="evenodd" viewBox="64 64 896 896" focusable="false" data-icon="close" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"></path></svg>',
        jellyfin:
            '<svg class="jv-svg" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <linearGradient id="grad3" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="30%" style="stop-color:#AA5CC3;stop-opacity:1" /><stop offset="100%" style="stop-color:#00A4DC;stop-opacity:1" /></linearGradient><path style="fill:url(#grad3)" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"/></svg>',
        emby: '<svg class="jv-svg" role="img" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" ><path d="M469.333333 85.333333L256 298.666667l42.666667 42.666666-213.333334 213.333334 213.333334 213.333333 42.666666-42.666667 213.333334 213.333334 213.333333-213.333334-42.666667-42.666666 213.333334-213.333334-213.333334-213.333333-42.666666 42.666667-213.333334-213.333334m-42.666666 277.333334l256 149.333333-256 149.333333v-298.666666z" fill="#05b010" p-id="1934"></path></svg>',
        oof: '<svg class="jv-oof-svg" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><g fill="none" fill-rule="evenodd"><path fill="#224888" d="M16,0 C24.836556,-1.623249e-15 32,7.163444 32,16 C32,24.836556 24.836556,32 16,32 C7.163444,32 1.082166e-15,24.836556 0,16 C-1.082166e-15,7.163444 7.163444,1.623249e-15 16,0 Z"/><path fill="#FFF" d="M21.7114092,7.38461538 C21.6913564,7.38461538 21.6724177,7.38794634 21.654036,7.39294276 C21.6406676,7.39793919 21.6272991,7.40349078 21.6133736,7.4084872 C21.217889,7.55838001 20.644715,7.7204863 20.0097117,7.7204863 C20.0097117,7.7204863 15.0121242,7.7204863 13.9315046,7.7204863 C13.5427043,7.7204863 13.2006937,7.90924021 12.9973813,8.19625717 C12.9957102,8.19958812 10.0769231,13.9971088 10.0769231,13.9971088 C10.0769231,13.9971088 11.5886766,14.1725389 11.6298961,14.1786456 C12.9762145,14.3851646 14.5737491,14.7771065 15.6822198,15.4444071 C17.2752983,16.4037211 18.2706939,17.7977241 18.1698732,19.2944316 C17.974359,22.195135 15.2421737,23.5003127 13.0937458,23.8139773 C11.5853344,24.0227169 10.5838117,23.8139773 10.5838117,23.8139773 C10.5838117,23.8139773 11.8148269,24.5562242 13.588937,24.6106298 C16.6180142,24.7038964 18.9736537,23.4159286 20.2197084,21.5539268 C22.1871046,18.6160278 21.0875463,13.9399274 15.9300939,12.5475898 C14.9408256,12.2805586 13.5672132,12.0279614 13.5672132,12.0279614 L14.3019232,10.5867699 C14.4651302,10.1792835 14.8750972,9.89060104 15.3558059,9.89060104 C15.3780867,9.89060104 15.4003675,9.89171136 15.4226483,9.89282168 L17.8746523,9.89282168 C17.8752093,9.89282168 17.8763234,9.89282168 17.8768804,9.89282168 C17.8774374,9.89282168 17.8785515,9.89282168 17.8802225,9.89282168 L20.0587295,9.89282168 C20.4854071,9.89282168 20.8563828,9.66465151 21.0485548,9.32822544 L21.0691646,9.28825402 L21.8801863,7.70993829 C21.8957829,7.69050774 21.9069233,7.6683014 21.9141646,7.64387442 C21.9197348,7.62610935 21.9230769,7.60778912 21.9230769,7.58835857 C21.9230769,7.47621654 21.8283834,7.38461538 21.7114092,7.38461538 Z"/></g></svg>'
    }

    function throttle(fn, threshhold = 500, scope) {
        let previous = 0
        return (...args) => {
            const context = scope || this
            const now = Date.now()
            if (now - previous > threshhold) {
                previous = now
                return fn.apply(context, args)
            }
        }
    }

    function notify(title, content, timeout = 3000) {
        log(title, content)
        const element = document.createElement('div')
        element.className = 'jv-notification'
        const texts = [
            `<span class='jv-close-icon'>${ICONS.close}</span>`,
            title && `<div class='jv-title'>${title}</div>`,
            content && `<div class='jv-content'>${content}</div>`
        ]
        setInnerHTML(element, texts.filter(Boolean).join('\n'))
        const closeIcon = element.querySelector('.jv-close-icon')
        const close = () => {
            element?.remove()
        }
        closeIcon.addEventListener('click', close)
        document.body.append(element)
        if (timeout > 0) {
            setTimeout(close, timeout)
        }
        return close
    }

    const throttleNotify = throttle(notify)

    class KeysEvent {
        constructor(eventType = 'keypress', interval = 500) {
            this.interval = interval
            this.inputs = []
            this.eventMap = new Map()

            document.addEventListener(eventType, this.handler)
        }

        handler = e => {
            const now = Date.now()
            const key = e.key.toLowerCase()
            log(key)
            const index = this.inputs.findLastIndex(({ time }) => now - time > this.interval)
            if (index > -1) {
                this.inputs.splice(0, index + 1)
            }
            this.inputs.push({ key, time: now })
            this.trigger()
        }

        trigger = () => {
            const inputKeys = this.inputs.map(input => input.key).join('')
            for (const [keys, listeners] of this.eventMap) {
                const startIndex = inputKeys.indexOf(keys)
                if (startIndex > -1) {
                    try {
                        listeners.forEach(listener => listener())
                        this.inputs.splice(startIndex, keys.length)
                    } catch (error) {
                        console.error(error)
                    }
                }
            }
        }

        on = (keys, listener) => {
            const listeners = this.eventMap.get(keys)
            if (listeners) {
                // 不允许注册相同的处理函数
                if (!listeners.includes(listener)) {
                    listeners.push(listener)
                }
            } else {
                this.eventMap.set(keys, [listener])
            }
            return () => this.off(keys, listener)
        }

        off = (keys, listener) => {
            const listeners = this.eventMap.get(keys)
            if (!listeners) return
            const index = listeners.findIndex(l => l === listener)
            listeners.splice(index, 1)
        }
    }

    const keysEvent = new KeysEvent()

    function setStyle(element, styles, isImportant = false) {
        Object.entries(styles).forEach(([key, value]) => {
            element.style.setProperty(key, value, isImportant ? 'important' : '')
        })
    }

    class AsyncQueue {
        constructor(concurrent = 5) {
            this.concurrent = concurrent
            this.activeCount = 0
            this.queue = []
        }

        push(promiseCreator) {
            this.queue.push(promiseCreator)
            this.next()
        }

        next() {
            if (this.activeCount < this.concurrent && this.queue.length) {
                const promiseCreator = this.queue.shift()
                this.activeCount++

                promiseCreator()
                    .then(() => {
                        this.activeCount--
                        this.next()
                    })
                    .catch(() => {
                        this.activeCount = 0
                        this.queue.length = 0
                    })
            }
        }
    }

    const queue = new AsyncQueue()

    function request(url, params = {}) {
        log(`请求: ${url}`)
        const { method = 'GET', data, headers } = params
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method,
                url,
                data,
                headers: { ...headers, 'X-Emby-Token': settings.apiKey },
                onload(response) {
                    if (response.status === 200) {
                        try {
                            resolve(JSON.parse(response.responseText))
                        } catch (error) {
                            reject(error)
                        }
                    } else {
                        reject(response)
                    }
                },
                onerror(error) {
                    reject(error)
                }
            })
        })
    }

    function addQuery(base, obj) {
        const url = new URL(base)
        url.search = new URLSearchParams(obj)
        return url.toString()
    }

    async function fetchItems(params) {
        const finalParams = {
            startIndex: 0,
            fields: 'SortName',
            imageTypeLimit: 1,
            includeItemTypes: 'Movie',
            recursive: true,
            sortBy: 'SortName',
            sortOrder: 'Ascending',
            limit: 2,
            ...params
        }
        const { isEmby, serverUrl } = settings
        const url = isEmby ? `${serverUrl}/emby/Items` : `${serverUrl}/Items`
        try {
            const response = await request(addQuery(url, finalParams))
            return response.Items
        } catch (error) {
            throttleNotify('请求jellyfin/emby报错', '请检查apiKey与serverUrl是否设置正确', 0)
            console.error(error)
            throw error
        }
    }

    async function getItemByCode(code) {
        if (codeMap.has(code)) {
            return codeMap.get(code)
        }
        const items = await fetchItems({ searchTerm: code })
        const item = items?.[0] || null
        codeMap.set(code, item)
        return item
    }

    function requireCookie(cb) {
        const { enable, cookie } = oofSettings
        if (enable && !cookie) {
            notify('缺失cookie', '打开115设置, 手动设置cookie; 或者去115登录, 再点击获取cookie')
            return noop
        }
        return cb
    }

    function getParsedCode({ text, useDefaultName = true, fc2Prefix = false }) {
        for (const { type, reg } of fileRegList) {
            const match = text.match(reg)
            const code = getCodeByRegType(match, type, fc2Prefix)
            if (code) {
                return code.toLowerCase()
            }
        }
        return useDefaultName ? text : ''
    }

    function setOOFFiles() {
        if (oofCodeMap.size === 0) return
        GM_setValue('oofFiles', [...oofCodeMap.values()])
    }

    async function initOOFCodeMap(forceRefresh = false) {
        let oofFiles = GM_getValue('oofFiles', [])
        const length = oofFiles.length
        const force = length === 0 || forceRefresh
        if (force) {
            oofFiles = await patchGetOOFFiles()
        }
        oofFiles.forEach(file => {
            if (force) {
                file.code = getParsedCode({ text: file.n })
            }
            oofCodeMap.set(file.code, file)
        })
        if (force) {
            setOOFFiles()
        }
    }

    function clearOOFCodeMap() {
        oofCodeMap.clear()
        GM_setValue('oofFiles', [])
    }

    const patchGetOOFFiles = requireCookie(async () => {
        const limit = parseInt(oofSettings.limit) || 1000
        const baseUrl = 'https://webapi.115.com/files'
        const params = {
            aid: 1,
            cid: 0,
            o: 'user_ptime',
            asc: 0,
            show_dir: 0,
            type: 4,
            format: 'json'
        }
        let files = []
        let count = 1
        let offset = 0
        let pageIndex = 0
        while (offset < count) {
            const response = await request(addQuery(baseUrl, { ...params, limit, offset }))
            files = files.concat(response.data)
            count = response.count
            offset += limit
            pageIndex++
        }
        log(`视频总数量为${count}条, 每次获取${limit}条, 共分了${pageIndex}次请求`)
        log(files)
        return files
    })

    const getAndUpdateOOFItemByCode = requireCookie(async (code, text = '', forceCheck = false) => {
        if (!forceCheck) {
            return oofCodeMap.get(code)
        }
        const keyword = text && text !== code ? `${code} ${text}` : code
        log(`搜索关键字: ${keyword}`)
        const response = await request(
            addQuery('https://webapi.115.com/files/search', {
                search_value: keyword,
                format: 'json',
                limit: 100
            })
        )
        log(response)
        let item
        if (response.count > 0) {
            item = response.data.find(file => {
                if (!file.play_long) return false
                return code === getParsedCode({ text: file.n })
            })
        }
        if (item) {
            item.code = code
            oofCodeMap.set(code, item)
            setOOFFiles()
            return item
        } else {
            if (oofCodeMap.has(code)) {
                oofCodeMap.delete(code)
                setOOFFiles()
            }
            return null
        }
    })

    class ConfigModal {
        constructor({ title = '设置', getFormData, onSubmit, onShow, extraButtons, defaultData, buildItems }) {
            this.title = title
            this.getFormData = getFormData
            this.onSubmit = onSubmit
            this.onShow = onShow
            this.extraButtons = extraButtons
            this.defaultData = defaultData
            this.buildItems = buildItems
            this.initialized = false
        }

        init() {
            this.formData = this.getFormData()

            const modal = this.create()

            const form = modal.querySelector('.jv-form')
            const submitBtn = modal.querySelector('.jv-submit')
            const resetBtn = modal.querySelector('.jv-reset')
            const closeIcon = modal.querySelector('.jv-close-icon')
            const restoreBtn = modal.querySelector('.jv-restore')

            submitBtn.addEventListener('click', this.submit)
            closeIcon.addEventListener('click', this.hide)
            resetBtn.addEventListener('click', () => this.refresh())
            restoreBtn.addEventListener('click', this.restore)
            this.modal = modal
            this.form = form

            this.buildExtraButtons()

            document.body.append(this.modal)
            this.initialized = true
        }

        convertFormValue(value) {
            if (value === 'true') return true
            if (value === 'false') return false
            return value
        }

        buildExtraButtons() {
            if (!this.extraButtons) return
            const btnGroup = document.createElement('div')
            btnGroup.className = 'jv-btn-group'
            const btnFragment = document.createDocumentFragment()
            this.extraButtons.forEach(({ withCtrl = true, type = 'button', onclick, ...others }) => {
                const btn = document.createElement('input')
                btn.type = type
                if (onclick) {
                    addClickEvent(btn, onclick, withCtrl)
                }
                Object.entries(others).forEach(([key, value]) => {
                    btn[key] = value
                })
                btnFragment.append(btn)
            })
            btnGroup.append(btnFragment)
            this.form.after(btnGroup)
        }

        buildFormItems() {
            if (this.buildItems) return this.buildItems(this.formData)

            return Object.keys(this.defaultData)
                .map(key => {
                    return `
                    <div class='jv-form-item'>
                        <label for='${key}'>${key}: </label>
                        <input type='text' name='${key}' value='${this.formData[key]}' />
                    </div>
                `
                })
                .join('\n')
        }

        create() {
            const modal = document.createElement('div')
            modal.className = 'jv-modal'
            const html = `
            <div class='jv-close-icon'>${ICONS.close}</div>
            <div class='jv-section'>
                <div class='jv-title'>${this.title}</div>
                <form class='jv-form'>
                    ${this.buildFormItems()}
                </form>
                <div class='jv-btn-group'>
                    <button class='jv-submit'>确定</button>
                    <button class='jv-reset'>重置</button>
                    <button class='jv-restore'>恢复默认</button>
                </div>
            </div>`
            setInnerHTML(modal, html)
            return modal
        }

        show = () => {
            if (!this.initialized) {
                this.init()
            }
            this.modal.style.display = 'block'
            this.refresh(this.formData)
            this.onShow?.()
        }

        hide = () => {
            this.modal.style.display = 'none'
        }

        refresh = formData => {
            this.formData = formData || this.getFormData()
            setInnerHTML(this.form, this.buildFormItems())
        }

        restore = () => {
            this.formData = this.defaultData
            setInnerHTML(this.form, this.buildFormItems())
        }

        submit = () => {
            const formData = new FormData(this.form)
            const data = {}
            for (const [key, value] of formData) {
                data[key] = this.convertFormValue(value.trim())
            }
            this.formData = data
            this.onSubmit?.(data)
            this.hide()
        }
    }

    const configModal = new ConfigModal({
        defaultData: defaultSettings,
        getFormData: getSettings,
        onSubmit(formData) {
            settings = formData
            GM_setValue('settings', formData)
            notify('设置成功')
            restart()
        }
    })

    const refreshClearBtn = () => {
        const clearBtn = document.querySelector('#jv-refresh-clear-btn')
        if (clearBtn) {
            clearBtn.value = `清除所有缓存(${oofCodeMap.size})`
        }
    }

    const oofModal = new ConfigModal({
        title: '115设置',
        defaultData: defaultOOFSettings,
        getFormData: getOOFSettings,
        onSubmit(formData) {
            oofSettings = formData
            GM_setValue('oofSettings', formData)
            setCookies(formData.cookie, formData.expiresIn)
            restart()
        },
        onShow: refreshClearBtn,
        extraButtons: [
            {
                value: '获取115Cookie',
                onclick: getCookieAfterLogin
            },
            {
                value: `刷新所有缓存`,
                onclick: async () => {
                    await initOOFCodeMap(true)
                    refreshClearBtn()
                }
            },
            {
                id: 'jv-refresh-clear-btn',
                value: `清除所有缓存(${oofCodeMap.size})`,
                onclick: () => {
                    clearOOFCodeMap()
                    refreshClearBtn()
                }
            }
        ]
    })

    const localCodeModal = new ConfigModal({
        title: '手动输入番号',
        buildItems(formData) {
            return `
                <textarea name='localCodeList' style='width: 1100px; height: 500px;' id='jv-local-code-textarea'>${formData.localCodeList.join(', ')}</textarea>
            `
        },
        defaultData: { localCodeList: [] },
        getFormData: () => ({ localCodeList: getLocalCodeList() }),
        onSubmit(formData) {
            const newLocalCodeList = formData.localCodeList
                .split(/[,;\s]\s*/)
                .map(code => getParsedCode({ text: code, useDefaultName: false, fc2Prefix: true }))
                .filter(Boolean)

            localCodeList = newLocalCodeList
            GM_setValue('localCodeList', newLocalCodeList)
            this.refresh({ localCodeList: newLocalCodeList })
            notify('设置成功', `共设置番号${newLocalCodeList.length}个`)
        }
    })

    const requiredCookieNames = ['UID', 'CID', 'SEID', 'KID']

    function parseCookieTextToMap(text) {
        const cookieMap = new Map()
        if (!text) return cookieMap
        text.split(/;\s*/).forEach(item => {
            if (!item) return
            const [name, value] = item.split('=')
            cookieMap.set(name, value)
        })
        return cookieMap
    }

    function setCookies(text, expiresIn) {
        const cookieMap = parseCookieTextToMap(text)
        for (const name of requiredCookieNames) {
            if (!cookieMap.has(name)) {
                notify(`Cookie 缺少 ${name}`)
                return
            }
        }
        const expires = parseInt(expiresIn) || 30
        for (const name of requiredCookieNames) {
            const value = cookieMap.get(name)
            GM_cookie.set({
                name,
                value,
                domain: '.115.com',
                path: '/',
                secure: false,
                httpOnly: true,
                expirationDate: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * expires
            })
        }

        notify('Cookie 设置成功', `最长有效期为${expiresIn}天`)
    }

    function parseCookieArrayToText(array) {
        return array.map(({ name, value }) => `${name}=${value}`).join(';')
    }

    async function getCookieAfterLogin() {
        GM_cookie.list({ domain: '.115.com' }, (cookies, error) => {
            if (error) {
                console.error(error)
                return
            }
            if (location.hostname !== '115.com') {
                notify('请在115页面上获取Cookie')
                return
            }
            if (cookies?.length > 0) {
                log(cookies)
                const cookieText = parseCookieArrayToText(cookies)
                oofSettings.cookie = cookieText
                GM_setValue('oofSettings', oofSettings)
                oofModal.refresh()
            } else {
                notify('Cookie 不存在, 请先登录')
            }
        })
    }

    function convertTextToElement(text) {
        const div = document.createElement('div')
        setInnerHTML(div, text)
        return div.firstChild
    }

    function addIcon(parent, item) {
        if (!parent || !item) return
        if (parent.querySelector('.jv-svg')) return
        let icon, url
        const { isEmby, serverUrl } = settings
        if (isEmby) {
            icon = ICONS.emby
            url = `${serverUrl}/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`
        } else {
            icon = ICONS.jellyfin
            url = `${serverUrl}/web/index.html#!/details?id=${item.Id}`
        }
        const element = convertTextToElement(icon)
        addClickEvent(element, e => {
            openTab(url, e.ctrlKey)
        })
        parent.append(element)
    }

    async function checkCodeExist({ link, code, text, updateIcon }) {
        const { enable: jellyfinEnable, localCodeEnable } = settings
        const { enable: oofEnable, forceShowOOFBtn } = oofSettings
        const conditions = [
            {
                andCondition: jellyfinEnable,
                orCondition: false,
                cb: async () => {
                    const item = await getItemByCode(code)
                    if (updateIcon && item) {
                        addIcon(link, item)
                    }
                    return item
                }
            },
            {
                andCondition: oofEnable,
                orCondition: oofEnable && forceShowOOFBtn,
                cb: () => checkWatchIcon({ link, code, text, forceCheck: false, update: updateIcon })
            },
            {
                andCondition: localCodeEnable,
                orCondition: false,
                cb: () => localCodeList.includes(code)
            }
        ]
        let isExist = false
        for (const { andCondition, orCondition, cb } of conditions) {
            if ((!isExist && andCondition) || orCondition) {
                isExist = isExist || (await cb())
            }
        }
        return isExist
    }

    function findCode(boxSelector, codeSelector) {
        const { enable: jellyfinEnable, apiKey, serverUrl, reverseEmphasis, emphasisOutlineStyle } = settings
        if (jellyfinEnable && (!apiKey || !serverUrl)) {
            notify('缺少必填项', '填写apiKey和serverUrl或者关闭jellyfin/emby功能')
            configModal.show()
            return
        }
        const run = () => {
            const boxes = document.querySelectorAll(boxSelector)
            for (const box of boxes) {
                if (box.hasAttribute('data-jv-outline')) continue
                let code = typeof codeSelector === 'function' ? codeSelector(box) : box.querySelector(codeSelector)?.textContent
                if (!code) return
                box.setAttribute('data-jv-outline', box.style.outline)
                box.setAttribute('data-jv-outline-priority', box.style.getPropertyPriority('outline'))
                code = code.toLowerCase()
                queue.push(async () => {
                    const isExist = await checkCodeExist({ link: null, code, text: code, updateIcon: false })
                    if ((!reverseEmphasis && !isExist) || (reverseEmphasis && isExist)) {
                        setStyle(box, { outline: emphasisOutlineStyle }, true)
                    }
                })
            }
        }
        const clear = () => {
            const boxes = document.querySelectorAll(boxSelector)
            boxes.forEach(box => {
                const outline = box.getAttribute('data-jv-outline')
                const priority = box.getAttribute('data-jv-outline-priority')
                box.removeAttribute('data-jv-outline')
                box.removeAttribute('data-jv-outline-priority')
                setStyle(box, { outline }, priority)
            })
        }

        return { run, clear }
    }

    function openSiteByCode(site, code, ctrlKey) {
        if (!site) return
        const url = site.replaceAll('${code}', code)
        openTab(url, ctrlKey)
    }

    async function checkWatchIcon({ link, code, text, forceCheck, update }) {
        const hasIcon = link?.querySelector('.jv-oof-svg')
        if (!forceCheck && hasIcon) return true
        const item = await getAndUpdateOOFItemByCode(code, text, forceCheck)
        if (update && link) {
            if (item) {
                if (!hasIcon) {
                    const element = convertTextToElement(ICONS.oof)
                    addClickEvent(element, e => {
                        openSiteByCode(oofSettings.openSite, item.pc, e.ctrlKey)
                    })
                    link.append(element)
                }
            } else {
                hasIcon?.remove()
            }
        }

        return Boolean(item)
    }

    const getUserId = requireCookie(() => {
        const { cookie } = oofSettings
        const cookieMap = parseCookieTextToMap(cookie)
        const UID = cookieMap.get('UID')
        const match = UID?.match(/(\d+)_/)
        const userId = match?.[1]
        log(`userId为: ${userId}`)
        return userId
    })

    const addOfflineTask = requireCookie(async magnet => {
        const close = notify('正在添加离线任务', magnet, 0)
        try {
            const userId = await getUserId()
            if (!userId) return
            const now = Date.now()
            const signUrl = `https://115.com/?ct=offline&ac=space&_=${now}`
            const { sign, time } = await request(signUrl)
            log(`sign: ${sign}, time: ${time}`)
            const data = { url: magnet, uid: userId, sign, time }
            const response = await request('https://115.com/web/lixian/?ct=lixian&ac=add_task_url', {
                method: 'POST',
                data: new URLSearchParams(data).toString(),
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
            })
            log(response)
            if (response.state) {
                notify('离线任务添加成功', '按住alt并单击对应的番号链接, 即可看到在线观看按钮')
            } else {
                notify('离线任务添加失败', response.error_msg)
            }
        } catch (error) {
            notify('离线失败')
            console.error(error)
        } finally {
            close()
        }
    })

    function addOfflineBtn(parent, magnet) {
        const { enable } = oofSettings
        if (!enable) return
        if (parent.querySelector('.jv-oof-btn')) return
        const button = document.createElement('button')
        button.className = 'jv-oof-btn'
        button.textContent = '115离线'
        addClickEvent(button, () => addOfflineTask(magnet), true)
        parent.append(button)
    }

    function clearOOFBtns() {
        const btns = document.querySelectorAll('.jv-oof-btn, .jv-oof-svg')
        btns.forEach(btn => btn.remove())
    }

    function createLink(text, code, type) {
        const link = document.createElement('a')
        link.append(text)
        link.className = 'jv-link'
        link.setAttribute('data-jv-code', code)
        const { linkColor, linkVisitedColor, linkExistColor, openSite, secondarySite, fc2Site, magnetColor } = settings
        const { enable: oofEnable } = oofSettings
        setStyle(link, { color: isTypeMagnetLike(type) ? magnetColor : linkColor })
        if (oofEnable && isTypeMagnetLike(type)) {
            addOfflineBtn(link, code)
        }
        const handler = async e => {
            log(e)
            if (isTypeMagnetLike(type)) {
                if (e.ctrlKey && type === 'magnet') {
                    window.open(code, '_self')
                } else {
                    GM_setClipboard(code)
                    notify('复制成功', code)
                }
            } else {
                if (oofEnable && e.altKey) {
                    const isExsit = await checkWatchIcon({ link, code, text, forceCheck: true, update: true })
                    if (!isExsit) {
                        notify(`${code}在你的115中不存在`)
                    }
                } else {
                    setStyle(link, { color: linkVisitedColor })
                    if (e.shiftKey) {
                        openSiteByCode(secondarySite, code, e.ctrlKey)
                    } else if (type === 'fc2') {
                        openSiteByCode(fc2Site || openSite, code, e.ctrlKey)
                    } else {
                        openSiteByCode(openSite, code, e.ctrlKey)
                    }
                }
            }
        }
        addClickEvent(link, handler)
        if (!isTypeMagnetLike(type)) {
            queue.push(async () => {
                const isExist = await checkCodeExist({ link, code, text, updateIcon: true })
                if (isExist) {
                    setStyle(link, { color: linkExistColor })
                }
            })
        }
        return link
    }

    function replaceCodeWithLink(text, regItem) {
        const { type, reg } = regItem
        let match = reg.exec(text)
        if (!match) return null
        const fragment = document.createDocumentFragment()
        let lastIndex = 0
        while (match) {
            const textBeforeMatch = text.slice(lastIndex, match.index)
            if (textBeforeMatch.length > 0) {
                fragment.append(textBeforeMatch)
            }
            let code = getCodeByRegType(match, type)
            code = code.toLowerCase()
            const link = createLink(match[0], code, type)
            fragment.append(link)
            lastIndex = reg.lastIndex
            match = reg.exec(text)
        }
        const remainingText = text.slice(lastIndex)
        if (remainingText.length > 0) {
            fragment.append(remainingText)
        }
        return fragment
    }

    function handleTextNode(node) {
        const text = node.nodeValue
        if (!text) return
        for (const regItem of textRegList) {
            const fragment = replaceCodeWithLink(text, regItem)
            if (fragment) {
                node.replaceWith(fragment)
                return
            }
        }
    }

    function traverseTextNodes(handler) {
        log('开始遍历所有文本')
        const iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT, node => {
            const parent = node.parentNode
            if ((parent.tagName.toLowerCase() === 'a' && parent.hasAttribute('data-jv-code')) || parent.id === 'jv-local-code-textarea') {
                return NodeFilter.FILTER_SKIP
            }
            return NodeFilter.FILTER_ACCEPT
        })
        let node
        while ((node = iterator.nextNode()) !== null) {
            handler(node)
        }
    }

    function handleLink(node) {
        const href = node.getAttribute('href')
        if (!href) return
        const match = href.match(REG.magnet) || href.match(REG.ed2k)
        if (!match) return
        addOfflineBtn(node, match[0])
    }

    function traverseLinks(handler) {
        log('开始遍历所有链接')
        const iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_ELEMENT, node => {
            if (node.tagName.toLowerCase() !== 'a' || node.hasAttribute('data-jv-code')) return NodeFilter.FILTER_SKIP
            return NodeFilter.FILTER_ACCEPT
        })
        let node
        while ((node = iterator.nextNode()) !== null) {
            handler(node)
        }
    }

    function traverse() {
        traverseTextNodes(handleTextNode)
        if (oofSettings.enable) {
            traverseLinks(handleLink)
        }
    }

    let clearSpecialMatchEvent = null
    let clearSpecialMathcDoms = null
    function startSpecialMatch() {
        const { hotKeys, triggerOnload } = settings
        for (const config of CONFIG) {
            const { site, cb } = config
            if (!site.test(location.href)) continue
            log('网址匹配路径正则, 将额外显示一个边框')
            const { run, clear } = cb()
            clearSpecialMathcDoms = clear
            clearSpecialMatchEvent = keysEvent.on(hotKeys, run)
            if (triggerOnload) {
                run()
            }
            return
        }
    }

    function clearCommonMatchDoms() {
        const links = document.querySelectorAll('.jv-link')
        links.forEach(link => {
            const parent = link.parentNode
            link.replaceWith(link.firstChild)
            parent.normalize()
        })
    }

    let clearCommonMatchEvent = null
    function startCommonMatch() {
        const { hotKeys, triggerOnload } = settings
        clearCommonMatchEvent = keysEvent.on(hotKeys, () => {
            traverse()
        })
        if (triggerOnload) {
            traverse()
        }
    }

    function clearDoms() {
        log('清除页面改动')
        clearSpecialMathcDoms?.()
        clearCommonMatchDoms()
        clearOOFBtns()
    }

    function clearAll() {
        clearDoms()
        clearSpecialMatchEvent?.()
        clearCommonMatchEvent?.()
    }

    async function restart() {
        const { debug } = settings
        const { enable: oofEnable } = oofSettings
        log = debug ? console.log.bind(console) : noop

        log('jellyfin过滤插件已启动...')
        log(settings)
        log(oofSettings)

        clearAll()

        if (oofEnable) {
            await initOOFCodeMap()
        }

        startSpecialMatch()
        startCommonMatch()
    }

    function removeAD() {
        document.querySelector('#js_common_mini-dialog')?.remove()
        document.querySelector('.vt-headline > div:last-child')?.remove()
    }

    function addStyle() {
        const baseColorStyle = () => `
            color: rgba(0, 0, 0, 0.88);
            background: rgb(255, 255, 255);
            box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.08), -2px -2px 2px 1px rgba(0, 0, 0, 0.08);
        `

        const baseStyle = () => `
            ${baseColorStyle()}
            font-size: 14px;
            line-height: 1.5;
            padding: 20px 24px;
            border-radius: 8px;
        `

        const css = `
        .jv-link {
            display: inline-block;
            white-space: nowrap;
            cursor: pointer;
        }
        .jv-svg, .jv-oof-svg {
            margin: 0 2px;
            width: 1.2em;
            max-width: 32px;
            vertical-align: middle;
            cursor: pointer;
        }
        .jv-oof-btn {
            ${baseColorStyle()}
            margin: 0 2px;
            padding: 1px 6px;
            cursor: pointer;
            font-size: 12px;
            line-height: 1.5;
            background: #fff;
            border: none;
            outline: none;
            border-radius: 4px;
        }
        .jv-modal {
            ${baseStyle()}
            position: fixed;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            z-index: 1100;
            overflow: auto;
            display: none;
        }
        .jv-section {
            width: 1100px;
            margin: 20px 0;
        }
        .jv-section .jv-title {
            font-weight: bold;
            font-size: 22px;
            margin: 20px 0;
            text-align: center;
        }
        .jv-form {
            display: flex;
            flex-wrap: wrap;
            justify-content: space-between;
            max-width: unset;
        }
        .jv-form-item {
            display: flex;
            justify-content: space-between;
            margin: 15px 0;
            width: 500px;
        }
        .jv-form input, .jv-form textarea {
            width: 300px;
            padding: 4px 11px;
            line-height: 1.5;
            outline: 0;
            border: 1px solid #d9d9d9;
            border-radius: 6px;
            box-sizing: border-box;
        }
        .jv-form input:focus, .jv-form textarea:focus {
            border-color: #1677ff;
            box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
        }
        .jv-btn-group {
            margin-top: 20px;
        }
        .jv-btn-group button, .jv-btn-group input {
            margin-right: 5px;
            cursor: pointer;
        }
        .jv-close-icon {
            position: absolute;
            right: 10px;
            top: 10px;
            cursor: pointer;
        }
        .jv-notification {
            position: fixed;
            z-index: 100001;
            top: 24px;
            right: 24px;
            ${baseStyle()}
        }

        .jv-notification .jv-title {
            margin-bottom: 8px;
            font-size: 16px;
            line-height: 1.5;
            padding-right: 24px;
        }

        .jv-notification .jv-content {
            font-size: 14px;
        }

    `
        GM_addStyle(css)
    }

    function start() {
        addStyle()
        GM_registerMenuCommand('设置', configModal.show)
        GM_registerMenuCommand('115设置', oofModal.show)
        GM_registerMenuCommand('手动输入番号', localCodeModal.show)
        keysEvent.on(settings.recoverHotKeys, clearDoms)
        removeAD()
        restart()
    }

    start()
})()