// ==UserScript==
// @name Jellyfin番号过滤
// @namespace http://tampermonkey.net/
// @version 2.2.20
// @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',
// 复制所有番号快捷键
copyCodeHotKeys: 'ww',
// 复制与用户自定义正则匹配的字符串
copyUserRegHotKeys: 'cc',
// 点击番号时的默认跳转链接,${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: 'brown',
// 番号在jellyfin/115/本地中存在时显示的链接颜色
linkExistColor: '#2A7B5FFF',
// 定义磁力和ed2k链接的颜色
magnetColor: 'indianred',
// 高亮卡片边框样式
emphasisOutlineStyle: '2px solid red',
// 默认会高亮不存在的番号,设置为true则反之
reverseEmphasis: false,
// 是否尽量复用窗口,可以加快打开速度
openLinkInSameTab: false,
// 自定义正则,匹配优先级最低
// \d*[a-z]\d*[-_]\d{2,}
userRegexp: '',
// 自定义正则匹配的高亮颜色
userRegColor: 'orange'
}
// 默认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会保存在云下载目录
offlineCid: '',
// 获取该目录下的文件来更新缓存缓存,包括点击刷新按钮时
fetchCid: '0',
// 是否开启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*[-_]?\s*(?:ppv)?\s*[-_]?\s*(\d{6,8})/i,
num2: /(?<![a-z\d])(\d{4,8})[-_](\d{3,4})/i,
censored: /(?<=\W\d{3}|^\d{3}|\W|^)([a-z]{3,9})(?:[-_]|\s*|0*)?(\d{3,6})(?![a-z\d]|\.com)/i,
uncensored: /(?<![a-z\d])([nk])(\d{3,6})(?![a-z\d]|\.com)/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[1]}` : match[1]
if (type === 'uncensored') return match[1] + match[2]
if (type === 'userReg') return match[0]
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', [])
}
function getLocalNameList() {
return GM_getValue('localNameList', [])
}
let settings = getSettings()
let oofSettings = getOOFSettings()
const codeMap = new Map()
const oofCodeMap = new Map()
let localCodeList = getLocalCodeList()
let localNameList = getLocalNameList()
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 copy = ({ text, desc }) => {
if (!text) return
GM_setClipboard(text)
notify(desc || '复制成功', text)
}
function addClickEvent({ element, copyText, withCtrl = false, handler = noop }) {
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) {
if (typeof copyText === 'string') {
copy({ text: copyText })
} else if (copyText === null) {
// text为null则执行handler,交给handler自己去处理鼠标左右键的情况
handler(e)
}
}
}
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 = [
timeout && `<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 = '取消'
confirmBtn.addEventListener('click', confirm(true))
cancelBtn.addEventListener('click', confirm(false))
footer.append(confirmBtn, cancelBtn)
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) => {
if (!keys) return noop
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()
class EventEmitter {
constructor() {
this.events = {}
}
getListeners(eventName) {
return this.events[eventName] || (this.events[eventName] = [])
}
on(eventName, listener) {
if (typeof listener === 'function') {
this.getListeners(eventName).push(listener)
}
}
off(eventName, listener) {
this.events[eventName] = this.getListeners(eventName).filter(item => item !== listener)
}
emit(eventName, ...args) {
this.getListeners(eventName).forEach(cb => {
try {
cb.apply(this, args)
} catch (error) {
log(error)
}
})
}
}
const eventEmitter = new EventEmitter()
const EVENT_Type = {
clearDom: Symbol(),
clearEvent: Symbol(),
startMatch: Symbol()
}
class CopySet {
constructor() {
this.sets = {}
}
getSetByType = type => {
let innerType = 'code'
const types = ['magnet', 'ed2k', 'userReg']
if (types.includes(type)) {
innerType = type
}
return this.sets[innerType] || (this.sets[innerType] = new Set())
}
registerCopyEvent = () => {
const { copyMagnetHotKeys, copyEd2kHotKeys, copyCodeHotKeys, copyUserRegHotKeys } = settings
const copyEvents = [
[copyMagnetHotKeys, 'magnet'],
[copyEd2kHotKeys, 'ed2k'],
[copyCodeHotKeys, 'code'],
[copyUserRegHotKeys, 'userReg']
]
const clearEventList = copyEvents.map(([keys, type]) => {
const set = this.getSetByType(type)
return keysEvent.on(keys, () => {
if (set.size > 0) {
const content = Array.from(set.values()).join('\n')
GM_setClipboard(content)
notify(`成功复制${set.size}条`, content)
} else {
notify('无内容')
}
})
})
eventEmitter.on(EVENT_Type.clearDom, () => {
Object.values(this.sets).forEach(set => set.clear())
})
eventEmitter.on(EVENT_Type.clearEvent, () => {
clearEventList.forEach(clear => clear())
})
}
setCopySetByType = (type, code) => {
const set = this.getSetByType(type)
set.add(code)
}
}
const copySet = new CopySet()
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 (value && typeof value === 'object') {
return Object.entries(value)
.map(([k, v]) => `${encodeURIComponent(`${key}[${k}]`)}=${encodeURIComponent(v)}`)
.join('&')
} else {
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) {
return (...args) => {
const { enable, cookie } = oofSettings
if (enable && !cookie) {
notify('缺失cookie', '打开115设置, 手动设置cookie; 或者去115登录, 再点击获取cookie')
return
}
return cb.apply(this, args)
}
}
const requiredCookieNames = ['UID', 'CID', 'SEID', 'KID']
function checkRequiredCookies(cookies) {
if (!Array.isArray(cookies) || cookies.length === 0) {
notify('Cookie不存在, 请先登录', '若已登录, 可点击获取Cookie按钮')
return false
}
const lackOfCookieNames = requiredCookieNames.filter(name => !cookies.some(item => item.name === name))
if (lackOfCookieNames.length > 0) {
notify(`Cookie缺少: ${lackOfCookieNames.join('、')}`)
return false
}
return true
}
function getRequiredCookies(cookies) {
return cookies.filter(item => requiredCookieNames.includes(item.name))
}
function parseCookiesToText(array) {
return array.map(({ name, value }) => `${name}=${value}`).join(';')
}
function parseTextToCookies(text) {
const cookies = []
if (!text) return cookies
text.split(/;\s*/).forEach(item => {
if (!item) return
const [name, value] = item.split('=')
cookies.push({ name, value })
})
return cookies
}
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() {
const oofFiles = [...oofCodeMap.values()]
GM_setValue('oofFiles', oofFiles)
}
async function initOOFCodeMap(cid, update = false) {
let oofFiles = GM_getValue('oofFiles', [])
const force = oofFiles.length === 0 || update
if (force) {
const files = await getAllOFFFiles({ cid })
oofFiles = filterFiles(files)
}
oofFiles.forEach(file => {
oofCodeMap.set(file.code, file)
})
if (force) {
setOOFFiles()
}
}
function clearOOFCodeMap() {
oofCodeMap.clear()
GM_setValue('oofFiles', [])
}
const getAllOFFFiles = 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
})
// 20210710-001 -> 071021-001
function parseSpecialNumCode(code) {
const match = code.match(/20(\d{2})(\d{4})-(\d+)/)
if (!match) return null
return `${match[2]}${match[1]}-${match[3]}`
}
function getKeyword({ code, text, type }) {
const words = [code]
if (type === 'num2') {
const parsedCode = parseSpecialNumCode(code)
if (parsedCode) {
words.push(parsedCode)
}
}
if (text.toLowerCase() !== code.toLowerCase()) {
words.push(text)
}
return words.join(' ')
}
const getAndUpdateFileByCode = requireCookie(async ({ code, text = '', forceCheck = false, type }) => {
if (!forceCheck) return oofCodeMap.get(code)
const keyword = getKeyword({ code, text, type })
log(`搜索关键字(${type}): ${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)
log(filteredFiles)
let item = null
let shouldUpdate = false
if (filteredFiles.length > 0) {
filteredFiles.forEach(file => {
if (file.code === code) {
item = file
}
oofCodeMap.set(file.code, file)
})
if (!item && type === 'num2') {
const parsedCode = parseSpecialNumCode(code)
if (parsedCode) {
const file = filteredFiles.find(f => f.code === parsedCode)
if (file) {
item = file
oofCodeMap.set(code, { ...file, code })
}
}
}
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 localName = localNameList.find(name => file.n.startsWith(name))
if (localName) {
file.code = localName
} else {
const code = getParsedCode({ text: file.n })
if (!code) return
file.code = code
}
newFiles.push(file)
})
return newFiles
}
const moveFiles = requireCookie(async (srcId, destId) => {
if (!srcId || !destId) {
notify('移动失败', '请设置移动源和目标目录')
return
}
const srcFiles = await getAllOFFFiles({ cid: srcId })
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: destId, 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 = destId
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 cancelBtn = modal.querySelector('.jv-cancel')
const closeIcon = modal.querySelector('.jv-close-icon')
submitBtn.addEventListener('click', this.submit)
closeIcon.addEventListener('click', this.hide)
cancelBtn.addEventListener('click', this.hide)
this.modal = modal
this.form = form
this.buildExtraButtons()
this.makeDraggable()
document.body.append(this.modal)
this.initialized = true
}
makeDraggable = () => {
let offsetX = 0
let offsetY = 0
const titleBar = this.modal.querySelector('.jv-title')
titleBar.draggable = true
this.modal.addEventListener('mousedown', () => {
const modals = document.querySelectorAll('.jv-modal')
modals.forEach(modal => {
if (modal !== this.modal) {
modal.style.zIndex = 1100
}
})
this.modal.style.zIndex = 1101
})
titleBar.addEventListener('dragstart', e => {
offsetX = e.clientX - this.modal.offsetLeft
offsetY = e.clientY - this.modal.offsetTop
e.dataTransfer.setData('text/plain', '')
e.dataTransfer.setDragImage(new Image(), 0, 0)
e.dataTransfer.dropEffect = 'move'
e.dataTransfer.effectAllowed = 'move'
})
document.addEventListener('dragover', e => {
if (e.target === titleBar) {
e.preventDefault()
const newX = e.clientX - offsetX
const newY = e.clientY - offsetY
this.modal.style.left = `${newX}px`
this.modal.style.top = `${newY}px`
}
})
}
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({
element: btn,
withCtrl,
handler: onclick.bind(this)
})
}
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'></form>
<div class='jv-btn-group'>
<button class='jv-submit'>确定</button>
<button class='jv-cancel'>取消</button>
</div>
</div>`
setInnerHTML(modal, html)
return modal
}
show = title => {
if (title) {
this.title = title
}
if (!this.initialized) {
this.init()
}
this.modal.style.display = 'block'
this.refresh(this.formData)
this.onShow?.()
}
hide = () => {
this.modal.style.display = 'none'
}
refresh = (formData, isUpdate = false) => {
if (isUpdate) {
this.formData = {
...this.getRealTimeFormData(),
...formData
}
} else {
this.formData = formData
}
const formItems = this.buildFormItems()
if (typeof formItems === 'string') {
setInnerHTML(this.form, formItems)
} else {
this.form.replaceChildren(formItems)
}
}
getRealTimeFormData = () => {
const formData = new FormData(this.form)
const data = {}
for (const [key, value] of formData) {
data[key] = convertFormValue(value.trim())
}
return data
}
submit = async () => {
const data = this.getRealTimeFormData()
this.formData = data
const shouldHide = await this.onSubmit?.(data)
if (shouldHide !== false) {
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({
defaultData: defaultOOFSettings,
getFormData: getOOFSettings,
async onSubmit(formData) {
const { cookie, expiresIn, enable } = formData
if (enable && location.hostname === '115.com') {
const cookies = parseTextToCookies(cookie)
const checked = checkRequiredCookies(cookies)
if (!checked) return false
const requiredCookies = getRequiredCookies(cookies)
const expires = parseInt(expiresIn) || 30
for (const { name, value } of requiredCookies) {
try {
await GM.cookie.set({
name,
value,
domain: '.115.com',
path: '/',
secure: false,
httpOnly: true,
expirationDate: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * expires
})
} catch (error) {
console.error(error)
notify('设置Cookie失败')
return
}
}
formData.cookie = parseCookiesToText(requiredCookies)
}
GM_setValue('oofSettings', formData)
notify('设置成功')
restart()
},
onShow: refreshClearBtn,
extraButtons: [
{
textContent: '获取115Cookie',
onclick: async () => {
if (location.hostname !== '115.com') {
notify('请在115页面上获取Cookie')
return
}
try {
const cookies = await GM.cookie.list({ domain: '.115.com' })
log(cookies)
const checked = checkRequiredCookies(cookies)
if (!checked) return
const requiredCookies = getRequiredCookies(cookies)
const cookieText = parseCookiesToText(requiredCookies)
notify('获取Cookie成功')
oofModal.refresh({ cookie: cookieText }, true)
} catch (error) {
console.error(error)
}
}
},
{
textContent: `刷新缓存`,
async onclick() {
const formData = this.getRealTimeFormData()
await initOOFCodeMap(formData.fetchCid, true)
refreshClearBtn()
}
},
{
id: 'jv-refresh-clear-btn',
textContent: `清空缓存(${oofCodeMap.size})`,
onclick: () => {
clearOOFCodeMap()
refreshClearBtn()
}
},
{
textContent: '批量移动',
onclick() {
const formData = this.getRealTimeFormData()
const [srcId, destId] = formData.move.split(/\s*:\s*/).map(item => item.trim())
moveFiles(srcId, destId)
}
}
]
})
const localCodeModal = new ConfigModal({
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}个`)
restart()
}
})
const localNameModal = new ConfigModal({
buildItems(formData) {
return `
<textarea name='localNameList' style='width: 1100px; height: 500px;' id='jv-local-name-textarea'>${formData.localNameList.join('\n')}</textarea>
`
},
defaultData: { localNameList: [] },
getFormData: () => ({ localNameList: getLocalNameList() }),
onSubmit(formData) {
const newLocalNameList = formData.localNameList
.split('\n')
.map(name => name.toLowerCase().trim())
.filter(Boolean)
localNameList = newLocalNameList
GM_setValue('localNameList', newLocalNameList)
this.refresh({ localNameList: newLocalNameList })
notify('设置成功', `共设置自定义名称${newLocalNameList.length}个`)
restart()
}
})
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({ element: icon })
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,
copyText: url,
handler: e => openTab(url, e.ctrlKey)
})
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, type, 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) {
copy({ text: url })
}
}
function getCidMap(cidPair) {
return new Map(
cidPair
.split(/\s*;\s*/)
.filter(Boolean)
.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) {
copy({ text: downloadUrl, desc: '下载链接已复制' })
}
}
}
let removeWatchIconEvent
async function checkWatchIcon({ link, code, text, type, 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, type, forceCheck })
if (!update || !link) return file
if (file) {
const bindEvent = (innerOOFIcon, innerAlistIcon) => {
const removeOOFEvent = addClickEvent({
element: innerOOFIcon,
handler: handleOOFIconClick(file, link),
copyText: null
})
let removeAlistEvent
if (innerAlistIcon) {
removeAlistEvent = addClickEvent({
element: innerAlistIcon,
handler: alistIconClick(file),
copyText: null
})
}
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 match = oofSettings.cookie.match(/UID=(\d+)_/)
const userId = match?.[1]
log(`userId为: ${userId}`)
return userId
})
const addOfflineTask = requireCookie(async magnet => {
const userId = getUserId()
if (!userId) {
notify('未获取到UID, 请检查Cookie')
return
}
const { close } = notify('正在添加离线任务', magnet, 0)
try {
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 { offlineCid } = oofSettings
const data = { url: magnet, uid: userId, sign, time, wp_path_id: offlineCid || 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({
element: button,
handler: () => addOfflineTask(magnet),
url: magnet,
withCtrl: true,
copyText: magnet
})
parent.append(button)
}
function getLinkColorByType(type) {
const { linkColor, magnetColor, userRegColor } = settings
if (type === 'userReg') return userRegColor
if (isTypeMagnetLike(type)) return magnetColor
return linkColor
}
function handleLinkClick(text, code, type) {
return async e => {
const link = e.target
const { linkVisitedColor, linkExistColor, openSite, secondarySite, fc2Site } = settings
const { enable: oofEnable } = oofSettings
if (isTypeMagnetLike(type)) {
if (type === 'magnet') {
window.open(code, '_self')
}
} else {
if (oofEnable && e.altKey) {
const isExsit = await checkWatchIcon({ link, code, text, type, forceCheck: true, update: true })
if (isExsit) {
setStyle(link, { color: linkExistColor })
} else {
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)
}
}
}
}
}
function createLink(text, code, type) {
const link = document.createElement('a')
link.append(text)
link.className = 'jv-link'
link.setAttribute('data-jv-code', code)
const { linkExistColor } = settings
const { enable: oofEnable } = oofSettings
const isMagnetLike = isTypeMagnetLike(type)
const linkColor = getLinkColorByType(type)
setStyle(link, { color: linkColor })
copySet.setCopySetByType(type, code)
if (oofEnable && isMagnetLike) {
addOfflineBtn(link, code)
}
addClickEvent({
element: link,
handler: handleLinkClick(text, code, type),
copyText: code
})
if (!isMagnetLike) {
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 replaceNameWithLink(text) {
text = text.toLowerCase()
const fragment = document.createDocumentFragment()
let isNull = true
let lastIndex = 0
const textLength = text.length
for (const localName of localNameList) {
if (lastIndex >= textLength) {
break
}
const matchIndex = text.indexOf(localName, lastIndex)
if (matchIndex > -1) {
isNull = false
const textBeforeMatch = text.slice(lastIndex, matchIndex)
if (textBeforeMatch.length > 0) {
fragment.append(textBeforeMatch)
}
const link = createLink(localName, localName, 'localName')
fragment.append(link)
lastIndex = matchIndex + localName.length
}
}
const remainingText = text.slice(lastIndex)
if (remainingText.length > 0) {
fragment.append(remainingText)
}
return isNull ? null : fragment
}
function handleTextNode(node) {
const text = node.nodeValue
if (!text) return
const { userRegexp } = settings
const regList = userRegexp ? [...textRegList, { type: 'userReg', reg: new RegExp(userRegexp, 'gi') }] : textRegList
for (const regItem of regList) {
const codeFragment = replaceCodeWithLink(text, regItem)
if (codeFragment) {
node.replaceWith(codeFragment)
return
}
}
const nameFragment = replaceNameWithLink(text)
if (nameFragment) {
node.replaceWith(nameFragment)
}
}
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' ||
parent.id === 'jv-local-name-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)
}
}
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()
if (triggerOnload) {
run()
}
eventEmitter.on(EVENT_Type.clearDom, clear)
eventEmitter.on(EVENT_Type.clearEvent, keysEvent.on(hotKeys, run))
}
}
function clearCommonMatchDoms() {
const links = document.querySelectorAll('.jv-link')
links.forEach(link => {
const parent = link.parentNode
link.replaceWith(link.firstChild)
parent.normalize()
})
}
function startCommonMatch() {
const { hotKeys, triggerOnload } = settings
if (triggerOnload) {
traverse()
}
eventEmitter.on(EVENT_Type.clearEvent, keysEvent.on(hotKeys, traverse))
eventEmitter.on(EVENT_Type.clearDom, clearCommonMatchDoms)
}
function registerKeysEvent() {
const { recoverHotKeys } = settings
const events = [
[
recoverHotKeys,
() => {
log('清除页面改动')
eventEmitter.emit(EVENT_Type.clearDom)
}
]
]
events.forEach(([keys, cb]) => {
eventEmitter.on(EVENT_Type.clearEvent, keysEvent.on(keys, cb))
})
}
async function restart() {
const { debug, serverUrl } = settings
const { enable: oofEnable, fetchCid } = 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)
if (oofEnable) {
await initOOFCodeMap(fetchCid, false)
}
eventEmitter.emit(EVENT_Type.clearDom)
eventEmitter.emit(EVENT_Type.clearEvent)
eventEmitter.emit(EVENT_Type.startMatch)
}
function removeAD() {
document.querySelector('#js_common_mini-dialog')?.remove()
document.querySelector('.vt-headline > div:last-child')?.remove()
}
function registerMenuCommand() {
const events = [
['设置', configModal.show],
['115设置', oofModal.show],
['补充本地番号', localCodeModal.show],
['新增匹配名称', localNameModal.show]
]
events.forEach(([title, cb]) => GM_registerMenuCommand(title, () => cb(title)))
}
function registerEvents() {
const startFuncList = [startSpecialMatch, startCommonMatch, registerKeysEvent, copySet.registerCopyEvent]
startFuncList.forEach(cb => eventEmitter.on(EVENT_Type.startMatch, cb))
}
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;
max-width: 100%;
overflow: auto;
}
.jv-svg, .jv-oof-svg, .jv-alist-svg {
margin: 0 2px;
width: 1.2em;
max-width: 32px;
vertical-align: middle;
cursor: pointer;
flex-shrink: 0;
min-width: 12px;
}
.jv-local-icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 1.4em;
height: 1.4em;
min-width: 12px;
max-width: 32px;
flex-shrink: 0;
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()}
padding-top: 0;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1100;
overflow: auto;
display: none;
}
.jv-section {
width: 1100px;
}
.jv-section .jv-title {
font-weight: bold;
font-size: 22px;
padding: 20px 0;
text-align: center;
cursor: grab;
user-select: none;
}
.jv-form {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
max-width: unset;
}
.jv-form-item {
display: flex;
justify-content: space-between;
align-items: center;
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 cssWithoutComment = css
.split('\n')
.filter(line => !line.trim().startsWith('//'))
.join('\n')
GM_addStyle(cssWithoutComment)
}
function start() {
addStyle()
registerMenuCommand()
registerEvents()
removeAD()
restart()
}
start()
})()