mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 07:42:47 +01:00
502 lines
17 KiB
JavaScript
502 lines
17 KiB
JavaScript
// KefinTweaks Item Version Preferences
|
|
// Reorders and sanitizes media source options on item details pages
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const LOG = (...args) => console.log('[KefinTweaks VersionPreferences]', ...args);
|
|
const WARN = (...args) => console.warn('[KefinTweaks VersionPreferences]', ...args);
|
|
|
|
const NONE_SENTINEL = 'None';
|
|
const DEFAULT_VERSIONS = ['4k', '2160p', '1080p', '720p', '576p', '480p', '360p', NONE_SENTINEL];
|
|
const DEFAULT_EDITIONS = ['Theatrical', "Director's Cut", 'Extended Edition', 'Fan Edit', NONE_SENTINEL];
|
|
|
|
const PAGE_SELECTOR = '#itemDetailPage:not(.hide)';
|
|
const FORM_SELECTOR = 'form.trackSelections';
|
|
const SELECTOR = `${PAGE_SELECTOR} ${FORM_SELECTOR} select.detailTrackSelect`;
|
|
const SELECT_FALLBACK = `${PAGE_SELECTOR} select.detailTrackSelect`;
|
|
const ORIGINAL_ATTR = 'data-kefin-original-label';
|
|
const SELECTED_INDEX_ATTR = 'data-selected-index';
|
|
const CHANGE_BOUND_ATTR = 'data-kefin-vp-change-bound';
|
|
const APPLIED_ATTR = 'data-kefin-vp-applied';
|
|
|
|
function isNoneSentinel(term) {
|
|
return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Keep exactly one "None" sentinel (canonical casing). Missing → append at end.
|
|
*/
|
|
function ensureNoneSentinel(list) {
|
|
const out = [];
|
|
let noneSeen = false;
|
|
(Array.isArray(list) ? list : []).forEach((term) => {
|
|
if (typeof term !== 'string' || !term.trim()) return;
|
|
if (isNoneSentinel(term)) {
|
|
if (!noneSeen) {
|
|
out.push(NONE_SENTINEL);
|
|
noneSeen = true;
|
|
}
|
|
return;
|
|
}
|
|
out.push(term.trim());
|
|
});
|
|
if (!noneSeen) out.push(NONE_SENTINEL);
|
|
return out;
|
|
}
|
|
|
|
function getDefaults() {
|
|
return {
|
|
versions: DEFAULT_VERSIONS.slice(),
|
|
editions: DEFAULT_EDITIONS.slice(),
|
|
showVersionBeforeEdition: false
|
|
};
|
|
}
|
|
|
|
function getPreferences() {
|
|
const defaults = getDefaults();
|
|
const cfg = window.KefinTweaksConfig?.versionPreferences || {};
|
|
return {
|
|
versions: ensureNoneSentinel(
|
|
Array.isArray(cfg.versions) ? cfg.versions : defaults.versions
|
|
),
|
|
editions: ensureNoneSentinel(
|
|
Array.isArray(cfg.editions) ? cfg.editions : defaults.editions
|
|
),
|
|
showVersionBeforeEdition: cfg.showVersionBeforeEdition === true
|
|
};
|
|
}
|
|
|
|
function indexOfNone(terms) {
|
|
const idx = (terms || []).findIndex(isNoneSentinel);
|
|
return idx >= 0 ? idx : (terms || []).length;
|
|
}
|
|
|
|
/**
|
|
* Highest-priority (earliest array index) case-insensitive substring match.
|
|
* Skips the "None" sentinel — it is not a text match.
|
|
* @returns {{ term: string, index: number } | null}
|
|
*/
|
|
function findBestTerm(label, terms) {
|
|
if (!label || !Array.isArray(terms) || !terms.length) return null;
|
|
const haystack = String(label).toLowerCase();
|
|
for (let i = 0; i < terms.length; i++) {
|
|
const term = terms[i];
|
|
if (typeof term !== 'string' || !term.trim()) continue;
|
|
if (isNoneSentinel(term)) continue;
|
|
if (haystack.includes(term.toLowerCase())) {
|
|
return { term, index: i };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getOriginalLabel(option) {
|
|
if (!option) return '';
|
|
if (option.hasAttribute(ORIGINAL_ATTR)) {
|
|
return option.getAttribute(ORIGINAL_ATTR) || '';
|
|
}
|
|
const label = option.textContent || '';
|
|
option.setAttribute(ORIGINAL_ATTR, label);
|
|
return label;
|
|
}
|
|
|
|
function sanitizeLabel(editionTerm, versionTerm, showVersionBeforeEdition = false) {
|
|
if (editionTerm && versionTerm) {
|
|
return showVersionBeforeEdition
|
|
? `${versionTerm} - ${editionTerm}`
|
|
: `${editionTerm} - ${versionTerm}`;
|
|
}
|
|
if (editionTerm) return editionTerm;
|
|
if (versionTerm) return versionTerm;
|
|
return null;
|
|
}
|
|
|
|
function desiredLabel(desc) {
|
|
if (desc.hasMatch) {
|
|
const { showVersionBeforeEdition } = getPreferences();
|
|
return sanitizeLabel(desc.editionTerm, desc.versionTerm, showVersionBeforeEdition) ?? desc.originalLabel;
|
|
}
|
|
return desc.originalLabel;
|
|
}
|
|
|
|
function rankOptions(options, prefs) {
|
|
const noneEditionIndex = indexOfNone(prefs.editions);
|
|
const noneVersionIndex = indexOfNone(prefs.versions);
|
|
|
|
const descriptors = Array.from(options).map((option, originalIndex) => {
|
|
const originalLabel = getOriginalLabel(option);
|
|
const editionMatch = findBestTerm(originalLabel, prefs.editions);
|
|
const versionMatch = findBestTerm(originalLabel, prefs.versions);
|
|
const hasMatch = !!(editionMatch || versionMatch);
|
|
return {
|
|
option,
|
|
originalIndex,
|
|
originalLabel,
|
|
hasMatch,
|
|
editionIndex: editionMatch ? editionMatch.index : noneEditionIndex,
|
|
versionIndex: versionMatch ? versionMatch.index : noneVersionIndex,
|
|
editionTerm: editionMatch?.term || null,
|
|
versionTerm: versionMatch?.term || null
|
|
};
|
|
});
|
|
|
|
descriptors.sort((a, b) => {
|
|
if (a.editionIndex !== b.editionIndex) return a.editionIndex - b.editionIndex;
|
|
if (a.versionIndex !== b.versionIndex) return a.versionIndex - b.versionIndex;
|
|
return a.originalIndex - b.originalIndex;
|
|
});
|
|
|
|
return descriptors;
|
|
}
|
|
|
|
function isSelectInExpectedState(select, ranked) {
|
|
const options = Array.from(select.options);
|
|
if (options.length !== ranked.length) return false;
|
|
for (let i = 0; i < ranked.length; i++) {
|
|
if (options[i] !== ranked[i].option) return false;
|
|
if (options[i].textContent !== desiredLabel(ranked[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function parseSelectedIndexAttr(select) {
|
|
if (!select?.hasAttribute(SELECTED_INDEX_ATTR)) return null;
|
|
const raw = select.getAttribute(SELECTED_INDEX_ATTR);
|
|
const idx = parseInt(raw, 10);
|
|
return Number.isFinite(idx) ? idx : null;
|
|
}
|
|
|
|
function bindSelectedIndexTracker(select) {
|
|
if (!select || select.hasAttribute(CHANGE_BOUND_ATTR)) return;
|
|
select.setAttribute(CHANGE_BOUND_ATTR, 'true');
|
|
select.addEventListener('change', () => {
|
|
select.setAttribute(SELECTED_INDEX_ATTR, String(select.selectedIndex));
|
|
});
|
|
}
|
|
|
|
/** True after Kefin has completed one full apply for this select / its track form. */
|
|
function selectAlreadyKefinModified(select) {
|
|
if (!select) return false;
|
|
if (select.hasAttribute(APPLIED_ATTR)) return true;
|
|
const form = select.closest?.(FORM_SELECTOR);
|
|
return !!form?.hasAttribute?.(APPLIED_ATTR);
|
|
}
|
|
|
|
function markApplied(select) {
|
|
if (!select) return;
|
|
select.setAttribute(APPLIED_ATTR, 'true');
|
|
const form = select.closest?.(FORM_SELECTOR);
|
|
if (form) form.setAttribute(APPLIED_ATTR, 'true');
|
|
}
|
|
|
|
/**
|
|
* After the first full apply: only refresh option labels when Jellyfin resets them.
|
|
* Never reorder options, never change selection, never dispatch change.
|
|
*/
|
|
function syncLabelsOnly(select) {
|
|
if (!select?.options?.length) return false;
|
|
|
|
const prefs = getPreferences();
|
|
let updated = 0;
|
|
|
|
isApplying = true;
|
|
try {
|
|
Array.from(select.options).forEach((option) => {
|
|
// Fresh/replaced options have no stamp — capture Jellyfin's native label first.
|
|
// Existing stamps keep the original native text even after we clean textContent.
|
|
if (!option.hasAttribute(ORIGINAL_ATTR)) {
|
|
option.setAttribute(ORIGINAL_ATTR, option.textContent || '');
|
|
}
|
|
const originalLabel = option.getAttribute(ORIGINAL_ATTR) || '';
|
|
const editionMatch = findBestTerm(originalLabel, prefs.editions);
|
|
const versionMatch = findBestTerm(originalLabel, prefs.versions);
|
|
const hasMatch = !!(editionMatch || versionMatch);
|
|
const desc = {
|
|
originalLabel,
|
|
hasMatch,
|
|
editionTerm: editionMatch?.term || null,
|
|
versionTerm: versionMatch?.term || null
|
|
};
|
|
const label = desiredLabel(desc);
|
|
if (option.textContent !== label) {
|
|
option.textContent = label;
|
|
updated += 1;
|
|
}
|
|
});
|
|
|
|
if (updated > 0) {
|
|
LOG('Synced version preference labels (selection unchanged)', {
|
|
updated,
|
|
selectedIndex: select.selectedIndex,
|
|
selected: select.value
|
|
});
|
|
}
|
|
return updated > 0;
|
|
} finally {
|
|
isApplying = false;
|
|
}
|
|
}
|
|
|
|
function applyToSelect(select) {
|
|
if (!select || !select.options?.length) return false;
|
|
|
|
// Subsequent passes: labels only — Jellyfin may reset native text after track changes.
|
|
if (selectAlreadyKefinModified(select)) {
|
|
return syncLabelsOnly(select);
|
|
}
|
|
|
|
bindSelectedIndexTracker(select);
|
|
|
|
const prefs = getPreferences();
|
|
const previousValue = select.value;
|
|
const savedIndex = parseSelectedIndexAttr(select);
|
|
const ranked = rankOptions(select.options, prefs);
|
|
|
|
if (isSelectInExpectedState(select, ranked)) {
|
|
markApplied(select);
|
|
return false;
|
|
}
|
|
|
|
isApplying = true;
|
|
try {
|
|
ranked.forEach((desc, i) => {
|
|
const label = desiredLabel(desc);
|
|
if (desc.option.textContent !== label) {
|
|
desc.option.textContent = label;
|
|
}
|
|
if (select.options[i] !== desc.option) {
|
|
select.appendChild(desc.option);
|
|
}
|
|
});
|
|
|
|
const maxIndex = select.options.length - 1;
|
|
let targetIndex = 0;
|
|
if (savedIndex != null && savedIndex >= 0 && savedIndex <= maxIndex) {
|
|
targetIndex = savedIndex;
|
|
}
|
|
|
|
const targetValue = select.options[targetIndex]?.value;
|
|
if (targetValue != null && targetValue !== '') {
|
|
if (select.selectedIndex !== targetIndex) {
|
|
select.selectedIndex = targetIndex;
|
|
}
|
|
if (select.value !== targetValue) {
|
|
select.value = targetValue;
|
|
}
|
|
if (targetValue !== previousValue) {
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
select.setAttribute(SELECTED_INDEX_ATTR, String(select.selectedIndex));
|
|
// Stamp before any Jellyfin rebuild so later passes stay label-only.
|
|
markApplied(select);
|
|
|
|
LOG('Applied version preferences to detailTrackSelect', {
|
|
count: ranked.length,
|
|
selectedIndex: select.selectedIndex,
|
|
selected: select.value,
|
|
restored: savedIndex != null
|
|
});
|
|
return true;
|
|
} finally {
|
|
isApplying = false;
|
|
}
|
|
}
|
|
|
|
function findSourceSelect() {
|
|
return document.querySelector(SELECTOR) || document.querySelector(SELECT_FALLBACK);
|
|
}
|
|
|
|
function findDetailsPage() {
|
|
return document.querySelector(PAGE_SELECTOR);
|
|
}
|
|
|
|
function findTrackSelectionsForm(page) {
|
|
const root = page || findDetailsPage();
|
|
return root?.querySelector?.(FORM_SELECTOR) || null;
|
|
}
|
|
|
|
let observer = null;
|
|
/** @type {Element|null} */
|
|
let observedRoot = null;
|
|
/** True while observing the details page until form.trackSelections mounts. */
|
|
let waitingForTrackForm = false;
|
|
let applyScheduled = false;
|
|
let isApplying = false;
|
|
|
|
function disconnectObserver() {
|
|
if (observer) {
|
|
observer.disconnect();
|
|
observer = null;
|
|
}
|
|
observedRoot = null;
|
|
waitingForTrackForm = false;
|
|
applyScheduled = false;
|
|
}
|
|
|
|
function tryApply() {
|
|
if (isApplying) return false;
|
|
const select = findSourceSelect();
|
|
if (!select || !select.options?.length) return false;
|
|
return applyToSelect(select);
|
|
}
|
|
|
|
function scheduleTryApply() {
|
|
if (isApplying || applyScheduled) return;
|
|
applyScheduled = true;
|
|
requestAnimationFrame(() => {
|
|
applyScheduled = false;
|
|
tryApply();
|
|
});
|
|
}
|
|
|
|
function isDetailsHash(hash) {
|
|
const h = hash || window.location.hash || '';
|
|
return h.includes('details.html') || h.includes('/details');
|
|
}
|
|
|
|
function isRelevantMutation(mutations) {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === 'characterData') {
|
|
const parent = mutation.target?.parentElement;
|
|
if (parent?.tagName === 'OPTION') return true;
|
|
if (parent?.closest?.('select.detailTrackSelect')) return true;
|
|
continue;
|
|
}
|
|
if (mutation.type !== 'childList') continue;
|
|
|
|
const target = mutation.target;
|
|
if (target?.matches?.('select.detailTrackSelect')) return true;
|
|
if (target?.tagName === 'OPTION' && !target.hasAttribute(ORIGINAL_ATTR)) return true;
|
|
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.nodeType !== 1) continue;
|
|
if (node.matches?.('select.detailTrackSelect')) return true;
|
|
if (node.matches?.('option') && !node.hasAttribute(ORIGINAL_ATTR)) return true;
|
|
if (node.matches?.(FORM_SELECTOR)) return true;
|
|
if (node.querySelector?.('select.detailTrackSelect, option, form.trackSelections')) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function mutationIntroducesTrackForm(mutations) {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type !== 'childList') continue;
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.nodeType !== 1) continue;
|
|
if (node.matches?.(FORM_SELECTOR)) return true;
|
|
if (node.querySelector?.(FORM_SELECTOR)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Observe a narrow root (prefer form.trackSelections; fall back to visible details page).
|
|
* If observing the page and the form appears, promote to the form.
|
|
*/
|
|
function observeRoot(root, pendingForm) {
|
|
if (!root) return;
|
|
|
|
disconnectObserver();
|
|
|
|
waitingForTrackForm = !!pendingForm;
|
|
observedRoot = root;
|
|
|
|
observer = new MutationObserver((mutations) => {
|
|
if (isApplying) return;
|
|
|
|
if (waitingForTrackForm) {
|
|
const form =
|
|
(mutationIntroducesTrackForm(mutations) && findTrackSelectionsForm(observedRoot)) ||
|
|
findTrackSelectionsForm(observedRoot);
|
|
if (form) {
|
|
observeRoot(form, false);
|
|
tryApply();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!isRelevantMutation(mutations)) return;
|
|
scheduleTryApply();
|
|
});
|
|
|
|
observer.observe(root, { childList: true, subtree: true, characterData: true });
|
|
}
|
|
|
|
function attachDetailsObserver() {
|
|
disconnectObserver();
|
|
|
|
const page = findDetailsPage();
|
|
if (!page) return;
|
|
|
|
const form = findTrackSelectionsForm(page);
|
|
if (form) {
|
|
observeRoot(form, false);
|
|
} else {
|
|
observeRoot(page, true);
|
|
}
|
|
tryApply();
|
|
}
|
|
|
|
function onDetailsPage() {
|
|
attachDetailsObserver();
|
|
}
|
|
|
|
function onAnyPage(view, _doc, hash) {
|
|
if (isDetailsHash(hash) || (typeof view === 'string' && /details/i.test(view))) {
|
|
return;
|
|
}
|
|
disconnectObserver();
|
|
}
|
|
|
|
function init() {
|
|
if (!window.KefinTweaksUtils?.onViewPage) {
|
|
WARN('KefinTweaksUtils.onViewPage unavailable');
|
|
return;
|
|
}
|
|
|
|
window.KefinTweaksUtils.onViewPage(() => {
|
|
try {
|
|
onDetailsPage();
|
|
} catch (err) {
|
|
WARN('Failed to apply version preferences', err);
|
|
}
|
|
}, { pages: ['details'] });
|
|
|
|
window.KefinTweaksUtils.onViewPage((view, doc, hash) => {
|
|
try {
|
|
onAnyPage(view, doc, hash);
|
|
} catch (err) {
|
|
WARN('Failed to clean up version preferences observer', err);
|
|
}
|
|
});
|
|
|
|
if (isDetailsHash()) {
|
|
attachDetailsObserver();
|
|
}
|
|
|
|
LOG('Initialized');
|
|
}
|
|
|
|
window.KefinVersionPreferences = {
|
|
NONE_SENTINEL,
|
|
getDefaults,
|
|
getPreferences,
|
|
ensureNoneSentinel,
|
|
isNoneSentinel,
|
|
findBestTerm,
|
|
isSelectInExpectedState,
|
|
applyToSelect
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|