Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html
T
ajp_anton 096c6be8c7 Release 0.3.4: Jellyfin 12 stable support and audit fixes
Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
2026-09-09 01:18:43 +00:00

1272 lines
55 KiB
HTML

<div id="ml-user" data-role="page" class="page type-interior pluginConfigurationPage">
<div class="content-primary ml-root">
<link rel="stylesheet" href="/Multilang/shared.css">
<h2>Metadata Language Rules</h2>
<form id="ml-user-form">
<fieldset class="ml-section">
<legend>Preferences</legend>
<div class="inputContainer ml-enable-row">
<label><input id="ml-enabled" type="checkbox"> Enable Multilang for this user</label>
</div>
<div class="inputContainer ml-sort-row">
<label class="inputLabel inputLabelUnfocused" for="ml-sort-locale">Sort locale:</label>
<select id="ml-sort-locale" class="emby-select ml-select"></select>
<div id="ml-sort-locale-auto" class="fieldDescription ml-muted"></div>
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Classification into categories</legend>
<div id="ml-categories" class="ml-category-list"></div>
<button id="ml-add-category" is="emby-button" type="button" class="raised"><span>Add classification rule</span></button>
</fieldset>
<fieldset class="ml-section">
<legend>Translation of categories</legend>
<div class="inputContainer ml-translation-option">
<label><input id="ml-trust-collections" type="checkbox"> Trust TMDb collections metadata</label>
</div>
<div id="ml-action-source" class="ml-action-source"></div>
<div id="ml-action-matrix" class="ml-action-matrix"></div>
</fieldset>
<fieldset class="ml-section">
<legend>Import and export</legend>
<div class="ml-actions">
<button id="ml-user-export" is="emby-button" type="button" class="raised"><span>Export my settings</span></button>
<button id="ml-user-import-open" is="emby-button" type="button" class="raised"><span>Import settings...</span></button>
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Debug</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-debug-item-id">Jellyfin item ID</label>
<input id="ml-debug-item-id" type="text" class="emby-input ml-input" placeholder="32-character item ID">
</div>
<div class="ml-actions">
<button id="ml-debug-open" is="emby-button" type="button" class="raised"><span>Open debug view</span></button>
</div>
<div class="fieldDescription">Shows how your classification and translation rules resolve for this item.</div>
</fieldset>
<button id="ml-save-user" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form>
<div id="ml-user-status" class="ml-status"></div>
<div id="ml-user-import-modal" class="ml-modal" hidden>
<div class="ml-modal-panel">
<h2>Import user settings</h2>
<div class="inputContainer">
<input id="ml-user-import-file" type="file" accept=".zip,application/zip">
</div>
<div id="ml-user-import-choice" class="inputContainer" hidden>
<label class="inputLabel inputLabelUnfocused" for="ml-user-import-source">Import settings from user ID</label>
<select id="ml-user-import-source" class="emby-select ml-select"></select>
</div>
<div class="ml-actions">
<button id="ml-user-import-run" is="emby-button" type="button" class="raised button-submit"><span>Import</span></button>
<button id="ml-user-import-cancel" is="emby-button" type="button" class="raised"><span>Cancel</span></button>
</div>
</div>
</div>
<script src="/Multilang/shared.js"></script>
<script>
(function () {
var M = window.Multilang;
var page = document.getElementById("ml-user");
var form = document.getElementById("ml-user-form");
var status = document.getElementById("ml-user-status");
var state = { allowed: [], rules: null };
var fields = [
{ id: "title", label: "Title", lockedJ: true },
{ id: "overview", label: "Overview" },
{ id: "tagline", label: "Tagline" },
{ id: "poster", label: "Poster", lockedJ: true },
{ id: "logo", label: "Logo" },
{ id: "banner", label: "Banner" },
{ id: "thumb", label: "Thumb" },
{ id: "backdrop", label: "Backdrop" }
];
var criteriaFields = [
{ id: "original_language", label: "Original language" },
{ id: "spoken_languages", label: "Spoken languages" },
{ id: "audio_language", label: "Audio language" },
{ id: "origin_countries", label: "Origin countries" },
{ id: "production_countries", label: "Production countries" }
];
var relations = [
{ id: "is", label: "is", pluralLabel: "are" },
{ id: "is_not", label: "is not", pluralLabel: "are not" },
{ id: "contains", label: "contains", pluralLabel: "contain" },
{ id: "not_contains", label: "does not contain", pluralLabel: "do not contain" }
];
var ISO6391 = M.staticData.iso6391;
var ISO3166 = M.staticData.iso3166;
var LANG_REGION_COMBOS = M.staticData.langRegionCombos;
var listeners = [];
function listen(target, type, handler, capture) {
target.addEventListener(type, handler, capture);
listeners.push(() => target.removeEventListener(type, handler, capture));
}
page._mlDispose = function () {
listeners.forEach(remove => remove());
listeners = [];
cleanupActionDrag();
};
function $(id) {
return page.querySelector("#" + id);
}
function make(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function option(label, value, selected) {
var opt = document.createElement("option");
opt.textContent = label;
opt.value = value;
opt.selected = !!selected;
return opt;
}
function randomId() {
return "cat-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
function defaultActionLists() {
var result = {};
fields.forEach(function (field) { result[field.id] = ["Jellyfin"]; });
return result;
}
function categories() {
state.rules.Categories = state.rules.Categories || [];
return state.rules.Categories;
}
function categoryId(category) {
return category.Id;
}
function categoryLabel(category, index) {
var label = String(category.Label || "").trim();
return label || categoryPlaceholder(index);
}
function categoryPlaceholder(index) {
var used = {};
categories().forEach(function (category) {
var label = String(category.Label || "").trim().toLowerCase();
if (label) used[label] = true;
});
var n = Math.max(1, index + 1);
while (used[("Category " + n).toLowerCase()]) n++;
return "Category " + n;
}
function newCategory(label) {
return {
Id: randomId(),
Label: label || "",
Requirements: [emptyRequirement()],
MatchAllConditions: true,
Scopes: ["M", "S", "C"],
FieldActionLists: defaultActionLists()
};
}
function emptyRequirement() {
return { Fields: [], UseFieldOr: false, Relation: "", UseOr: false, Values: [] };
}
function normalizeActionValue(fieldId, value) {
value = String(value || "").trim();
if (!value) return "";
if (value.toLowerCase() === "j" || value.toLowerCase() === "jellyfin") return "Jellyfin";
if (value.toLowerCase() === "o" || value.toLowerCase() === "original") return "Original";
var lang = value.toLowerCase().indexOf("language:") === 0 ? value.slice(9) : value;
var canonical = state.allowed.find(function (allowed) { return allowed.toLowerCase() === lang.toLowerCase(); });
return canonical ? "Language:" + canonical : "";
}
function normalizeActionList(fieldId, values) {
var field = fields.find(function (f) { return f.id === fieldId; });
var result = [];
(values || []).forEach(function (value) {
var normalized = normalizeActionValue(fieldId, value);
if (!normalized) return;
if (result.some(function (existing) { return existing.toLowerCase() === normalized.toLowerCase(); })) return;
result.push(normalized);
});
if (field && field.lockedJ && !result.some(function (value) { return value.toLowerCase() === "jellyfin"; })) {
result.push("Jellyfin");
}
return result.length || !(field && field.lockedJ) ? result : ["Jellyfin"];
}
function normalizeActionMap(source) {
var result = {};
fields.forEach(function (field) {
result[field.id] = normalizeActionList(field.id, source[field.id]);
});
return result;
}
function normalizeRules() {
state.rules.Categories.forEach(function (category) {
category.Requirements = normalizeRequirements(category.Requirements);
category.FieldActionLists = normalizeActionMap(category.FieldActionLists);
});
state.rules.FallbackFieldActions = normalizeActionMap(state.rules.FallbackFieldActions);
}
function normalizeRequirements(requirements) {
var normalized = (requirements || []).map(function (requirement) {
var fields = normalizeRequirementFields(requirement.Fields);
return {
Fields: fields,
UseFieldOr: requirement.UseFieldOr === true,
Relation: String(requirement.Relation || "").trim(),
UseOr: requirement.UseOr === true,
Values: orderedRequirementValues(fields[0] || "", requirement.Values || [])
};
});
return normalized.length ? normalized : [emptyRequirement()];
}
function completeRequirement(requirement) {
return !!(requirement && requirementFields(requirement).length && requirement.Relation && requirement.Values && requirement.Values.length);
}
function unique(values) {
var seen = {};
var out = [];
values.forEach(function (value) {
var key = String(value).toLowerCase();
if (seen[key]) return;
seen[key] = true;
out.push(value);
});
return out;
}
function criteriaField(id) {
return criteriaFields.find(function (item) { return item.id === id; });
}
function normalizeRequirementFields(values) {
var raw = [];
(values || []).forEach(function (value) {
value = String(value || "").trim();
if (value) raw.push(value);
});
var wanted = {};
raw.forEach(function (value) {
var id = value.toLowerCase();
if (criteriaField(id)) wanted[id] = true;
});
var result = [];
var kind = "";
criteriaFields.forEach(function (item) {
if (!wanted[item.id]) return;
var itemKind = fieldKind(item.id);
if (!kind) kind = itemKind;
if (itemKind === kind) result.push(item.id);
});
return result;
}
function requirementFields(requirement) {
return normalizeRequirementFields(requirement.Fields);
}
function criteriaLabel(id) {
var item = criteriaField(id);
return item ? item.label : id;
}
function renderSortLocales() {
var select = $("ml-sort-locale");
M.replaceChildren(select, option("Auto", "Auto", false));
state.allowed.forEach(function (lang) {
select.appendChild(option(lang, lang, false));
});
select.value = state.rules.SortLocale || "Auto";
renderSortLocaleAutoText();
}
function detectedLocale() {
return (navigator.languages && navigator.languages[0]) || navigator.language || "";
}
function renderSortLocaleAutoText() {
var el = $("ml-sort-locale-auto");
if (!el) return;
var selected = $("ml-sort-locale").value || "Auto";
var detected = detectedLocale();
el.textContent = selected === "Auto"
? (detected ? "Auto detected: " + detected : "Auto detected: none")
: "";
}
function listButton(label, action) {
var button = make("button", "raised ml-list-action");
button.type = "button";
button.setAttribute("is", "emby-button");
button.appendChild(make("span", "", label));
button.addEventListener("click", action);
return button;
}
function syncCategoryFromRow(row, category) {
category.Label = row.querySelector("[data-category-label]").value;
category.MatchAllConditions = row.querySelector("[data-category-match-all]").checked;
category.Scopes = Array.prototype.slice.call(row.querySelectorAll("[data-category-scope]:checked"))
.map(function (input) { return input.value; });
}
function syncCategoriesFromDom() {
Array.prototype.slice.call(document.querySelectorAll(".ml-category-card[data-category-id]")).forEach(function (row) {
var category = categories().find(function (item) { return categoryId(item) === row.dataset.categoryId; });
if (category) syncCategoryFromRow(row, category);
});
}
function addSelectOptions(select, items, selectedValue) {
M.replaceChildren(select);
items.forEach(function (item) {
select.appendChild(option(item.label, item.id, item.id === selectedValue));
});
}
function fieldKind(fieldId) {
return fieldId && fieldId.indexOf("countries") >= 0 ? "country" : "language";
}
function baseLang(lang) {
return String(lang || "").split("-")[0].toLowerCase();
}
function regionFromLang(lang) {
var parts = String(lang || "").split("-");
return parts.length > 1 ? parts[1].toUpperCase() : "";
}
function likelyValues(kind) {
if (kind === "country") {
return unique(state.allowed.map(regionFromLang).filter(Boolean)).sort(function (a, b) { return a.localeCompare(b); });
}
return unique(state.allowed.concat(state.allowed.map(baseLang)).filter(Boolean)).sort(function (a, b) { return a.localeCompare(b); });
}
function allValues(kind) {
return (kind === "country"
? ISO3166.slice()
: unique(ISO6391.concat(LANG_REGION_COMBOS))).sort(function (a, b) { return a.localeCompare(b); });
}
function valueOrder(kind) {
var likely = likelyValues(kind);
return likely.concat(allValues(kind).filter(function (value) {
return !likely.some(function (top) { return top.toLowerCase() === value.toLowerCase(); });
}));
}
function orderValues(values, ordered) {
var selected = {};
unique((values || []).map(function (value) { return String(value || "").trim(); }).filter(Boolean)).forEach(function (value) {
selected[value.toLowerCase()] = value;
});
var result = [];
ordered.forEach(function (value) {
var key = value.toLowerCase();
if (!selected[key]) return;
result.push(selected[key]);
delete selected[key];
});
Object.keys(selected).sort().forEach(function (key) { result.push(selected[key]); });
return result;
}
function orderedRequirementValues(fieldId, values) {
if (!fieldId) return unique((values || []).map(function (value) { return String(value || "").trim(); }).filter(Boolean));
return orderValues(values, valueOrder(fieldKind(fieldId)));
}
function fieldSummary(requirement) {
var selected = requirementFields(requirement);
if (!selected.length) return "Choose item property";
var joiner = requirement.UseFieldOr ? " or " : " and ";
if (selected.length === 1) return criteriaLabel(selected[0]);
var suffix = fieldKind(selected[0]) === "country" ? " countries" : " languages";
return selected.map(shortCriteriaLabel).join(joiner) + suffix;
}
function shortCriteriaLabel(id) {
return {
original_language: "Orig",
spoken_languages: "Spok",
audio_language: "Aud",
origin_countries: "Orig",
production_countries: "Prod"
}[id] || criteriaLabel(id);
}
function valueSummary(requirement) {
var values = orderedRequirementValues((requirementFields(requirement)[0] || ""), requirement.Values || []);
if (!values.length) return "Choose values";
var joiner = requirement.UseOr ? " or " : " and ";
return values.join(joiner);
}
function makeRequirementRow(category, requirement, index) {
var row = make("div", "ml-requirement-row");
var selectedFields = requirementFields(requirement);
var value = null;
var field = makeFieldDropdown(requirement, function (previousKind, nextKind) {
selectedFields = requirementFields(requirement);
relation.disabled = !selectedFields.length;
renderRelationOptions(relation, requirement);
if (!selectedFields.length || (previousKind && nextKind && previousKind !== nextKind)) {
requirement.Relation = "";
requirement.Values = [];
requirement.UseOr = false;
}
var nextValue = makeValueDropdown(requirement, !!(selectedFields.length && requirement.Relation));
value.replaceWith(nextValue);
value = nextValue;
});
var relation = make("select", "emby-select ml-select ml-relation-select");
relation.disabled = !selectedFields.length;
renderRelationOptions(relation, requirement);
value = makeValueDropdown(requirement, !!(selectedFields.length && requirement.Relation));
var del = listButton("X", function () {
if ((category.Requirements || []).length <= 1) return;
category.Requirements.splice(index, 1);
renderAll();
});
del.classList.add("ml-danger");
del.title = "Delete condition";
del.disabled = (category.Requirements || []).length <= 1;
relation.addEventListener("change", function () {
requirement.Relation = relation.value;
renderAll();
});
row.append(field, relation, value, del);
return row;
}
function renderRelationOptions(select, requirement) {
var current = requirement.Relation || "";
var fields = requirementFields(requirement);
var multiple = fields.length > 1;
M.replaceChildren(select, option("Choose relation", "", !current));
if (fields.length) {
relations.forEach(function (item) {
select.appendChild(option(multiple ? item.pluralLabel : item.label, item.id, item.id === current));
});
}
select.value = current;
}
function makeFieldDropdown(requirement, onChange) {
var root = make("div", "ml-value-dropdown ml-field-dropdown");
var button = make("button", "raised ml-value-button", fieldSummary(requirement));
button.type = "button";
var panel = make("div", "ml-value-panel");
panel.hidden = true;
var orLabel = make("label", "ml-value-option");
var orBox = make("input", "");
orBox.type = "checkbox";
orBox.checked = requirement.UseFieldOr === true;
orBox.addEventListener("change", function () {
requirement.UseFieldOr = orBox.checked;
button.textContent = fieldSummary(requirement);
});
orLabel.append(orBox, make("span", "", "Use \"or\""));
panel.appendChild(orLabel);
panel.appendChild(make("div", "ml-value-separator"));
var selected = requirementFields(requirement);
var selectedKind = selected.length ? fieldKind(selected[0]) : "";
criteriaFields.forEach(function (item) {
var label = make("label", "ml-value-option");
var input = make("input", "");
var checked = selected.indexOf(item.id) >= 0;
input.type = "checkbox";
input.value = item.id;
input.checked = checked;
input.disabled = !!(selectedKind && fieldKind(item.id) !== selectedKind && !checked);
input.addEventListener("change", function () {
var currentFields = requirementFields(requirement);
var previousKind = currentFields.length ? fieldKind(currentFields[0]) : "";
var fields = requirementFields(requirement);
if (input.checked) fields.push(item.id);
else fields = fields.filter(function (value) { return value !== item.id; });
fields = normalizeRequirementFields(fields, "");
var nextKind = fields.length ? fieldKind(fields[0]) : "";
requirement.Fields = fields;
button.textContent = fieldSummary(requirement);
refreshFieldPanelOptions(panel, requirement);
if (onChange) onChange(previousKind, nextKind);
});
label.append(input, make("span", "", item.label));
panel.appendChild(label);
});
button.addEventListener("click", function (event) {
event.stopPropagation();
closeValuePanels(panel);
panel.hidden = !panel.hidden;
});
panel.addEventListener("click", function (event) { event.stopPropagation(); });
root.append(button, panel);
return root;
}
function refreshFieldPanelOptions(panel, requirement) {
var selected = requirementFields(requirement);
var selectedKind = selected.length ? fieldKind(selected[0]) : "";
Array.prototype.slice.call(panel.querySelectorAll("input[value]")).forEach(function (input) {
if (!criteriaField(input.value)) return;
var checked = selected.indexOf(input.value) >= 0;
input.checked = checked;
input.disabled = !!(selectedKind && fieldKind(input.value) !== selectedKind && !checked);
});
}
function makeValueDropdown(requirement, enabled) {
var root = make("div", "ml-value-dropdown");
var button = make("button", "raised ml-value-button", valueSummary(requirement));
button.type = "button";
button.disabled = !enabled;
var panel = make("div", "ml-value-panel");
panel.hidden = true;
if (enabled) {
var orLabel = make("label", "ml-value-option");
var orBox = make("input", "");
orBox.type = "checkbox";
orBox.checked = requirement.UseOr === true;
orBox.addEventListener("change", function () {
requirement.UseOr = orBox.checked;
button.textContent = valueSummary(requirement);
});
orLabel.append(orBox, make("span", "", "use \"or\""));
panel.appendChild(orLabel);
panel.appendChild(make("div", "ml-value-separator"));
var likely = likelyValues(fieldKind(requirementFields(requirement)[0]));
appendValueOptions(panel, requirement, button, likely);
panel.appendChild(make("div", "ml-value-separator"));
appendValueOptions(panel, requirement, button, allValues(fieldKind(requirementFields(requirement)[0])).filter(function (value) {
return !likely.some(function (top) { return top.toLowerCase() === value.toLowerCase(); });
}));
}
button.addEventListener("click", function (event) {
if (button.disabled) return;
event.stopPropagation();
closeValuePanels(panel);
panel.hidden = !panel.hidden;
});
panel.addEventListener("click", function (event) { event.stopPropagation(); });
root.append(button, panel);
return root;
}
function closeValuePanels(except) {
Array.prototype.slice.call(document.querySelectorAll(".ml-value-panel")).forEach(function (panel) {
if (panel !== except) panel.hidden = true;
});
}
function appendValueOptions(panel, requirement, button, values) {
values.forEach(function (item) {
var label = make("label", "ml-value-option");
var input = make("input", "");
input.type = "checkbox";
input.value = item;
input.checked = (requirement.Values || []).some(function (value) { return value.toLowerCase() === item.toLowerCase(); });
input.addEventListener("change", function () {
var values = requirement.Values || [];
if (input.checked) values.push(item);
else values = values.filter(function (value) { return value.toLowerCase() !== item.toLowerCase(); });
requirement.Values = orderedRequirementValues(requirementFields(requirement)[0] || "", values);
button.textContent = valueSummary(requirement);
});
label.append(input, make("span", "", item));
panel.appendChild(label);
});
}
function categoryScope(label, value, category) {
var wrapper = make("label", "ml-inline-check");
var input = make("input", "");
input.type = "checkbox";
input.value = value;
input.dataset.categoryScope = "1";
input.checked = (category.Scopes || []).indexOf(value) >= 0;
wrapper.append(input, make("span", "", label));
return wrapper;
}
function cloneRequirements(requirements) {
return normalizeRequirements(requirements).map(function (requirement) {
return {
Fields: (requirement.Fields || []).slice(),
UseFieldOr: requirement.UseFieldOr === true,
Relation: requirement.Relation,
UseOr: requirement.UseOr === true,
Values: (requirement.Values || []).slice()
};
});
}
function copyCategory(category) {
return {
Id: randomId(),
Label: "",
Requirements: cloneRequirements(category.Requirements),
MatchAllConditions: category.MatchAllConditions !== false,
Scopes: (category.Scopes || ["M", "S", "C"]).slice(),
FieldActionLists: defaultActionLists()
};
}
function renderCategories() {
var root = $("ml-categories");
M.replaceChildren(root);
categories().forEach(function (category, index) {
var row = make("div", "ml-classification-rule");
row.dataset.categoryId = categoryId(category);
var main = make("div", "ml-category-card ml-classification-main");
var top = make("div", "ml-category-top");
var label = make("input", "emby-input ml-input");
label.type = "text";
label.placeholder = categoryPlaceholder(index);
label.value = String(category.Label || "").trim();
label.dataset.categoryLabel = "1";
var controls = make("div", "ml-classification-controls");
controls.appendChild(listButton("Up", function () {
syncCategoriesFromDom();
if (index <= 0) return;
var cats = categories();
var temp = cats[index - 1];
cats[index - 1] = cats[index];
cats[index] = temp;
renderAll();
}));
var deleteCategory = listButton("Delete", function () {
syncCategoriesFromDom();
categories().splice(index, 1);
renderAll();
});
deleteCategory.classList.add("ml-danger");
controls.appendChild(deleteCategory);
controls.appendChild(listButton("Down", function () {
syncCategoriesFromDom();
var cats = categories();
if (index >= cats.length - 1) return;
var temp = cats[index + 1];
cats[index + 1] = cats[index];
cats[index] = temp;
renderAll();
}));
var scopes = make("div", "ml-category-scopes");
scopes.append(categoryScope("Movies", "M", category), categoryScope("Shows", "S", category), categoryScope("Collections", "C", category));
top.append(label, scopes);
var requirements = make("div", "ml-requirements");
category.Requirements = normalizeRequirements(category.Requirements);
category.Requirements.forEach(function (requirement, requirementIndex) {
requirements.appendChild(makeRequirementRow(category, requirement, requirementIndex));
});
var addRequirement = listButton("Add condition", function () {
syncCategoryFromRow(row, category);
category.Requirements.push(emptyRequirement());
renderAll();
});
var conditionActions = make("div", "ml-category-condition-actions");
var leftConditionActions = make("div", "ml-category-condition-left");
var matchAll = make("label", "ml-inline-check ml-match-all-check");
var matchAllInput = make("input", "");
matchAllInput.type = "checkbox";
matchAllInput.dataset.categoryMatchAll = "1";
matchAllInput.checked = category.MatchAllConditions !== false;
matchAll.append(matchAllInput, make("span", "", "Match all conditions"));
leftConditionActions.append(addRequirement, matchAll);
var copyRule = listButton("Copy rule", function () {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().splice(index + 1, 0, copyCategory(category));
renderAll();
});
conditionActions.append(leftConditionActions, copyRule);
main.append(top, requirements, conditionActions);
row.append(main, controls);
row.addEventListener("input", function () {
syncCategoryFromRow(row, category);
renderActionMatrix();
});
row.addEventListener("change", function () {
syncCategoryFromRow(row, category);
renderActionMatrix();
});
root.appendChild(row);
});
}
function actionSourceValues() {
return ["Jellyfin", "Original"].concat(state.allowed.map(function (lang) { return "Language:" + lang; }));
}
function tokenLabel(value, source) {
if (source && value === "Jellyfin") return "Jellyfin default";
if (source && value === "Original") return "Original language";
if (value === "__all__") return "X";
if (value === "Jellyfin") return "J";
if (value === "Original") return "O";
if (value.indexOf("Language:") === 0) return value.slice(9);
return value;
}
function makeActionToken(value, locked, source) {
var token = make("span", "ml-action-token", tokenLabel(value, source));
token.title = value;
token.draggable = false;
token.dataset.value = value;
if (source) token.dataset.actionSource = "1";
if (locked) token.dataset.locked = "1";
if (source) {
token.classList.add("ml-action-token-source");
} else if (locked) {
token.classList.add("ml-action-token-locked");
}
return token;
}
function renderActionSource() {
var root = $("ml-action-source");
M.replaceChildren(root);
actionSourceValues().forEach(function (value) {
root.appendChild(makeActionToken(value, false, true));
});
}
function actionRows() {
var seen = {};
var rows = [];
categories().forEach(function (category, index) {
var label = categoryLabel(category, index);
if (seen[label]) return;
seen[label] = true;
rows.push({ id: categoryId(category), label: label, actions: category.FieldActionLists, fallback: false });
});
rows.push({ id: "__fallback__", label: "Fallback", actions: state.rules.FallbackFieldActions, fallback: true });
return rows;
}
function renderActionMatrix() {
renderActionSource();
var root = $("ml-action-matrix");
M.replaceChildren(root);
actionRows().forEach(function (row) {
var line = make("div", "ml-action-category-row");
if (row.fallback) line.classList.add("ml-action-category-row-fallback");
var nameCell = make("div", "ml-action-category-name");
nameCell.appendChild(make("div", "", row.label));
var allLabel = make("label", "ml-action-field ml-action-all-field");
allLabel.appendChild(make("span", "ml-action-field-label", "All"));
var allBox = make("div", "ml-action-box ml-action-all-box");
allBox.dataset.actionBox = row.id + "|__all__";
allBox.dataset.categoryId = row.id;
allBox.dataset.fieldId = "__all__";
allBox.appendChild(makeActionToken("__all__", false, false));
allLabel.appendChild(allBox);
nameCell.appendChild(allLabel);
line.appendChild(nameCell);
var fieldsRoot = make("div", "ml-action-fields");
fields.forEach(function (field) {
var group = make("label", "ml-action-field");
group.appendChild(make("span", "ml-action-field-label", field.label));
var box = make("div", "ml-action-box");
box.dataset.actionBox = row.id + "|" + field.id;
box.dataset.categoryId = row.id;
box.dataset.fieldId = field.id;
normalizeActionList(field.id, row.actions[field.id]).forEach(function (value) {
box.appendChild(makeActionToken(value, field.lockedJ && value === "Jellyfin", false));
});
group.appendChild(box);
fieldsRoot.appendChild(group);
});
line.appendChild(fieldsRoot);
root.appendChild(line);
});
}
function dropIndex(box, clientX, draggedValue) {
var tokens = Array.prototype.slice.call(box.querySelectorAll(".ml-action-token"));
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (token === actionPlaceholder) continue;
if (token.dataset.value === draggedValue) continue;
var rect = token.getBoundingClientRect();
if (clientX < rect.left + rect.width / 2) return i;
}
return tokens.length;
}
var actionDrag = null;
var actionGhost = null;
var actionPlaceholder = make("span", "ml-action-token ml-action-placeholder", "");
function actionBoxFromPoint(x, y) {
var el = document.elementFromPoint(x, y);
return el && el.closest ? el.closest(".ml-action-box") : null;
}
function hasActionToken(box, value, except) {
return Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).some(function (token) {
return token !== except && token !== actionPlaceholder && token.dataset.value && token.dataset.value.toLowerCase() === value.toLowerCase();
});
}
function actionFieldLocksJ(fieldId) {
var field = fields.find(function (f) { return f.id === fieldId; });
return !!(field && field.lockedJ);
}
function cleanupActionDrag() {
actionPlaceholder.remove();
if (actionGhost) actionGhost.remove();
actionGhost = null;
actionDrag = null;
Array.prototype.slice.call(document.querySelectorAll(".ml-action-box.ml-over")).forEach(function (box) {
box.classList.remove("ml-over");
});
}
function restoreActionDrag() {
if (!actionDrag || !actionDrag.sourceBox || !actionDrag.sourceToken) return;
var ref = Array.prototype.slice.call(actionDrag.sourceBox.children)[actionDrag.sourceIndex] || null;
actionDrag.sourceBox.insertBefore(actionDrag.sourceToken, ref);
}
function actionTargetTokenFromPoint(box, x, y) {
var els = document.elementsFromPoint(x, y);
for (var i = 0; i < els.length; i++) {
var token = els[i] && els[i].closest ? els[i].closest(".ml-action-token") : null;
if (!token) continue;
if (token === actionPlaceholder) continue;
if (token === actionDrag.sourceToken) continue;
if (token.dataset.actionSource === "1") continue;
if (token.parentElement !== box) continue;
return token;
}
return null;
}
function positionActionPlaceholder(box, x, y) {
if (!actionDrag) return;
if (box.dataset.fieldId === "__all__") {
if (actionDrag.value === "__all__") {
actionPlaceholder.remove();
return;
}
actionPlaceholder.textContent = tokenLabel(actionDrag.value, false);
actionPlaceholder.dataset.value = actionDrag.value;
var xToken = Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).find(function (token) {
return token.dataset.value === "__all__";
});
if (!xToken) {
box.appendChild(actionPlaceholder);
return;
}
var xRect = xToken.getBoundingClientRect();
box.insertBefore(actionPlaceholder, x < xRect.left + xRect.width / 2 ? xToken : xToken.nextSibling);
return;
}
var value = normalizeActionValue(box.dataset.fieldId, actionDrag.value);
if (!value) {
actionPlaceholder.remove();
return;
}
if (actionDrag.locked && actionDrag.sourceBox !== box) {
actionPlaceholder.remove();
return;
}
if (hasActionToken(box, value, actionDrag.sourceToken)) {
actionPlaceholder.remove();
return;
}
actionPlaceholder.textContent = tokenLabel(value, false);
actionPlaceholder.dataset.value = value;
var target = actionTargetTokenFromPoint(box, x, y);
if (!target) {
if (actionPlaceholder.parentElement !== box) box.appendChild(actionPlaceholder);
return;
}
var rect = target.getBoundingClientRect();
var before = x < rect.left + rect.width / 2;
box.insertBefore(actionPlaceholder, before ? target : target.nextSibling);
}
function beginActionDrag(event) {
var token = event.target.closest && event.target.closest(".ml-action-token");
if (!token || token === actionPlaceholder || !token.dataset.value) return;
var sourceBox = token.closest("[data-action-box]");
var fromSource = token.dataset.actionSource === "1";
if (!sourceBox && !fromSource) return;
event.preventDefault();
event.stopPropagation();
var rect = token.getBoundingClientRect();
actionDrag = {
pointerId: event.pointerId,
value: token.dataset.value,
locked: token.dataset.locked === "1",
sourceToken: fromSource ? null : token,
sourceBox: sourceBox,
sourceIndex: sourceBox ? Array.prototype.indexOf.call(sourceBox.children, token) : -1,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top
};
if (sourceBox) {
sourceBox.insertBefore(actionPlaceholder, token);
token.remove();
}
actionGhost = token.cloneNode(true);
actionGhost.classList.add("ml-action-drag-ghost");
actionGhost.style.position = "fixed";
actionGhost.style.margin = "0";
actionGhost.style.zIndex = "99999";
document.body.appendChild(actionGhost);
moveActionDrag(event);
}
function moveActionDrag(event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
if (actionGhost) {
actionGhost.style.left = event.clientX - actionDrag.offsetX + "px";
actionGhost.style.top = event.clientY - actionDrag.offsetY + "px";
}
var box = actionBoxFromPoint(event.clientX, event.clientY);
Array.prototype.slice.call(document.querySelectorAll(".ml-action-box.ml-over")).forEach(function (el) {
el.classList.toggle("ml-over", el === box);
});
if (box) positionActionPlaceholder(box, event.clientX, event.clientY);
else actionPlaceholder.remove();
}
function endActionDrag(event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
var box = actionBoxFromPoint(event.clientX, event.clientY);
if (actionDrag.value === "__all__") {
if (!box || box !== actionDrag.sourceBox) clearCategoryActions(actionDrag.sourceBox.dataset.categoryId);
restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
renderActionMatrix();
return;
}
if (box && box.dataset.fieldId === "__all__" && actionPlaceholder.parentElement === box) {
applyAllAction(box, actionDrag.value, actionPlaceholder.nextElementSibling && actionPlaceholder.nextElementSibling.dataset.value === "__all__");
if (actionDrag.sourceToken) restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
renderActionMatrix();
return;
}
if (box && actionPlaceholder.parentElement === box) {
var value = normalizeActionValue(box.dataset.fieldId, actionDrag.value);
var locked = actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin";
var token = actionDrag.sourceToken || makeActionToken(value, locked, false);
box.insertBefore(token, actionPlaceholder);
cleanupActionDrag();
updateActionStateFromBoxes();
return;
}
if (box && actionDrag.sourceToken) restoreActionDrag();
else if (actionDrag.sourceToken && actionDrag.locked) restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
}
function readBox(box) {
if (box.dataset.fieldId === "__all__") return [];
return normalizeActionList(box.dataset.fieldId, Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).map(function (token) {
return token.dataset.value;
}));
}
function boxesForCategory(categoryId) {
return Array.prototype.slice.call(document.querySelectorAll('[data-action-box]')).filter(function (box) {
return box.dataset.categoryId === categoryId && box.dataset.fieldId !== "__all__";
});
}
function clearCategoryActions(categoryId) {
boxesForCategory(categoryId).forEach(function (box) {
M.replaceChildren(box);
if (actionFieldLocksJ(box.dataset.fieldId)) box.appendChild(makeActionToken("Jellyfin", true, false));
});
}
function applyAllAction(allBox, rawValue, prepend) {
var categoryId = allBox.dataset.categoryId;
boxesForCategory(categoryId).forEach(function (box) {
var value = normalizeActionValue(box.dataset.fieldId, rawValue);
if (!value) return;
var existing = Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).filter(function (token) {
return token.dataset.value && token.dataset.value.toLowerCase() !== value.toLowerCase();
});
if (rawValue === "Original") existing = existing.filter(function (token) {
return token.dataset.value !== "Jellyfin";
});
var token = makeActionToken(value, actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin", false);
M.replaceChildren(box);
if (prepend) box.appendChild(token);
existing.forEach(function (item) { box.appendChild(item); });
if (!prepend) box.appendChild(token);
});
}
function updateActionStateFromBoxes() {
Array.prototype.slice.call(document.querySelectorAll("[data-action-box]")).forEach(function (box) {
var categoryId = box.dataset.categoryId;
if (box.dataset.fieldId === "__all__") return;
var fieldId = box.dataset.fieldId;
var category = categories().find(function (category) { return categoryId === category.Id; });
var target = categoryId === "__fallback__"
? state.rules.FallbackFieldActions
: (category || {}).FieldActionLists;
if (!target) return;
target[fieldId] = readBox(box);
if (category) {
categories().forEach(function (other, index) {
if (other === category) return;
if (categoryLabel(other, index) !== categoryLabel(category, categories().indexOf(category))) return;
other.FieldActionLists = target;
});
}
});
}
function renderAll() {
normalizeRules();
$("ml-enabled").checked = state.rules.Enabled === true;
$("ml-trust-collections").checked = state.rules.TrustTmdbCollections !== false;
renderSortLocales();
renderCategories();
renderActionMatrix();
}
function collect() {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().forEach(function (category) {
var complete = (category.Requirements || []).filter(completeRequirement);
category.Requirements = complete.length ? complete : [emptyRequirement()];
});
return {
Enabled: $("ml-enabled").checked,
SortLocale: $("ml-sort-locale").value || "Auto",
TrustTmdbCollections: $("ml-trust-collections").checked,
FallbackFieldActions: state.rules.FallbackFieldActions,
Categories: categories().map(function (category, index) {
var complete = (category.Requirements || []).filter(completeRequirement);
return {
Id: category.Id,
Label: categoryLabel(category, index),
Requirements: complete,
MatchAllConditions: category.MatchAllConditions !== false,
Scopes: category.Scopes,
FieldActionLists: category.FieldActionLists
};
})
};
}
async function load() {
M.setStatus(status, "Loading...");
try {
var all = await Promise.all([
M.get("/Multilang/AllowedLanguages"),
M.get("/Multilang/UserRules/self")
]);
state.allowed = (all[0] && all[0].Languages) || [];
state.rules = all[1] || {};
renderAll();
M.setStatus(status, "");
} catch (err) {
M.setStatus(status, String(err), true);
}
}
async function downloadUserExport() {
M.setStatus(status, "Creating export...");
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/export", {
cache: "no-store",
headers: M.authHeaders()
});
if (!response.ok) throw new Error("HTTP " + response.status);
var blob = await response.blob();
M.downloadBlob(blob, "multilang-user-settings.zip");
M.setStatus(status, "Export created.");
}
function userImportModal(show) {
$("ml-user-import-modal").hidden = !show;
if (!show) {
$("ml-user-import-choice").hidden = true;
M.replaceChildren($("ml-user-import-source"));
}
}
function userImportFormData(sourceUserId) {
var file = $("ml-user-import-file").files[0];
if (!file) throw new Error("Choose an export zip first.");
var formData = new FormData();
formData.append("file", file);
if (sourceUserId) formData.append("sourceUserId", sourceUserId);
return formData;
}
async function inspectUserImport() {
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/import/inspect", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: userImportFormData("")
});
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
}
async function postUserImport(sourceUserId) {
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/import", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: userImportFormData(sourceUserId)
});
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
}
async function runUserImport() {
M.setStatus(status, "Inspecting import...");
var choice = $("ml-user-import-choice");
if (!choice.hidden) {
var selected = $("ml-user-import-source").value;
if (!selected) throw new Error("Choose a source user ID.");
var chosen = await postUserImport(selected);
if ((chosen.UserSettingsImported ?? chosen.userSettingsImported ?? 0) <= 0) throw new Error("No user settings were imported.");
userImportModal(false);
await load();
M.setStatus(status, "Imported user settings.");
return;
}
var inspect = await inspectUserImport();
var count = inspect.UserSettingsCount ?? inspect.userSettingsCount ?? 0;
var containsCurrent = inspect.ContainsCurrentUser ?? inspect.containsCurrentUser;
var ids = inspect.UserIds || inspect.userIds || [];
if (count <= 0) {
M.setStatus(status, "No user settings were found in this export.", true);
return;
}
if (containsCurrent) {
var result = await postUserImport("");
if ((result.UserSettingsImported ?? result.userSettingsImported ?? 0) <= 0) throw new Error("No user settings were imported.");
userImportModal(false);
await load();
M.setStatus(status, "Imported user settings.");
return;
}
var select = $("ml-user-import-source");
M.replaceChildren(select);
ids.forEach(function (id) { select.appendChild(option(id, id, false)); });
choice.hidden = false;
M.setStatus(status, "No matching user ID was found. Choose which exported user settings to import.", true);
}
function bindEvents() {
if (page.dataset.bound === "1") return;
page.dataset.bound = "1";
form.addEventListener("submit", async function (event) {
event.preventDefault();
M.setStatus(status, "Saving...");
try {
state.rules = await M.post("/Multilang/UserRules/self", collect());
renderAll();
M.setStatus(status, "Saved.");
} catch (err) {
M.setStatus(status, String(err), true);
}
});
$("ml-reset-user").addEventListener("click", load);
$("ml-sort-locale").addEventListener("change", function () {
state.rules.SortLocale = $("ml-sort-locale").value || "Auto";
renderSortLocaleAutoText();
});
$("ml-debug-open").addEventListener("click", function () {
var itemId = $("ml-debug-item-id").value.trim();
if (!itemId) {
M.setStatus(status, "Enter a Jellyfin item ID.", true);
return;
}
window.open(M.debugUrl(itemId), "_blank", "noopener");
});
$("ml-user-export").addEventListener("click", function () {
downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); });
});
$("ml-user-import-open").addEventListener("click", function () {
$("ml-user-import-file").value = "";
userImportModal(true);
});
$("ml-user-import-cancel").addEventListener("click", function () { userImportModal(false); });
$("ml-user-import-run").addEventListener("click", function () {
runUserImport().catch(function (err) { M.setStatus(status, String(err), true); });
});
listen(document, "click", function () { closeValuePanels(null); });
$("ml-add-category").addEventListener("click", function () {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().push(newCategory(""));
renderAll();
});
listen(document, "dragstart", function (event) {
if (event.target.closest && event.target.closest(".ml-action-token")) event.preventDefault();
}, true);
page.addEventListener("pointerdown", beginActionDrag, true);
listen(document, "pointermove", moveActionDrag, true);
listen(document, "pointerup", endActionDrag, true);
listen(document, "pointercancel", function (event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
}, true);
listen(document, "viewshow", function (event) {
if (event.target && event.target.id === "ml-user") load();
});
}
bindEvents();
load();
})();
</script>
</div>
</div>