Jellyfin番号过滤

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

Mint 2024.12.09.. Lásd a legutóbbi verzió

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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,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
// @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)
})()