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