Files
KefinTweaks/scripts/versionPreferences.js

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();
}
})();