// ==UserScript== // @name Jellyfin随机播放优化 // @version 0.4.0 // @description 劫持电影库页面原有的"随机播放"按钮:改为一次 API 请求随机取一部电影并自动播放,不重扫库;模式可在脚本猫面板菜单中切换 // @author ShuiYun // @match *://*/web/index.html* // @run-at document-idle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== (function () { 'use strict'; // 顶层防御:任何未预期错误都打印出来,便于定位;局部错误不影响其他功能 try { // 沙箱中访问页面全局:ScriptCat 半沙箱,unsafeWindow 不可用时退回 window const pageWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; console.log('[JFRP] 脚本已加载 URL:', pageWin.location.href, 'hash:', pageWin.location.hash); // ========== 配置 ========== // 只在目标页面(hash 路由)劫持原按钮;第一版只支持电影页,#/tv 后续扩展 const TARGET_HASHES = ['#/movies', '#!/movies']; // 详情页路由:10.9+ 与 modern UI 用 /details,10.8 旧版是 /itemdetails.html const DETAILS_ROUTE = '/details?id='; // 原有"随机播放"按钮的选择器(master modern UI 为 .btnShuffle) const SHUFFLE_SELECTORS = ['.btnShuffle', 'button[data-testid="shuffle"]', '.shuffleButton']; // ========== 模式(脚本猫面板菜单中切换,localStorage 记忆) ========== // 'play' 随机后自动播放;'details' 随机后只打开详情页 const MODE_KEY = 'jfrp-random-mode'; let currentMode = 'play'; function loadMode() { try { const v = pageWin.localStorage.getItem(MODE_KEY); if (v === 'play' || v === 'details') currentMode = v; } catch (e) { console.warn('[JFRP] 读取模式失败(保持默认):', e); } } function saveMode(mode) { currentMode = mode === 'details' ? 'details' : 'play'; try { pageWin.localStorage.setItem(MODE_KEY, currentMode); } catch (e) { console.warn('[JFRP] 保存模式失败:', e); } } // ========== 工具 ========== // 等待任一选择器匹配的元素出现且可见(非 .hide),用于详情页播放按钮 // legacy 页面按钮:.mainDetailButtons [data-action="play"] // React 版页面按钮:.btnPlayOrResume(不同版本结构不同,全部探测) function waitForAny(selectors, timeoutMs) { return new Promise((resolve, reject) => { const start = Date.now(); const timer = setInterval(() => { for (const sel of selectors) { const el = document.querySelector(sel); if (el && !el.classList.contains('hide')) { clearInterval(timer); resolve(el); } } if (Date.now() - start > (timeoutMs || 10000)) { clearInterval(timer); reject(new Error('等待超时: ' + selectors.join(', '))); } }, 200); }); } // 从当前 URL hash 解析媒体库 ID(兼容 legacy 的 parentId 与 modern 的 topParentId) function getParentIdFromUrl() { const hash = pageWin.location.hash || ''; const qIndex = hash.indexOf('?'); if (qIndex < 0) return null; const params = new URLSearchParams(hash.slice(qIndex + 1)); return params.get('parentId') || params.get('topParentId'); } function isTargetPage() { return TARGET_HASHES.some((h) => (pageWin.location.hash || '').startsWith(h)); } // ========== 核心:随机取一部电影并跳转详情页 ========== // autoplay=true 自动点击播放(模式一);false 只打开详情页(模式二) async function randomMovie(autoplay) { const apiClient = pageWin.ApiClient; if (!apiClient) { alert('未找到 Jellyfin ApiClient,请确认已登录后重试'); return; } const userId = apiClient.getCurrentUserId(); // 与电影库页面同一组过滤参数 + SortBy=Random + Limit=1: // 服务端 ORDER BY RANDOM() 一次只回一部,毫秒级,不重扫库 const query = { SortBy: 'Random', SortOrder: 'Ascending', IncludeItemTypes: 'Movie', Recursive: true, Filters: 'IsNotFolder', Limit: 1, StartIndex: 0, }; const parentId = getParentIdFromUrl(); if (parentId) query.ParentId = parentId; try { const result = await apiClient.getItems(userId, query); const item = result.Items && result.Items[0]; if (!item) { alert('库中没有可播放的电影'); return; } console.log('[JFRP] 随机选中:', item.Name, item.Id, '模式:', autoplay ? '播放' : '详情'); // 导航到详情页 pageWin.location.hash = DETAILS_ROUTE + encodeURIComponent(item.Id); // 模式一:等待播放按钮可见后自动点击(优先从头播放,其次继续播放) if (autoplay) { const playBtn = await waitForAny([ '.mainDetailButtons [data-action="play"]', '.btnPlayOrResume[data-action="play"]', '.mainDetailButtons [data-action="resume"]', '.btnPlayOrResume[data-action="resume"]', ]); playBtn.click(); } } catch (e) { console.error('[JFRP] 随机播放失败:', e); // ---- 失败诊断,用于定位详情页结构 ---- console.log('[JFRP] 诊断 hash:', pageWin.location.hash); console.log('[JFRP] 诊断 .mainDetailButtons 数量:', document.querySelectorAll('.mainDetailButtons').length); console.log('[JFRP] 诊断 .btnPlayOrResume 数量:', document.querySelectorAll('.btnPlayOrResume').length); console.log('[JFRP] 诊断 播放按钮:', [...document.querySelectorAll('[data-action]')] .map((el) => el.tagName + '.' + el.className + ' action=' + el.dataset.action + ' hide=' + el.classList.contains('hide')) .join(' | ') || '(无 data-action 元素)'); alert('随机播放失败: ' + (e && e.message ? e.message : e)); } } // ========== 劫持原有"随机播放"按钮 ========== // 在捕获阶段拦截点击,阻止 Jellyfin 原逻辑(拉全库建队列,慢), // 改为快速随机。React 的合成事件绑定在根容器,捕获阶段拦截可完全掐断。 // SPA 重渲染会重建按钮,由 MutationObserver 重新绑定。 let hookedBtn = null; function hookShuffleButton() { // 已绑定且按钮还在 DOM:不动 if (hookedBtn && document.body.contains(hookedBtn)) return; hookedBtn = null; for (const sel of SHUFFLE_SELECTORS) { const el = document.querySelector(sel); if (!el) continue; if (!el.dataset.jfrpHooked) { el.dataset.jfrpHooked = '1'; el.addEventListener('click', (e) => { // 非目标页面:保持 Jellyfin 原功能 if (!isTargetPage()) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); randomMovie(currentMode === 'play'); }, true); } hookedBtn = el; console.log('[JFRP] 已劫持原随机播放按钮:', sel, '标题:', el.title || el.getAttribute('aria-label') || '(无)'); return; } console.warn('[JFRP] 未找到原随机播放按钮,选择器:', SHUFFLE_SELECTORS.join(', ')); } // ========== 启动 ========== loadMode(); try { hookShuffleButton(); } catch (e) { console.error('[JFRP] 按钮劫持失败:', e); } // SPA 渲染变化时确保劫持仍然生效(防抖 300ms) let watchTimer = null; new MutationObserver(() => { if (watchTimer) return; watchTimer = setTimeout(() => { watchTimer = null; try { hookShuffleButton(); } catch (e) { console.error('[JFRP] 按钮维护失败:', e); } }, 300); }).observe(document.body, { childList: true, subtree: true }); // 路由切换后重新确认劫持 pageWin.addEventListener('hashchange', () => { try { hookShuffleButton(); } catch (e) { console.error('[JFRP] 按钮更新失败:', e); } }); // ========== ScriptCat 面板菜单:一条命令,点击切换模式(设置面板) ========== let menuId = null; // 菜单文字随当前模式变化:注销旧命令,注册新命令 function registerMenuCommand() { if (typeof GM_registerMenuCommand === 'undefined') { console.warn('[JFRP] 当前环境不支持 GM_registerMenuCommand,模式切换不可用'); return; } if (menuId != null && typeof GM_unregisterMenuCommand !== 'undefined') { try { GM_unregisterMenuCommand(menuId); } catch (e) { /* 忽略 */ } } const isPlay = currentMode === 'play'; menuId = GM_registerMenuCommand( isPlay ? '随机模式:播放(点击切换为详情)' : '随机模式:详情(点击切换为播放)', () => toggleMode() ); console.log('[JFRP] 菜单命令已注册,当前模式:', currentMode); } // 点击菜单命令 = 切换模式 function toggleMode() { saveMode(currentMode === 'play' ? 'details' : 'play'); alert('已切换模式: ' + (currentMode === 'play' ? '播放(自动播放)' : '详情(只打开详情页)')); registerMenuCommand(); // 更新菜单文字 } try { registerMenuCommand(); } catch (e) { console.error('[JFRP] 菜单注册失败:', e); } } catch (e) { console.error('[JFRP] 脚本执行出错:', e); } })();