Jellyfin番号过滤

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

2024-12-09 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

// ==UserScript==
// @name         Jellyfin番号过滤
// @namespace    http://tampermonkey.net/
// @version      1.0.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://www.youtube.com/*
// @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_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// ==/UserScript==

;(function () {
    'use strict'
    const defalultSettings = {
        apiKey: '7d7ba7ea977b496c8f113f1a07a03136',
        serverUrl: 'http://127.0.0.1:8096',
        // 跳转时${code}会被替换为真正的番号
        openSite: 'https://javdb.com/search?q=${code}',
        // openSite: 'https://www.javbus.com/${code}',
        isEmby: false,
        // 若为true,则在页面加载完成后自动触发一次过滤
        triggerOnload: false,
        // 自定义快捷键,可以是任意长度的数字或字母
        hotKeys: 'ee',
        debug: false,
        linkColor: 'red',
        linkVisitedColor: '#87CEEB',
        linkExistColor: '#0000FF',
        emphasisOutlineStyle: '2px solid red',
        reverseEmphasis: false
    }

    const CONFIG = [
        {
            site: /https:\/\/www\.javbus\.com(?!\/forum\/forum\.php)/,
            cb: () => findCode('a.movie-box', 'date', '.item-tag')
        },
        {
            site: /https:\/\/www\.javlibrary\.com\/cn(?!(\/tl_bestreviews.php|\/publicgroups.php|\/publictopic.php))/,
            cb: () => findCode('.video', '.id', 'a[href]')
        },
        {
            site: /javdb/,
            cb: () => findCode('.movie-list .item', '.video-title strong', '.tags.has-addons')
        },
        {
            site: /https:\/\/jinjier\.art\/sql.*/,
            cb: () =>
                findCode(
                    'tbody tr',
                    box => {
                        const td = box.querySelector('td:nth-of-type(3)')
                        return td.textContent.split(' ')[0]
                    },
                    'td:nth-of-type(3)'
                )
        }
    ]

    let settings = getSettings()

    if (window.trustedTypes && window.trustedTypes.createPolicy) {
        window.trustedTypes.createPolicy('default', {
            createHTML: string => string,
            createScript: string => string
        })
    }

    const codeMap = new Map()

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

    function findCode(boxSelector, codeSelector, iconParentSelector) {
        if (!settings.apiKey || !settings.serverUrl) {
            showModal()
            return
        }
        const boxes = document.querySelectorAll(boxSelector)
        for (const box of boxes) {
            const code = typeof codeSelector === 'function' ? codeSelector(box) : box.querySelector(codeSelector)?.textContent
            if (!code) return
            queue.push(async () => {
                const item = await getItemByCode(code)
                if (item) {
                    const iconParent = box.querySelector(iconParentSelector) || box
                    addIcon(iconParent, item)
                }
                if ((!settings.reverseEmphasis && !item) || (settings.reverseEmphasis && item)) {
                    setStyle(box, { outline: settings.emphasisOutlineStyle })
                }
            })
        }
    }

    function registerKeysEvent(eventType, keys, callback, timeout = 500) {
        if (!keys) {
            throw new Error('keys不能为空')
        }
        const innerKeys = keys.split('')
        let firstTime = 0
        let index = 0
        document.addEventListener(eventType, e => {
            const currentTime = Date.now()
            const key = innerKeys[index]
            if (index > innerKeys.length - 1 || e.key.toLowerCase() !== key.toLowerCase()) {
                firstTime = 0
                index = 0
                return
            }
            if (currentTime - firstTime > timeout) {
                firstTime = 0
                index = 0
            }
            if (index === innerKeys.length - 1) {
                try {
                    callback()
                } catch (error) {
                    console.error(error)
                }
                firstTime = 0
                index = 0
                return
            }
            if (index === 0) {
                firstTime = currentTime
            }
            index++
        })
    }

    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().finally(() => {
                    this.activeCount--
                    this.next()
                })
            }
        }
    }

    const queue = new AsyncQueue()

    function request(url, method = 'GET') {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method,
                url,
                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) {
        if (!obj) {
            return base
        }
        const query = Object.entries(obj)
            .filter(([_, value]) => value != null)
            .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
            .join('&')
        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 url = settings.isEmby ? `${settings.serverUrl}/emby/Items` : `${settings.serverUrl}/Items`
        try {
            const response = await request(addQuery(url, finalParams))
            return response.Items
        } catch (error) {
            log('请检查apiKey与serverUrl是否设置正确')
            console.error(error)
        }
    }

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

    function setStyle(element, styles) {
        Object.entries(styles).forEach(([key, value]) => {
            element.style[key] = value
        })
    }

    function createLink(text, code) {
        const link = document.createElement('a')
        link.append(text)
        link.className = 'jv-link'
        setStyle(link, {
            color: settings.linkColor
        })
        link.setAttribute('data-jv-click', 0)
        link.setAttribute('data-jv-code', code)
        link.onclick = e => {
            e.preventDefault()
            e.stopPropagation()
            setStyle(link, { color: settings.linkVisitedColor })
            const clickCount = link.getAttribute('data-jv-click')
            link.setAttribute('data-jv-click', Number(clickCount) + 1)
            if (settings.openSite) {
                const url = settings.openSite.replaceAll('${code}', code)
                GM_openInTab(url, { active: true, insert: true, setParent: true })
            }
        }
        queue.push(async () => {
            const item = await getItemByCode(code)
            if (item) {
                setStyle(link, { color: settings.linkExistColor })
                addIcon(link, item)
            }
        })
        return link
    }

    function getTextNodes() {
        const nodes = []
        const iterator = document.createNodeIterator(
            document.body,
            NodeFilter.SHOW_TEXT,
            node => {
                const parent = node.parentNode
                return parent.tagName.toLowerCase() === 'a' && parent.hasAttribute('data-jv-click') ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT
            },
            false
        )
        let node = iterator.nextNode()
        while (node) {
            nodes.push(node)
            node = iterator.nextNode()
        }
        return nodes
    }

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

    function addIcon(parent, item) {
        if (!parent || !item) return
        if (parent.querySelector('.jv-icon-wrapper')) return
        let text
        let url
        if (settings.isEmby) {
            text =
                '<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>'
            url = `${settings.serverUrl}/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`
        } else {
            text =
                '<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>'
            url = `${settings.serverUrl}/web/index.html#!/details?id=${item.Id}`
        }
        const element = convertTextToElement(text)
        element.addEventListener('click', e => {
            e.stopPropagation()
            e.preventDefault()
            GM_openInTab(url, { active: true, insert: true, setParent: true })
        })
        parent.append(element)
    }

    function replaceCodeWithLink(text, reg) {
        let match = reg.exec(text)
        if (!match) return null
        const fragment = document.createDocumentFragment()
        let lastIndex = 0
        while (match) {
            const textBeforeMatch = text.substring(lastIndex, match.index)
            if (textBeforeMatch.length > 0) {
                const textNode = document.createTextNode(textBeforeMatch)
                fragment.append(textNode)
            }
            const code = `${match[1]}-${match[2]}`
            const link = createLink(match[0], code)
            fragment.append(link)
            lastIndex = reg.lastIndex
            match = reg.exec(text)
        }
        const remainingText = text.substring(lastIndex)
        if (remainingText.length > 0) {
            const textNode = document.createTextNode(remainingText)
            fragment.append(textNode)
        }
        return fragment
    }

    function traverse() {
        const nodes = getTextNodes()
        for (const node of nodes) {
            const text = node.nodeValue.trim()
            if (!text) continue
            const fc2Reg = /(fc2)(?:\s*[-_]?ppv)?[-_]?(\d+)/gi
            const reg1 = /(?<![a-z\d]|btih:)([a-z]{2,5})(?:[-_]|\s*)?((?<=[-_0a-z\s])\d{3,4}|(?<=0{2,})[1-9]\d{3,4})(?!\w*p|\d)/gi
            const reg2 = /(?<![a-z\d]|btih:)([nk])(\d{3,})/gi

            const regList = [fc2Reg, reg1, reg2]
            for (const reg of regList) {
                const fragment = replaceCodeWithLink(text, reg)
                if (fragment) {
                    node.replaceWith(fragment)
                    break
                }
            }
        }
    }
    function log(...args) {
        if (!settings.debug) return
        console.log(...args)
    }

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

        function showModal() {
            modal.style.display = 'block'
        }

        function hideModal() {
            modal.style.display = 'none'
        }

        function resetForm() {
            settings = getSettings()
            form.innerHTML = buildFormItems()
        }

        function submitForm() {
            const formData = new FormData(form)
            const data = {}
            for (const [key, value] of formData) {
                data[key] = convertFormValue(value.trim())
            }
            GM_setValue('settings', data)
            settings = getSettings()
            log('设置成功')
            hideModal()
        }

        function createModal() {
            const modal = document.createElement('div')
            modal.id = 'jv-modal'
            modal.innerHTML = `
                <div class='jv-close-icon'>X</div>
                <div class='jv-section'>
                    <h2>设置参数</h2>
                    <form id='jv-form'>
                        ${buildFormItems()}
                    </form>
                    <div class='jv-btn-group'>
                        <button id='jv-submit'>确定</button>
                        <button id='jv-reset'>重置</button>
                    </div>
                </div>
           `
            return modal
        }

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

        const modal = createModal()
        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')

        submitBtn.addEventListener('click', submitForm)
        closeIcon.addEventListener('click', hideModal)
        resetBtn.addEventListener('click', resetForm)

        GM_registerMenuCommand('打开设置', showModal)
        document.body.appendChild(modal)
    }

    function start() {
        log('jellyfin过滤插件已启动...')
        let isMatchConfig = false

        createConfigModal()
        const { hotKeys, triggerOnload } = settings
        for (const config of CONFIG) {
            const { site, cb } = config
            if (site.test(location.href)) {
                log('网址匹配路径正则,会进行特殊处理')
                isMatchConfig = true
                registerKeysEvent('keypress', hotKeys, cb, 500)
                if (triggerOnload) {
                    cb()
                }
                break
            }
        }
        if (!isMatchConfig) {
            log('网址不匹配路径正则,进行一般化处理')
            registerKeysEvent('keypress', hotKeys, traverse)
            if (triggerOnload) {
                traverse()
            }
        }
    }

    start()

    const css = `
        .jv-link {
            display: inline-block;
            white-space: nowrap;
            cursor: pointer;
        }
        .jv-svg {
            margin-left: 2px;
            width: 1em;
            max-width: 16px;
            vertical-align: middle;
            cursor: pointer;
        }
        #jv-modal {
            position: fixed;
            left: 50%;
            top: 40%;
            transform: translate(-50%, -50%);
            background: #fff;
            z-index: 1100;
            overflow: auto;
            display: none;
            padding: 0 50px;
            border: 1px solid black;
        }
        .jv-section {
            width: 500px;
            margin-bottom: 50px;
        }
        .jv-form-item {
            margin-bottom: 20px;
        }
        .jv-form-item input {
            width: 300px;
        }
        .jv-btn-group {
            margin-top: 20px;
        }
        .jv-btn-group button {
            margin-right: 5px;
            cursor: pointer;
        }
        .jv-close-icon {
            position: absolute;
            right: 10px;
            top: 10px;
            cursor: pointer;
            font-size: 20px;
            font-weight: bold;
        }
    `
    GM_addStyle(css)
})()