// ==UserScript== // @name MPC-JF - MPC launcher for Jellyfin // @namespace https://github.com/Damocles-fr/MPCJF/ // @version 10.11 // @updateURL https://raw.githubusercontent.com/Damocles-fr/MPCJF/refs/heads/main/MPCJF.js // @downloadURL https://raw.githubusercontent.com/Damocles-fr/MPCJF/refs/heads/main/MPCJF.js // @description Intercept Play/Resume clicks in Jellyfin to launch medias with the MPCJF.ps1 script // @author Damocles-fr // @match http://localhost:8096/* // @run-at document-end // @grant none // ==/UserScript== (() => { 'use strict'; const CFG = { debug: false }; const log = (...a) => { if (CFG.debug) console.log('[MPCJF]', ...a); }; // mémorise le choix "version" par itemId (utile quand Jellyfin recycle le DOM) const lastSelectedByItem = new Map(); let _userIdPromise = null; const getUserId = async () => { if (!_userIdPromise) _userIdPromise = ApiClient.getCurrentUser().then(u => u.Id); return _userIdPromise; }; const toMPCJFUrl = (rawPath) => { const forward = String(rawPath).replace(/\\/g, '/'); const encoded = encodeURIComponent(forward).replace(/%2F/g, '/'); return 'MPCJF://' + encoded; }; const isGuidLike = (s) => typeof s === 'string' && /^[0-9a-f]{8,}(-[0-9a-f]{4,}){0,4}$/i.test(s); const getAttr = (el, name) => (el && el.getAttribute) ? el.getAttribute(name) : null; const isVisible = (el) => { if (!el || !el.isConnected) return false; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; if (el.closest && el.closest('[aria-hidden="true"]')) return false; const r = el.getBoundingClientRect(); return (r.width > 0 || r.height > 0); }; const getIdFromEl = (el) => { if (!el || !el.getAttribute) return null; const directAttrs = [ 'data-id', 'data-itemid', 'data-item-id', 'data-baseitemid', 'data-playbackid', 'data-entityid', 'data-mediaid', 'data-episodeid' ]; for (const a of directAttrs) { const v = getAttr(el, a); if (v && isGuidLike(v)) return v; } const ds = el.dataset || {}; const dsCandidates = [ds.id, ds.itemid, ds.itemId, ds.baseitemid, ds.baseItemId, ds.entityid, ds.entityId]; for (const v of dsCandidates) { if (v && isGuidLike(v)) return v; } const href = getAttr(el, 'href'); if (href) { const m = /[?&]id=([^&]+)/.exec(href); if (m && isGuidLike(m[1])) return m[1]; } return null; }; const getIdFromHash = () => { const h = String(location.hash || ''); const m = /[?&]id=([^&]+)/.exec(h); if (m && isGuidLike(m[1])) return m[1]; return null; }; const buildPath = (target) => { const out = []; let el = target; while (el) { out.push(el); el = el.parentNode; if (out.length > 25) break; } out.push(document, window); return out; }; const eventPath = (e) => (e && typeof e.composedPath === 'function') ? e.composedPath() : buildPath(e.target); const normalizeText = (s) => String(s || '').trim().toLowerCase(); const classHasToken = (cls, token) => new RegExp(`(^|[\\s_-])${token}([\\s_-]|$)`, 'i') .test(String(cls || '')); const looksLikePlayControl = (el) => { if (!el || !el.getAttribute) return false; const aria = normalizeText(getAttr(el, 'aria-label')); const title = normalizeText(getAttr(el, 'title')); const cls = String(el.className || ''); const txt = normalizeText(el.textContent); // --- NEW: Blacklist Section --- // If the button mentions "mark" or "check", it's a status toggle, not a play command. const isMarkControl = aria.includes('mark') || title.includes('mark') || aria.includes('check') || title.includes('check') || classHasToken(cls, 'played'); if (isMarkControl) return false; // ------------------------------ const dataMode = normalizeText(getAttr(el, 'data-mode')); const dataAction = normalizeText(getAttr(el, 'data-action')); const dataCommand = normalizeText(getAttr(el, 'data-command')); if (dataMode === 'play' || dataMode === 'resume') return true; if (dataAction === 'play' || dataAction === 'resume') return true; if (dataCommand.includes('play') || dataCommand.includes('resume')) return true; if (aria.includes('play') || aria.includes('lire') || aria.includes('reprendre') || aria.includes('resume')) return true; if (title.includes('play') || title.includes('lire') || title.includes('reprendre') || title.includes('resume')) return true; if (txt === 'play_arrow' || txt === 'play_circle' || txt === 'resume' || txt === 'replay') return true; if (classHasToken(cls, 'play') || classHasToken(cls, 'resume')) return true; return false; }; const findPlayControlInPath = (path) => { for (const node of path) { if (!node || !node.getAttribute) continue; const tag = String(node.tagName || '').toLowerCase(); const role = normalizeText(getAttr(node, 'role')); const isInteractive = (tag === 'button' || tag === 'a' || role === 'button'); if (isInteractive && looksLikePlayControl(node)) return node; if (!isInteractive && looksLikePlayControl(node)) { const owner = node.closest ? node.closest('button,a,[role="button"]') : null; if (owner && looksLikePlayControl(owner)) return owner; } } return null; }; const findItemIdInPath = (path) => { for (const node of path) { const id = getIdFromEl(node); if (id) return id; } return getIdFromHash(); }; // ---- Source/version select (multi-versions) ---- const getSelectValueAsMediaSourceId = (sel) => { if (!sel) return null; // propriété value let v = String(sel.value || ''); if (isGuidLike(v)) return v; // selectedOptions / selectedIndex const opt = (sel.selectedOptions && sel.selectedOptions[0]) || (sel.options && sel.options[sel.selectedIndex]) || sel.querySelector?.('option[selected]') || null; v = String(opt?.value || ''); if (isGuidLike(v)) return v; return null; }; const findVisibleSourceSelectFromPath = (path) => { const strong = 'select.selectSource.detailTrackSelect'; const weak = 'select.selectSource'; for (const node of path) { if (!node || !node.querySelectorAll) continue; const a = [...node.querySelectorAll(strong)].find(isVisible); if (a) return a; const b = [...node.querySelectorAll(weak)].find(isVisible); if (b) return b; } return null; }; const findVisibleSourceSelectInDocument = () => { const strong = [...document.querySelectorAll('select.selectSource.detailTrackSelect')].find(isVisible); if (strong) return strong; const weak = [...document.querySelectorAll('select.selectSource')].find(isVisible); if (weak) return weak; return null; }; const getSelectedMediaSourceId = (path, itemId) => { // 1) select visible dans la même vue que le clic const localSel = findVisibleSourceSelectFromPath(path); const localVal = getSelectValueAsMediaSourceId(localSel); if (localVal) return localVal; // 2) select visible dans le document (page details active) const docSel = findVisibleSourceSelectInDocument(); const docVal = getSelectValueAsMediaSourceId(docSel); if (docVal) return docVal; // 3) fallback mémoire par itemId if (itemId && lastSelectedByItem.has(itemId)) return lastSelectedByItem.get(itemId); return null; }; // mémorise le choix quand l'utilisateur change la version document.addEventListener('change', (e) => { const t = e?.target; if (!t || !t.matches) return; if (!t.matches('select.selectSource.detailTrackSelect, select.selectSource')) return; if (!isVisible(t)) return; const itemId = getIdFromHash(); const msid = getSelectValueAsMediaSourceId(t); if (itemId && msid) { lastSelectedByItem.set(itemId, msid); log('remember', { itemId, msid }); } }, true); const stopEvent = (e) => { try { e.preventDefault(); } catch {} try { e.stopPropagation(); } catch {} try { e.stopImmediatePropagation(); } catch {} }; const resolvePathFromItem = async (itemId, mediaSourceId = null, depth = 0) => { if (!itemId || depth > 6) return null; const userId = await getUserId(); const item = await ApiClient.getItem(userId, itemId); const ms = item && item.MediaSources; // priorité à la MediaSource sélectionnée if (mediaSourceId && ms && ms.length) { const wanted = ms.find(x => String(x?.Id || '').toLowerCase() === String(mediaSourceId).toLowerCase() || String(x?.MediaSourceId || '').toLowerCase() === String(mediaSourceId).toLowerCase() ); if (wanted?.Path) return wanted.Path; } // comportement standard if (item?.Path && (!ms || ms.length <= 1)) return item.Path; if (ms?.length && ms[0]?.Path) return ms[0].Path; // Folder-like items: find a playable descendant const query = { parentId: itemId, recursive: true, includeItemTypes: 'Movie,Episode,Video', limit: 1, sortBy: 'SortName', sortOrder: 'Ascending' }; const res = await ApiClient.getItems(userId, query); if (res?.Items?.length && res.Items[0]?.Id) { return resolvePathFromItem(res.Items[0].Id, null, depth + 1); } return null; }; let _lastLaunchAt = 0; const launchMPCJF = async (itemId, mediaSourceId = null) => { const now = Date.now(); if (now - _lastLaunchAt < 600) return; _lastLaunchAt = now; const p = await resolvePathFromItem(itemId, mediaSourceId); if (!p) { console.warn('[MPCJF] Unable to resolve a local Path for itemId:', itemId, 'mediaSourceId:', mediaSourceId); return; } const url = toMPCJFUrl(p); log('launch', { itemId, mediaSourceId, path: p, url }); window.location.replace(url); }; const shouldIgnore = (e) => { if (!e) return true; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return true; if (typeof e.button === 'number' && e.button !== 0) return true; return false; }; const onUserActivate = (e) => { if (shouldIgnore(e)) return; const path = eventPath(e); const control = findPlayControlInPath(path); if (!control) return; const itemId = findItemIdInPath(path); if (!itemId) return; const mediaSourceId = getSelectedMediaSourceId(path, itemId); stopEvent(e); launchMPCJF(itemId, mediaSourceId); }; document.addEventListener('click', onUserActivate, true); document.addEventListener('pointerup', onUserActivate, true); document.addEventListener('keydown', (e) => { const key = e && e.key; if (key !== 'Enter' && key !== ' ') return; const path = eventPath(e); const control = findPlayControlInPath(path); if (!control) return; const itemId = findItemIdInPath(path); if (!itemId) return; const mediaSourceId = getSelectedMediaSourceId(path, itemId); stopEvent(e); launchMPCJF(itemId, mediaSourceId); }, true); log('loaded'); })();