Sleazy Fork is available in English.

Jellyfin番号过滤

标记所有番号,并添加跳转链接。支持jellyfin、emby、115、手动输入的番号查重。

// ==UserScript==
// @name         Jellyfin番号过滤
// @namespace    http://tampermonkey.net/
// @version      2.2.14
// @description  标记所有番号,并添加跳转链接。支持jellyfin、emby、115、手动输入的番号查重。
// @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/*
// @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,
        // 在其他图标已存在时,是否依然显示本地图标
        forceShowLocalBtn: false,
        // 从jellyfin/emby 控制台获取
        apiKey: '',
        // 服务器地址
        serverUrl: 'http://127.0.0.1:8096',
        // jellyfin用户保持为false,emby用户需设置为true
        isEmby: false,
        // 若为true,则在页面加载完成后自动触发一次过滤
        triggerOnload: false,
        // 自定义快捷键,可以是任意长度的字母或数字
        hotKeys: 'ee',
        // 脚本会改变页面的原有结构,此处定义可使页面恢复原状的快捷键
        recoverHotKeys: 'ss',
        // 复制所有磁力链接快捷键
        copyMagnetHotKeys: 'aa',
        // 复制所有ed2k链接快捷键
        copyEd2kHotKeys: 'qq',
        // 点击番号时的默认跳转链接,${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中都存在,默认只显示一个jellyfin图标,若设置为true,则也会显示115图标
        forceShowOOFBtn: false,
        // 115在线观看链接
        openSite: 'https://v.anxia.com/?pickcode=${code}',
        // 首次匹配115网盘文件时,需要批次获取全量数据
        // limit定义每次获取条数,根据实际情况谨慎填写,过大可能导致服务器返回缓慢,过小请求次数过多可能触发115风控
        limit: '500',
        // 离线目录id, 留空则115会保存在云下载目录
        cid: '',
        // 是否开启alist功能
        alistEnable: false,
        // alist地址: http://127.0.0.1:5244/d/115/${dir}/${file}
        alistUrl: '',
        // cid与目录名的对应关系: 1111:目录1; 2222:目录2 用来匹配alistUrl
        cidPair: '',
        // 将115中某个目录中所有匹配番号规则的视频文件移动到另一个目录
        // 格式为cid对,比如 1111:2222 会将目录1中的文件移动到目录2
        move: ''
    }

    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 code = span.textContent
                    if (!code.startsWith('fc2')) {
                        span.textContent = `fc2-${code}`
                    }
                    return code
                })
            }
        }
    ]

    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,})(?:[-_]|\s*|0*)?(\d{3,})(?![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()
    const magnetSet = new Set()
    const ed2kSet = new Set()

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

    const copyText = (text, desc) => {
        if (!text) return
        GM_setClipboard(text)
        notify(desc || '复制成功', text)
    }

    function addClickEvent(element, handler, text = null, withCtrl = false) {
        const prevent = e => {
            e.stopPropagation()
            e.preventDefault()
        }

        const handleMousedown = e => {
            e.stopPropagation()
            e.preventDefault()
            if (e.button === 0) {
                if (withCtrl && !e.ctrlKey) {
                    notify('请按住ctrl键再点击', '此举是为了防止不小心点错')
                    return
                }
                handler(e)
            } else if (e.button === 2 && typeof text === 'string') {
                // text为空字符串则执行handler,handler中要分别处理鼠标左右键的情况
                text === '' ? handler(e) : copyText(text)
            }
        }
        element.addEventListener('contextmenu', prevent)
        element.addEventListener('click', prevent)
        element.addEventListener('mousedown', handleMousedown)
        return () => {
            document.removeEventListener('contextmenu', prevent)
            document.removeEventListener('click', prevent)
            document.removeEventListener('mousedown', handleMousedown)
        }
    }

    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"><defs><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></defs><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>',
        alist: '<svg class="jv-alist-svg" width="22" height="22" viewBox="0 0 1252 1252" xmlns="http://www.w3.org/2000/svg" version="1.1"><g><g id="#70c6beff"><path id="svg_2" d="m634.37,138.38c11.88,-1.36 24.25,1.3 34.18,8.09c14.96,9.66 25.55,24.41 34.49,39.51c40.59,68.03 81.45,135.91 122.02,203.96c54.02,90.99 108.06,181.97 161.94,273.06c37.28,63 74.65,125.96 112.18,188.82c24.72,41.99 50.21,83.54 73.84,126.16c10.18,17.84 15.77,38.44 14.93,59.03c-0.59,15.92 -3.48,32.28 -11.84,46.08c-11.73,19.46 -31.39,33.2 -52.71,40.36c-11.37,4.09 -23.3,6.87 -35.43,6.89c-132.32,-0.05 -264.64,0.04 -396.95,0.03c-11.38,-0.29 -22.95,-1.6 -33.63,-5.72c-7.81,-3.33 -15.5,-7.43 -21.61,-13.42c-10.43,-10.32 -17.19,-24.96 -15.38,-39.83c0.94,-10.39 3.48,-20.64 7.76,-30.16c4.15,-9.77 9.99,-18.67 15.06,-27.97c22.13,-39.47 45.31,-78.35 69.42,-116.65c7.72,-12.05 14.44,-25.07 25.12,-34.87c11.35,-10.39 25.6,-18.54 41.21,-19.6c12.55,-0.52 24.89,3.82 35.35,10.55c11.8,6.92 21.09,18.44 24.2,31.88c4.49,17.01 -0.34,34.88 -7.55,50.42c-8.09,17.65 -19.62,33.67 -25.81,52.18c-1.13,4.21 -2.66,9.52 0.48,13.23c3.19,3 7.62,4.18 11.77,5.22c12,2.67 24.38,1.98 36.59,2.06c45,-0.01 90,0 135,0c8.91,-0.15 17.83,0.3 26.74,-0.22c6.43,-0.74 13.44,-1.79 18.44,-6.28c3.3,-2.92 3.71,-7.85 2.46,-11.85c-2.74,-8.86 -7.46,-16.93 -12.12,-24.89c-119.99,-204.91 -239.31,-410.22 -360.56,-614.4c-3.96,-6.56 -7.36,-13.68 -13.03,-18.98c-2.8,-2.69 -6.95,-4.22 -10.77,-3.11c-3.25,1.17 -5.45,4.03 -7.61,6.57c-5.34,6.81 -10.12,14.06 -14.51,21.52c-20.89,33.95 -40.88,68.44 -61.35,102.64c-117.9,198.43 -235.82,396.85 -353.71,595.29c-7.31,13.46 -15.09,26.67 -23.57,39.43c-7.45,10.96 -16.49,21.23 -28.14,27.83c-13.73,7.94 -30.69,11.09 -46.08,6.54c-11.23,-3.47 -22.09,-9.12 -30.13,-17.84c-10.18,-10.08 -14.69,-24.83 -14.17,-38.94c0.52,-14.86 5.49,-29.34 12.98,-42.1c71.58,-121.59 143.62,-242.92 215.93,-364.09c37.2,-62.8 74.23,-125.69 111.64,-188.36c37.84,-63.5 75.77,-126.94 113.44,-190.54c21.02,-35.82 42.19,-71.56 64.28,-106.74c6.79,-11.15 15.58,-21.15 26.16,-28.85c8.68,-5.92 18.42,-11 29.05,-11.94z" fill="#70c6be"/></g><g id="#1ba0d8ff"><path id="svg_3" d="m628.35,608.38c17.83,-2.87 36.72,1.39 51.5,11.78c11.22,8.66 19.01,21.64 21.26,35.65c1.53,10.68 0.49,21.75 -3.44,31.84c-3.02,8.73 -7.35,16.94 -12.17,24.81c-68.76,115.58 -137.5,231.17 -206.27,346.75c-8.8,14.47 -16.82,29.47 -26.96,43.07c-7.37,9.11 -16.58,16.85 -27.21,21.89c-22.47,11.97 -51.79,4.67 -68.88,-13.33c-8.66,-8.69 -13.74,-20.63 -14.4,-32.84c-0.98,-12.64 1.81,-25.42 7.53,-36.69c5.03,-10.96 10.98,-21.45 17.19,-31.77c30.22,-50.84 60.17,-101.84 90.3,-152.73c41.24,-69.98 83.16,-139.55 124.66,-209.37c4.41,-7.94 9.91,-15.26 16.09,-21.9c8.33,-8.46 18.9,-15.3 30.8,-17.16z" fill="#1ba0d8"/></g></g></svg>'
    }

    function getUniqueId() {
        return Math.random().toString(36).slice(2)
    }

    function getIcon(name) {
        if (name === 'jellyfin') {
            const id = getUniqueId()
            return ICONS.jellyfin.replaceAll('grad3', id)
        }
        return ICONS[name]
    }

    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,
            element
        }
    }

    function notifyWithConfirm(title, content) {
        return new Promise(resolve => {
            const { close, element } = notify(title, content, 0)
            const confirm = result => () => {
                close()
                resolve(result)
            }
            const footer = document.createElement('div')
            footer.className = 'jv-btn-group'
            const confirmBtn = document.createElement('button')
            const cancelBtn = document.createElement('button')
            confirmBtn.textContent = '确定'
            cancelBtn.textContent = '取消'
            footer.append(confirmBtn, cancelBtn)
            confirmBtn.addEventListener('click', confirm(true))
            cancelBtn.addEventListener('click', confirm(false))
            element.append(footer)
        })
    }

    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(error => {
                        console.error(error)
                        this.activeCount = 0
                        this.queue.length = 0
                    })
            }
        }
    }

    const queue = new AsyncQueue()

    function request(url, { method = 'GET', data, headers = {} } = {}) {
        log(`请求: ${url}`)
        if (new URL(url).origin === settings.serverUrl) {
            headers['X-Emby-Token'] = settings.apiKey
        }
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method,
                url,
                data,
                headers: {
                    'Content-Type': 'application/json',
                    ...headers
                },
                onload(response) {
                    if (response.status === 200) {
                        try {
                            resolve(JSON.parse(response.responseText))
                        } catch (error) {
                            reject(error)
                        }
                    } else {
                        reject(response)
                    }
                },
                onerror: reject
            })
        })
    }

    function requestWithCookie(url, config = {}) {
        config.headers = { ...config.headers, cookie: oofSettings.cookie }
        return request(url, config)
    }

    function getQuery(params) {
        return Object.entries(params)
            .filter(([_, value]) => value != null)
            .map(([key, value]) => {
                if (Array.isArray(value)) {
                    return value.map((v, i) => `${encodeURIComponent(key)}[${i}]=${encodeURIComponent(v)}`).join('&')
                }
                return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
            })
            .join('&')
    }

    function addQuery(base, params) {
        if (!params) return base
        const query = getQuery(params)
        if (!query) return base
        return base.endsWith('?') ? base + query : `${base}?${query}`
    }

    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, 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 null
    }

    function setOOFFiles(files) {
        const oofFiles = files || [...oofCodeMap.values()]
        GM_setValue('oofFiles', oofFiles)
    }

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

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

    const patchGetOOFFiles = requireCookie(async ({ cid = 0 }) => {
        const limit = parseInt(oofSettings.limit) || parseInt(defaultOOFSettings.limit)
        const baseUrl = 'https://webapi.115.com/files'
        const params = {
            aid: 1,
            cid,
            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 url = addQuery(baseUrl, { ...params, limit, offset })
            const response = await requestWithCookie(url)
            files = files.concat(response.data)
            count = response.count
            offset += limit
            pageIndex++
        }
        log(`视频总数量为${count}条, 每次获取${limit}条, 共分了${pageIndex}次请求`)
        log(files)
        return files
    })

    const getAndUpdateFileByCode = requireCookie(async (code, text = '', forceCheck = false) => {
        if (!forceCheck) return oofCodeMap.get(code)
        const keyword = text && text !== code ? `${code} ${text}` : code
        log(`搜索关键字: ${keyword}`)
        const params = { search_value: keyword, format: 'json', limit: 100 }
        const url = addQuery('https://webapi.115.com/files/search', params)
        const response = await requestWithCookie(url)
        log(response)
        const filteredFiles = filterFiles(response.data)
        let item = null
        let shouldUpdate = false
        if (filteredFiles.length > 0) {
            filteredFiles.forEach(file => {
                if (file.code === code) {
                    item = file
                }
                oofCodeMap.set(file.code, file)
            })
            shouldUpdate = true
        }

        if (!item && oofCodeMap.has(code)) {
            oofCodeMap.delete(code)
            shouldUpdate = true
        }
        if (shouldUpdate) {
            setOOFFiles()
        }
        return item
    })

    function filterFiles(files) {
        const newFiles = []
        files.forEach(file => {
            if (!file.play_long) return
            const code = getParsedCode({ text: file.n })
            if (!code) return
            file.code = code
            newFiles.push(file)
        })
        return newFiles
    }

    const moveFiles = requireCookie(async () => {
        const { move } = oofSettings
        const [src, dest] = move.split(/\s*:\s*/).map(item => item.trim())
        if (!src || !dest) {
            notify('移动失败', '请设置移动源和目标目录')
            return
        }
        const srcFiles = await patchGetOOFFiles({ cid: src })
        const filteredFiles = filterFiles(srcFiles)
        log(filteredFiles)
        if (filteredFiles.length === 0) {
            notify('源目录为空')
            return
        }
        const config = {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            data: getQuery({ pid: dest, fid: filteredFiles.map(file => file.fid) })
        }
        try {
            const response = await requestWithCookie('https://webapi.115.com/files/move', config)
            log(response)
            notify('移动成功', `共移动了${filteredFiles.length}/${srcFiles.length}个文件`)
            filteredFiles.forEach(file => {
                file.cid = dest
                oofCodeMap.set(file.code, file)
            })
            setOOFFiles()
        } catch (error) {
            notify('移动失败')
            console.error(error)
        }
    })

    const deleteFile = requireCookie(async file => {
        const confirm = await notifyWithConfirm(`确定从115中删除 ${file.n} 吗?`)
        if (!confirm) return
        const url = 'https://webapi.115.com/rb/delete'
        const config = {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            data: getQuery({ fid: [file.fid], ignore_warn: 1 })
        }
        try {
            await requestWithCookie(url, config)
            if (oofCodeMap.has(file.code)) {
                oofCodeMap.delete(file.code)
                setOOFFiles()
            }
            notify('删除成功', file.n)
            return true
        } catch (error) {
            notify('删除失败')
            console.error(error)
            return false
        }
    })

    function convertFormValue(value) {
        const switchMap = new Map([
            ['true', true],
            ['false', false],
            ['undefined', undefined],
            ['null', null]
        ])
        if (switchMap.has(value)) {
            return switchMap.get(value)
        }
        return value
    }

    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
        }

        buildExtraButtons() {
            if (!this.extraButtons) return
            const btnGroup = document.createElement('div')
            btnGroup.className = 'jv-btn-group'
            const btnFragment = document.createDocumentFragment()
            this.extraButtons.forEach(({ withCtrl = true, onclick, ...others }) => {
                const btn = document.createElement('button')
                if (onclick) {
                    addClickEvent(btn, onclick, null, 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 => {
                    const id = `jv-label-id-${key}`
                    return `
                    <div class='jv-form-item'>
                        <label for='${id}'>${key}: </label>
                        <input type='text' name='${key}' value='${this.formData[key]}' id='${id}'/>
                    </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] = 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.textContent = `清除所有缓存(${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: [
            {
                textContent: '获取115Cookie',
                onclick: getCookieAfterLogin
            },
            {
                textContent: `刷新所有缓存`,
                onclick: async () => {
                    await initOOFCodeMap(true)
                    refreshClearBtn()
                }
            },
            {
                id: 'jv-refresh-clear-btn',
                textContent: `清除所有缓存(${oofCodeMap.size})`,
                onclick: () => {
                    clearOOFCodeMap()
                    refreshClearBtn()
                }
            },
            {
                textContent: '批量移动文件',
                onclick: moveFiles
            }
        ]
    })

    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, 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 addLocalIcon(link) {
        if (!link) return
        if (link.querySelector('.jv-local-icon')) return
        const icon = document.createElement('span')
        icon.className = 'jv-local-icon'
        icon.textContent = 'L'
        addClickEvent(icon, noop)
        link.append(icon)
    }

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

    function addIcon(link, item) {
        if (!link || !item) return
        if (link.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 = getIcon('jellyfin')
            url = `${serverUrl}/web/index.html#!/details?id=${item.Id}`
        }
        const element = convertTextToElement(icon)
        addClickEvent(element, e => openTab(url, e.ctrlKey), url)
        link.append(element)
    }

    async function checkCodeExist({ link, code, text, updateIcon, type }) {
        const { enable: jellyfinEnable, localCodeEnable, forceShowLocalBtn } = 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: () => {
                    return checkWatchIcon({ link, code, text, forceCheck: false, update: updateIcon })
                }
            },
            {
                andCondition: localCodeEnable,
                orCondition: localCodeEnable && forceShowLocalBtn,
                cb: () => {
                    let exist
                    if (type) {
                        if (type === 'fc2') {
                            code = `fc2-${code}`
                        }
                        exist = localCodeList.includes(code)
                    } else {
                        exist = localCodeList.some(localCode => localCode.includes(code))
                    }
                    if (updateIcon && exist) {
                        addLocalIcon(link)
                    }
                    return exist
                }
            }
        ]
        let isExist = false
        for (const { andCondition, orCondition, cb } of conditions) {
            if ((!isExist && andCondition) || orCondition) {
                const result = await cb()
                isExist = isExist || result
            }
        }
        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, type: null })
                    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, e) {
        if (!site) return
        const url = site.replaceAll('${code}', code)
        if (e.button === 0) {
            openTab(url, e.ctrlKey)
        } else if (e.button === 2) {
            copyText(url)
        }
    }

    function getCidMap(cidPair) {
        return new Map(cidPair.split(/\s*;\s*/).map(item => item.split(/\s*:\s*/)))
    }

    function handleOOFIconClick(file, link) {
        return async e => {
            if (e.altKey) {
                const success = await deleteFile(file)
                if (success) {
                    link.querySelector('.jv-alist-svg')?.remove()
                    link.querySelector('.jv-oof-svg')?.remove()
                }
            } else {
                openSiteByCode(oofSettings.openSite, file.pc, e)
            }
        }
    }

    function alistIconClick(file) {
        return e => {
            const { alistUrl, cidPair } = oofSettings
            if (!alistUrl || !cidPair) {
                notify('缺少参数', '请先设置alistUrl和cidPair')
                return
            }
            const cidMap = getCidMap(cidPair)
            const dir = cidMap.get(file.cid)
            if (!dir) {
                notify('没找到对应的alist目录, 请检查设置')
                return
            }
            const url = new URL(alistUrl.replaceAll('${dir}', dir).replaceAll('${file}', file.n).replace('/d', ''))
            const downloadUrl = `${url.origin}/d${url.pathname}`
            if (e.button === 0) {
                if (e.altKey) {
                    window.open(downloadUrl, '_self')
                } else {
                    openTab(url.href, e.ctrlKey)
                }
            } else if (e.button === 2) {
                copyText(downloadUrl, '下载链接已复制')
            }
        }
    }

    let removeWatchIconEvent
    async function checkWatchIcon({ link, code, text, forceCheck, update }) {
        const { alistEnable } = oofSettings
        const oofIcon = link?.querySelector('.jv-oof-svg')
        const alistIcon = link?.querySelector('.jv-alist-svg')
        if (!forceCheck && oofIcon) return true
        const file = await getAndUpdateFileByCode(code, text, forceCheck)
        if (!update || !link) return file
        if (file) {
            const bindEvent = (innerOOFIcon, innerAlistIcon) => {
                const removeOOFEvent = addClickEvent(innerOOFIcon, handleOOFIconClick(file, link), '')
                let removeAlistEvent
                if (innerAlistIcon) {
                    removeAlistEvent = addClickEvent(innerAlistIcon, alistIconClick(file), '')
                }
                removeWatchIconEvent = () => {
                    removeOOFEvent()
                    removeAlistEvent?.()
                }
            }
            if (oofIcon) {
                removeWatchIconEvent?.()
                bindEvent(oofIcon, alistIcon)
            } else {
                const newOOFIcon = convertTextToElement(ICONS.oof)
                const newAlistIcon = alistEnable ? convertTextToElement(ICONS.alist) : null
                bindEvent(newOOFIcon, newAlistIcon)
                link.append(newOOFIcon)
                if (newAlistIcon) {
                    link.append(newAlistIcon)
                }
            }
        } else {
            oofIcon?.remove()
            alistIcon?.remove()
        }
        return file
    }

    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 requestWithCookie(signUrl)
            log(`sign: ${sign}, time: ${time}`)
            const { cid } = oofSettings
            const data = { url: magnet, uid: userId, sign, time, wp_path_id: cid || null }
            const response = await requestWithCookie('https://115.com/web/lixian/?ct=lixian&ac=add_task_url', {
                method: 'POST',
                data: getQuery(data),
                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), 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
        const isMagnetLike = isTypeMagnetLike(type)
        setStyle(link, { color: isMagnetLike ? magnetColor : linkColor })
        if (oofEnable && isMagnetLike) {
            addOfflineBtn(link, code)
        }
        const handler = async e => {
            log(e)
            if (isMagnetLike) {
                if (type === 'magnet') {
                    window.open(code, '_self')
                }
            } 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)
                    } else if (type === 'fc2') {
                        openSiteByCode(fc2Site || openSite, code, e)
                    } else {
                        openSiteByCode(openSite, code, e)
                    }
                }
            }
        }
        addClickEvent(link, handler, code)
        if (isMagnetLike) {
            if (type === 'magnet') {
                magnetSet.add(code)
            } else {
                ed2kSet.add(code)
            }
        } else {
            queue.push(async () => {
                const isExist = await checkCodeExist({ link, code, text, updateIcon: true, type })
                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, serverUrl } = settings
        const { enable: oofEnable } = oofSettings
        if (debug) {
            log = console.log.bind(console)
            unsafeWindow.top.oofCodeMap = oofCodeMap
        } else {
            log = noop
        }
        if (location.origin === serverUrl) {
            settings.enable = false
        }

        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 copyMagnet(type) {
        return () => {
            let values = []
            if (type === 'magnet') {
                values = Array.from(magnetSet.values())
            } else if (type === 'ed2k') {
                values = Array.from(ed2kSet.values())
            }
            const length = values.length
            if (length > 0) {
                const content = values.join('\n')
                GM_setClipboard(content)
                notify(`成功复制${length}条`, content)
            }
        }
    }

    function addStyle() {
        const baseColor = () => `
            color: rgba(0, 0, 0, 0.88);
            background: #fff;
            border: 1px solid #d9d9d9;
        `
        const baseContainerStyle = () => `
            ${baseColor()}
            font-size: 14px;
            line-height: 1.5;
            padding: 20px 24px;
            border-radius: 8px;
            box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.08), -2px -2px 2px 1px rgba(0, 0, 0, 0.08);
        `

        const baseBtnStyle = () => `
            ${baseColor()}
            height: 2.2em;
            padding: 0 15px;
            outline: none;
            border-radius: 4px;
            font-size: 13px;
            box-shadow: 0 2px 0 rgba(0, 0, 0, 0.02);
            cursor: pointer;
        `

        const focusBtnStyle = () => `
            border: 1px solid #1677ff;
            box-shadow: 0 2px 0 rgba(5, 145, 255, 0.1);
        `

        const css = `
        .jv-link {
            display: inline-flex;
            align-items: center;
            flex-wrap: nowrap;
            cursor: pointer;
        }
        .jv-svg, .jv-oof-svg, .jv-alist-svg {
            margin: 0 2px;
            width: 1.2em;
            max-width: 32px;
            vertical-align: middle;
            cursor: pointer;
        }
        .jv-local-icon {
            display: inline-flex;
            justify-content: center;
            align-items: center;
            width: 1.4em;
            height: 1.4em;
            margin: 0 2px;
            border-radius: 50%;
            background: #243fbf;
            color: #fff;
            font-size: 14px;
            font-weight: 700;
            cursor: default;
        }
        .jv-oof-btn {
            ${baseBtnStyle()}
            background: #1677ff;
            color: #fff;
            height: 1.7em;
            line-height: 1.7;
            margin: 0 2px;
            padding: 0 6px;
            font-size: 12px;
            border: none;
        }
        .jv-modal {
            ${baseContainerStyle()}
            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: 12px 0;
            width: 500px;
        }
       .jv-form input, .jv-form textarea {
            ${baseBtnStyle()}
            font-size: 14px;
            box-sizing: border-box;
            width: 300px;
            padding: 0 11px;
            cursor: text;
            height: 2.2em;
            line-height: 2.2;
        }
        .jv-form input:focus, .jv-form textarea:focus {
           ${focusBtnStyle()}
        }
        .jv-btn-group {
            margin-top: 20px;
            text-align: center;
        }
        .jv-btn-group button {
            ${baseBtnStyle()}
            margin-right: 5px;
        }
        .jv-btn-group button:hover {
            ${focusBtnStyle()}
            color: #1677ff;
        }
        .jv-close-icon {
            position: absolute;
            right: 0;
            top: 0;
            cursor: pointer;
            padding: 10px;
        }
        .jv-close-icon:hover {
            color: red;
        }
        .jv-notification {
            ${baseContainerStyle()}
            position: fixed;
            z-index: 100001;
            top: 24px;
            right: 24px;
        }

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

        .jv-notification .jv-content {
            max-height: 500px;
            overflow: auto;
            white-space: pre;
        }
    `
        const cssWithComment = css
            .split('\n')
            .filter(line => !line.trim().startsWith('//'))
            .join('\n')
        GM_addStyle(cssWithComment)
    }

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

    start()
})()