// ==UserScript==
// @name Jellyfin番号过滤
// @namespace http://tampermonkey.net/
// @version 0.0.6
// @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 = {
// 在jellyfin或emby中获取,必填
apiKey: '',
// 服务器是否为emby
isEmby: false,
// 服务器地址,必填
serverUrl: 'http://127.0.0.1:8096',
// 默认会突出显示没有下载的
reverseEmphasis: false,
// 可自定义突出显示样式
emphasisStyle: {
outline: '2px solid red'
},
// 若为true,则在页面加载完成后自动触发过滤,后面的快捷键设置会失效
triggerOnload: false,
// 自定义快捷键,可以是任意长度的数字或字母
hotKeys: 'ee',
// 规定时间内按完快捷键才会触发,单位为毫秒,不建议修改
timeout: 500,
// 快捷键触发方式,不建议修改
eventType: 'keypress'
}
const CONFIG = [
{
site: 'javbus',
cb: () => findCode('a.movie-box', 'date', '.item-tag')
},
{
site: 'javlibrary',
cb: () => findCode('.video', '.id', 'a[href]')
},
{
site: 'javdb',
cb: () => findCode('.movie-list .item', '.video-title strong', '.tags.has-addons')
},
{
site: 'jinjier',
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 = {
...defaultSettings,
...GM_getValue('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 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++
})
}
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 shakeSettings(userSettings) {
const newSettings = {}
Object.keys(defaultSettings).forEach(key => {
const value = userSettings[key]
newSettings[key] = value != null ? value : defaultSettings[key]
})
return newSettings
}
function handleSubmit() {
try {
const userSettings = JSON.parse(contentEle.textContent)
if (userSettings.apiKey && userSettings.serverUrl) {
const newSettings = shakeSettings(userSettings)
GM_setValue('settings', newSettings)
settings = newSettings
hideModal()
setTimeout(() => {
window.location.reload()
})
} else {
alert('apiKey与serverUrl是必填项')
}
} catch (error) {
alert('设置填写错误,请重新检查')
}
}
function registerMenuListener() {
GM_registerMenuCommand('自定义设置', showModal)
}
function registerEventListeners() {
submitBtn.addEventListener('click', handleSubmit)
cancelBtn.addEventListener('click', hideModal)
resetBtn.addEventListener('click', () => {
refreshModal(defaultSettings)
})
}
function start() {
registerMenuListener()
registerEventListeners()
CONFIG.forEach(({ site, cb }) => {
if (!location.href.includes(site)) return
const { eventType, hotKeys, timeout, triggerOnload } = settings
if (triggerOnload) {
cb()
} else {
registerKeysEvent(eventType, hotKeys, cb, timeout)
}
})
}
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)
})()