Jellyfin番号过滤

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

// ==UserScript==
// @name         Jellyfin番号过滤
// @namespace    http://tampermonkey.net/
// @version      0.0.4
// @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/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=undefined.localhost
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// ==/UserScript==

;(function () {
    'use strict'

    const defaultSettings = {
        apiKey: '',
        isEmby: false,
        serverUrl: 'http://127.0.0.1:8096',
        reverseEmphasis: false, // 默认突出显示的是没有下载的
        emphasisStyle: {
            outline: '2px solid red'
        }
    }

    const CONFIG = [
        {
            site: 'javbus',
            eventType: 'keypress',
            timeout: 300,
            keys: 'ee',
            cb: () => findCode('a.movie-box', 'date', '.item-tag')
        },
        {
            site: 'javlibrary',
            eventType: 'keypress',
            timeout: 300,
            keys: 'ee',
            cb: () => findCode('.video', '.id', 'a[href]')
        },
        {
            site: 'javdb',
            eventType: 'keypress',
            timeout: 300,
            keys: 'ee',
            cb: () => findCode('.movie-list .item', '.video-title strong', '.tags.has-addons')
        },
        {
            site: 'jinjier',
            eventType: 'keypress',
            timeout: 300,
            keys: 'ee',
            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 = {}
    const modal = createModal()
    const submitBtn = modal.querySelector('#jv-submit')
    const cancelBtn = modal.querySelector('#jv-cancel')
    const resetBtn = modal.querySelector('#jv-reset')
    const contentEle = modal.querySelector('#jv-content')

    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) {
            console.error('请检查apiKey与serverUrl是否设置正确')
            console.error(error)
        }
    }

    function setStyle(element, styles) {
        for (const key in styles) {
            if (styles.hasOwnProperty(key)) {
                element.style[key] = styles[key]
            }
        }
    }

    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 items = await fetchItems({ searchTerm: code })
                if ((!settings.reverseEmphasis && items.length === 0) || (settings.reverseEmphasis && items.length > 0)) {
                    setStyle(box, settings.emphasisStyle)
                }
                if (items.length > 0) {
                    const iconParent = box.querySelector(iconParentSelector) || box
                    addIconBtn(iconParent, items[0])
                }
            })
        }
    }

    function addIconBtn(iconParent, item) {
        if (!iconParent || !item) return
        if (iconParent.querySelector('.jv-icon-wrapper')) return
        let iconSvg
        let url
        if (settings.isEmby) {
            iconSvg =
                '<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 {
            iconSvg =
                '<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 iconWrapper = document.createElement('span')
        iconWrapper.classList.add('jv-icon-wrapper')
        iconWrapper.innerHTML = iconSvg

        iconParent.appendChild(iconWrapper)
        iconWrapper.addEventListener('click', e => {
            e.stopPropagation()
            e.preventDefault()
            GM_openInTab(url, { active: true, insert: true, setParent: true })
        })
    }

    function registerDoubleEvent(eventType, keys, callback, timeout = 300) {
        let firstTime = 0
        const [key1, key2] = keys.split('')
        document.addEventListener(eventType, e => {
            if (!keys.includes(e.key)) return
            const currentTime = Date.now()
            if (firstTime === 0 && e.key === key1) {
                firstTime = currentTime
            } else if (e.key === key2) {
                if (firstTime && currentTime - firstTime < timeout) {
                    try {
                        callback()
                    } catch (error) {
                        console.error(error)
                    }
                }
                firstTime = 0
            } else {
                firstTime = 0
            }
        })
    }

    function createModal() {
        const modal = document.createElement('div')
        modal.id = 'jv-modal'
        modal.innerHTML = `
            <div id='jv-content' contenteditable='true'></div>
            <div class='jv-btn-group'>
                <button id='jv-submit'>确定</button>
                <button id='jv-cancel'>关闭</button>
                <button id='jv-reset'>重置</button>
            </div>
       `
        document.body.appendChild(modal)
        return modal
    }

    function refreshModal(currentSettings) {
        contentEle.textContent = JSON.stringify(currentSettings, null, 4)
    }

    function showModal() {
        modal.style.display = 'flex'
        refreshModal(settings)
    }

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

    function registerMenuListener() {
        GM_registerMenuCommand('自定义设置', showModal)
    }

    function registerEventListeners() {
        CONFIG.forEach(({ site, eventType, keys, timeout, cb }) => {
            if (!location.href.includes(site)) return
            registerDoubleEvent(eventType, keys, cb, timeout)
        })
        submitBtn.addEventListener('click', () => {
            try {
                const newSettings = JSON.parse(contentEle.textContent)
                if (newSettings.apiKey && newSettings.serverUrl) {
                    GM_setValue('settings', newSettings)
                    settings = newSettings
                    hideModal()
                    setTimeout(() => {
                        window.location.reload()
                    })
                } else {
                    alert('apiKey与serverUrl是必填项')
                }
            } catch (error) {
                alert('设置填写错误,请重新检查')
            }
        })
        cancelBtn.addEventListener('click', hideModal)

        resetBtn.addEventListener('click', () => {
            refreshModal(defaultSettings)
        })
    }

    function start() {
        settings = {
            ...defaultSettings,
            ...GM_getValue('settings')
        }
        registerMenuListener()
        registerEventListeners()
    }

    start()

    const css = `
        #jv-modal {
            position: fixed;
            left: 0;
            right: 0;
            top: 0;
            bottom: 0;
            background: rgba(0, 0, 0, 0.7);
            z-index: 1100;
            overflow: auto;
            display: none;
            flex-direction: column;
            align-items: center;
            justify-content: center;
        }

        #jv-content {
            white-space: pre-wrap;
            color: white;
            background: #183956;
            width: 50%;
            padding: 30px;
        }

        #jv-content:focus-visible {
            outline: none;
        }

        .jv-btn-group {
            margin-top: 10px;
        }
        .jv-icon-wrapper {
            display: inline-block;
            margin-left: 6px;
            cursor: pointer;
        }
        .jv-icon-wrapper svg {
            width: 1em;
            vertical-align: middle;
        }
    `

    GM_addStyle(css)
})()