Initial jellyfin-multilang rewrite

This commit is contained in:
ajp_anton
2026-05-31 18:28:04 +00:00
commit 63dbc3a6d4
32 changed files with 9326 additions and 0 deletions
@@ -0,0 +1,55 @@
using System.Xml.Serialization;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Multilang.Configuration;
public sealed class PluginConfiguration : BasePluginConfiguration
{
public string TmdbApiKey { get; set; } = string.Empty;
public string FanartApiKey { get; set; } = string.Empty;
public string[] Languages { get; set; } = ["en-US", "fi-FI", "sv-SE"];
public ProviderState[] Providers { get; set; } =
[
new() { Id = "tmdb", MetadataOrder = 0, ArtworkOrder = 0 },
new() { Id = "fanart", MetadataOrder = -1, ArtworkOrder = 1 }
];
public int WaitDaysForMissingData { get; set; } = 7;
public int WaitDaysForExistingData { get; set; } = 60;
public int ItemsProxyCacheThresholdMs { get; set; } = 1000;
public int ItemsProxyCacheTtlMinutes { get; set; } = 120;
public int ItemsProxyCacheMaxMiB { get; set; } = 50;
[XmlArray("SortArticles")]
[XmlArrayItem("SortArticleEntry")]
public SortArticleEntry[] ArticleEntries { get; set; } = [];
public bool EnableLogging { get; set; }
public bool VerboseLogging { get; set; }
}
public sealed class ProviderState
{
public string Id { get; set; } = string.Empty;
public int MetadataOrder { get; set; } = -1;
public int ArtworkOrder { get; set; } = -1;
}
public sealed class SortArticleEntry
{
public string Language { get; set; } = string.Empty;
public string Articles { get; set; } = string.Empty;
public bool? AlwaysApply { get; set; }
}
@@ -0,0 +1,752 @@
<div id="ml-admin" data-role="page" class="page type-interior pluginConfigurationPage">
<div class="content-primary ml-root">
<link rel="stylesheet" href="/Multilang/shared.css">
<h1>Multilang</h1>
<form id="ml-admin-form">
<fieldset class="ml-section">
<legend>Providers</legend>
<div id="ml-provider-inputs"></div>
<div class="inputContainer">
<div class="fieldDescription">Provider priority order</div>
<div class="ml-buckets">
<div class="ml-bucket-col">
<div class="ml-bucket-title"><span class="ml-provider-cap ml-provider-cap-on">M</span>etadata</div>
<div class="ml-bucket ml-provider-bucket">
<ul id="ml-metadata-providers" class="ml-provider-list"></ul>
</div>
</div>
<div class="ml-bucket-col">
<div class="ml-bucket-title"><span class="ml-provider-cap ml-provider-cap-on">A</span>rtwork</div>
<div class="ml-bucket ml-provider-bucket">
<ul id="ml-artwork-providers" class="ml-provider-list"></ul>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Languages</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-languages">Languages</label>
<input id="ml-languages" type="text" class="emby-input ml-input" placeholder="en-US, fi-FI, sv-SE">
<div class="fieldDescription">Use BCP-47 language tags. These are the languages refresh jobs fetch and user rules can select.</div>
</div>
<details id="ml-article-details" class="ml-articles">
<summary>Articles</summary>
<div class="fieldDescription">Comma- or semicolon-separated leading articles to ignore per language.</div>
<div id="ml-article-list" class="ml-articles-list"></div>
<div class="ml-articles-actions">
<button id="ml-article-add" is="emby-button" type="button" class="raised"><span>Add language</span></button>
<button id="ml-article-repair" is="emby-button" type="button" class="raised"><span>Repair built-in</span></button>
<button id="ml-article-reset" is="emby-button" type="button" class="raised"><span>Reset to default</span></button>
</div>
</details>
</fieldset>
<fieldset class="ml-section">
<legend>Scanning task</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-missing-days">Wait days before trying to find missing data</label>
<input id="ml-missing-days" type="number" class="emby-input ml-input" min="0" step="1">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-existing-days">Wait days before refreshing existing data</label>
<input id="ml-existing-days" type="number" class="emby-input ml-input" min="0" step="1">
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Refresh activity</legend>
<button id="ml-refresh-activity-refresh" is="emby-button" type="button" class="raised"><span>Refresh activity</span></button>
<h3 class="ml-subheading">Current scan</h3>
<div id="ml-refresh-scan" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Queue</h3>
<div id="ml-refresh-queue" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Provider HTTP</h3>
<div id="ml-provider-http" class="ml-cache-entries"></div>
</fieldset>
<fieldset class="ml-section">
<legend>Items cache</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-cache-threshold">ItemsProxy cache threshold ms</label>
<input id="ml-cache-threshold" type="number" class="emby-input ml-input" min="0" step="100">
<div class="fieldDescription">Responses slower than this threshold can be stored in the in-memory ItemsProxy cache.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-cache-ttl">ItemsProxy cache TTL minutes</label>
<input id="ml-cache-ttl" type="number" class="emby-input ml-input" min="1" step="1">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-cache-max">ItemsProxy cache max MiB</label>
<input id="ml-cache-max" type="number" class="emby-input ml-input" min="1" step="1">
</div>
<div class="ml-actions">
<button id="ml-cache-refresh" is="emby-button" type="button" class="raised"><span>Refresh cache diagnostics</span></button>
<button id="ml-cache-clear" is="emby-button" type="button" class="raised ml-danger"><span>Clear cache</span></button>
</div>
<h3 class="ml-subheading">Stored responses</h3>
<div id="ml-cache-entries" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Recent requests</h3>
<div id="ml-proxy-requests" class="ml-cache-entries"></div>
</fieldset>
<fieldset class="ml-section">
<legend>Logging</legend>
<div class="inputContainer">
<label><input id="ml-log-enabled" type="checkbox"> Enable logging</label>
</div>
<div class="inputContainer">
<label><input id="ml-log-verbose" type="checkbox"> Verbose logging</label>
</div>
</fieldset>
<button id="ml-save" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form>
<div id="ml-status" class="ml-status"></div>
<script src="/Multilang/shared.js"></script>
<script>
(function () {
var M = window.Multilang;
var state = { config: null, providers: [], articles: {} };
var page = document.getElementById("ml-admin");
var form = document.getElementById("ml-admin-form");
var status = document.getElementById("ml-status");
var providerInputs = document.getElementById("ml-provider-inputs");
var metadataList = document.getElementById("ml-metadata-providers");
var artworkList = document.getElementById("ml-artwork-providers");
var articleList = document.getElementById("ml-article-list");
function make(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function providerId(provider) {
return String(provider.Id || provider.id || "").trim().toLowerCase();
}
function providerName(id) {
var provider = state.providers.find(function (p) { return providerId(p) === id; });
return provider ? (provider.Name || provider.name || id) : id;
}
function providerKeyField(provider) {
return provider.ApiKeyField || provider.apiKeyField || "";
}
function providerSupports(provider, bucket) {
return bucket === "metadata"
? !!(provider.SupportsMetadata || provider.supportsMetadata)
: !!(provider.SupportsArtwork || provider.supportsArtwork);
}
function providerById(id) {
return state.providers.find(function (p) { return providerId(p) === id; }) || null;
}
function splitList(value) {
return M.splitList(String(value || "").replace(/;/g, ","));
}
function normalizeLang(value) {
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
}
function syncProviderSourceWidth() {
var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source"));
if (!sources.length) return;
page.style.setProperty("--ml-provider-source-width", "auto");
var max = 0;
sources.forEach(function (source) {
max = Math.max(max, Math.ceil(source.getBoundingClientRect().width));
});
page.style.setProperty("--ml-provider-source-width", max + "px");
}
function renderProviderInputs() {
providerInputs.replaceChildren();
state.providers.forEach(function (provider) {
var id = providerId(provider);
var keyField = providerKeyField(provider);
var row = make("div", "inputContainer");
var inner = make("div", "ml-provider-input-row");
var source = make("span", "ml-provider-chip ml-provider-source ml-chip", providerName(id));
source.dataset.provider = id;
source.draggable = false;
var meta = make("span", "ml-provider-cap " + (providerSupports(provider, "metadata") ? "ml-provider-cap-on" : "ml-provider-cap-off"), "M");
var art = make("span", "ml-provider-cap " + (providerSupports(provider, "artwork") ? "ml-provider-cap-on" : "ml-provider-cap-off"), "A");
var input = make("input", "emby-input ml-provider-key");
input.type = "password";
input.autocomplete = "off";
input.dataset.provider = id;
input.dataset.key = keyField;
input.placeholder = provider.ApiKeyPlaceholder || provider.apiKeyPlaceholder || "";
input.value = state.config[keyField] || "";
var test = make("button", "raised");
test.type = "button";
test.setAttribute("is", "emby-button");
test.dataset.test = id;
test.appendChild(make("span", "", "Test"));
var result = make("span", "ml-muted");
result.dataset.testResult = id;
inner.append(source, meta, art, input, test, result);
row.appendChild(inner);
providerInputs.appendChild(row);
});
syncProviderSourceWidth();
}
function providerState(id) {
var states = state.config.Providers || [];
return states.find(function (p) { return String(p.Id || p.id || "").toLowerCase() === id; }) || {};
}
function orderFor(id, bucket) {
var p = providerState(id);
return bucket === "metadata" ? (p.MetadataOrder ?? p.metadataOrder ?? -1) : (p.ArtworkOrder ?? p.artworkOrder ?? -1);
}
function makeProviderChip(id) {
var chip = make("li", "ml-provider-chip ml-chip", providerName(id));
chip.dataset.provider = id;
chip.draggable = false;
return chip;
}
function renderBucket(list, bucket) {
var ids = state.providers
.map(providerId)
.filter(function (id) { return providerSupports(providerById(id), bucket) && orderFor(id, bucket) >= 0; })
.sort(function (a, b) { return orderFor(a, bucket) - orderFor(b, bucket); });
list.replaceChildren();
ids.forEach(function (id) { list.appendChild(makeProviderChip(id)); });
}
function renderProviderBuckets() {
renderBucket(metadataList, "metadata");
renderBucket(artworkList, "artwork");
}
function addArticleRow(lang, articles, builtIn, alwaysApply) {
var row = make("div", "ml-articles-row");
row.dataset.builtin = builtIn ? "1" : "";
var langInput = make("input", "emby-input ml-articles-lang");
langInput.type = "text";
langInput.placeholder = "en";
langInput.value = lang || "";
var articlesInput = make("input", "emby-input ml-articles-words");
articlesInput.type = "text";
articlesInput.placeholder = builtIn ? "Can't delete this language, but leaving empty will disable it." : "a, an, the";
articlesInput.value = articles || "";
var always = make("label", "ml-articles-apply");
var alwaysInput = make("input", "ml-articles-apply-toggle");
alwaysInput.type = "checkbox";
alwaysInput.checked = !!alwaysApply;
always.append(alwaysInput, make("span", "", "Always"));
var remove = make("button", "raised ml-articles-remove");
remove.type = "button";
remove.setAttribute("is", "emby-button");
remove.appendChild(make("span", "", "Delete"));
remove.addEventListener("click", function () {
if (row.dataset.builtin === "1") {
articlesInput.value = "";
return;
}
row.remove();
});
row.append(langInput, articlesInput, always, remove);
articleList.appendChild(row);
}
function renderArticles() {
articleList.replaceChildren();
var configured = new Map();
(state.config.ArticleEntries || []).forEach(function (entry) {
var lang = normalizeLang(entry.Language || entry.language);
if (lang) configured.set(lang, entry);
});
var builtInKeys = new Set(Object.keys(state.articles).map(normalizeLang));
Object.keys(state.articles).sort().forEach(function (lang) {
var entry = configured.get(normalizeLang(lang));
addArticleRow(
lang,
entry ? (entry.Articles || entry.articles || "") : (state.articles[lang] || []).join(", "),
true,
entry ? !!(entry.AlwaysApply ?? entry.alwaysApply) : normalizeLang(lang) === "en");
});
configured.forEach(function (entry, lang) {
if (builtInKeys.has(lang)) return;
addArticleRow(lang, entry.Articles || entry.articles || "", false, !!(entry.AlwaysApply ?? entry.alwaysApply));
});
}
function readBucket(list) {
return Array.prototype.slice.call(list.querySelectorAll(".ml-provider-chip"))
.map(function (chip) { return chip.dataset.provider; })
.filter(Boolean);
}
function collect() {
document.querySelectorAll("[data-key]").forEach(function (input) {
state.config[input.dataset.key] = input.value;
});
var metadata = readBucket(metadataList);
var artwork = readBucket(artworkList);
state.config.Providers = state.providers.map(function (provider) {
var id = providerId(provider);
return {
Id: id,
MetadataOrder: metadata.indexOf(id),
ArtworkOrder: artwork.indexOf(id)
};
});
state.config.Languages = splitList(document.getElementById("ml-languages").value);
state.config.WaitDaysForMissingData = Number(document.getElementById("ml-missing-days").value || 0);
state.config.WaitDaysForExistingData = Number(document.getElementById("ml-existing-days").value || 0);
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0);
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1);
state.config.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked;
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
state.config.ArticleEntries = Array.prototype.slice.call(articleList.children).map(function (row) {
return {
Language: normalizeLang(row.querySelector(".ml-articles-lang").value),
Articles: row.querySelector(".ml-articles-words").value,
AlwaysApply: row.querySelector(".ml-articles-apply-toggle").checked
};
}).filter(function (entry) { return entry.Language; });
return state.config;
}
function loadIntoForm() {
document.getElementById("ml-languages").value = (state.config.Languages || []).join(", ");
document.getElementById("ml-missing-days").value = state.config.WaitDaysForMissingData ?? 7;
document.getElementById("ml-existing-days").value = state.config.WaitDaysForExistingData ?? 60;
document.getElementById("ml-cache-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000;
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50;
document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging;
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
renderProviderInputs();
renderProviderBuckets();
renderArticles();
loadCacheDiagnostics();
loadRefreshDiagnostics();
}
function renderCacheEntries(entries, error) {
var root = document.getElementById("ml-cache-entries");
root.replaceChildren();
if (error) {
root.appendChild(make("div", "ml-error", error));
return;
}
if (!entries || entries.length === 0) {
root.appendChild(make("div", "ml-muted", "No cache entries."));
return;
}
entries.forEach(function (entry) {
var url = entry.Url || entry.url || "";
root.appendChild(makeDiagnosticRow(url, [
"cached",
(entry.ItemCount || entry.itemCount || 0) + " items",
(entry.DurationMs || entry.durationMs || 0) + " ms",
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB",
(entry.AgeSeconds || entry.ageSeconds || 0) + " s old"
]));
});
}
async function loadCacheEntries() {
try {
renderCacheEntries(await M.get("/Multilang/CacheEntries"));
} catch (err) {
renderCacheEntries([], "Unable to load cache entries: " + String(err && err.message ? err.message : err));
}
}
function renderProxyRequests(entries, error) {
var root = document.getElementById("ml-proxy-requests");
root.replaceChildren();
if (error) {
root.appendChild(make("div", "ml-error", error));
return;
}
if (!entries || entries.length === 0) {
root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests."));
return;
}
entries.forEach(function (entry) {
var url = entry.Url || entry.url || "";
root.appendChild(makeDiagnosticRow(url, [
(entry.CacheHit || entry.cacheHit) ? "hit" : ((entry.CacheStored || entry.cacheStored) ? "stored" : "miss"),
"HTTP " + (entry.StatusCode || entry.statusCode || 0),
(entry.ItemCount || entry.itemCount || 0) + " items",
"total " + (entry.TotalMs || entry.totalMs || 0) + " ms",
"upstream " + (entry.UpstreamMs || entry.upstreamMs || 0) + " ms",
"transform " + (entry.TransformMs || entry.transformMs || 0) + " ms",
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB",
(entry.AgeSeconds || entry.ageSeconds || 0) + " s old"
]));
});
}
function makeDiagnosticRow(url, parts) {
var row = make("div", "ml-diagnostic-row");
var top = make("div", "ml-diagnostic-meta");
parts.forEach(function (part, index) {
var chip = make("span", index === 0 ? "ml-diagnostic-chip ml-diagnostic-chip-strong" : "ml-diagnostic-chip", part);
top.appendChild(chip);
});
var urlEl = make("div", "ml-cache-url", url);
urlEl.title = url;
row.append(top, urlEl);
return row;
}
async function loadProxyRequests() {
try {
renderProxyRequests(await M.get("/Multilang/ItemsProxyRequests"));
} catch (err) {
renderProxyRequests([], "Unable to load ItemsProxy requests: " + String(err && err.message ? err.message : err));
}
}
function loadCacheDiagnostics() {
loadCacheEntries();
loadProxyRequests();
}
async function clearCacheEntries() {
if (!window.confirm("Clear all Multilang ItemsProxy cache entries?")) return;
M.setStatus(status, "Clearing cache...");
try {
await M.post("/Multilang/ClearCache", {});
await loadCacheDiagnostics();
M.setStatus(status, "Cache cleared.");
} catch (err) {
M.setStatus(status, "Unable to clear cache: " + String(err && err.message ? err.message : err), true);
}
}
function prop(obj, pascal, camel, fallback) {
if (!obj) return fallback;
if (obj[pascal] !== undefined) return obj[pascal];
if (obj[camel] !== undefined) return obj[camel];
return fallback;
}
function formatUnixTime(value) {
value = Number(value || 0);
if (!value) return "never";
return new Date(value * 1000).toLocaleString();
}
function renderRefreshDiagnostics(data, error) {
var scanRoot = document.getElementById("ml-refresh-scan");
var queueRoot = document.getElementById("ml-refresh-queue");
var httpRoot = document.getElementById("ml-provider-http");
scanRoot.replaceChildren();
queueRoot.replaceChildren();
httpRoot.replaceChildren();
if (error) {
scanRoot.appendChild(make("div", "ml-error", error));
return;
}
data = data || {};
var scan = prop(data, "Scan", "scan", {});
var queue = prop(data, "Queue", "queue", {});
var providerHttp = prop(data, "ProviderHttp", "providerHttp", {});
var running = !!prop(scan, "Running", "running", false);
scanRoot.appendChild(makeDiagnosticRow("Last scan started: " + formatUnixTime(prop(data, "LastScanStarted", "lastScanStarted", 0)), [
running ? "running" : "idle",
String(prop(scan, "Mode", "mode", "") || "none"),
String(prop(scan, "Source", "source", "") || "none"),
"items " + Number(prop(scan, "Items", "items", 0) || 0),
"due " + Number(prop(scan, "Due", "due", 0) || 0),
"refreshed " + Number(prop(scan, "Refreshed", "refreshed", 0) || 0),
"skipped " + (Number(prop(scan, "SkippedNotDue", "skippedNotDue", 0) || 0) + Number(prop(scan, "SkippedNoData", "skippedNoData", 0) || 0)),
"new " + Number(prop(scan, "New", "new", 0) || 0),
"deleted " + Number(prop(scan, "Deleted", "deleted", 0) || 0),
"current " + Number(prop(scan, "CurrentIndex", "currentIndex", 0) || 0) + "/" + Number(prop(scan, "Items", "items", 0) || 0),
String(prop(scan, "CurrentAction", "currentAction", "") || "idle"),
Number(prop(scan, "ElapsedMs", "elapsedMs", 0) || 0) + " ms"
]));
var currentItem = String(prop(scan, "CurrentItemId", "currentItemId", "") || "");
if (currentItem) {
scanRoot.appendChild(makeDiagnosticRow("Current item: " + currentItem, [
String(prop(scan, "CurrentName", "currentName", "") || ""),
String(prop(scan, "CurrentKind", "currentKind", "") || "")
]));
}
var scanError = String(prop(scan, "Error", "error", "") || "");
if (scanError) scanRoot.appendChild(make("div", "ml-error", scanError));
var active = prop(queue, "Active", "active", null);
var queued = prop(queue, "Queued", "queued", []) || [];
var recent = prop(queue, "Recent", "recent", []) || [];
if (active) {
queueRoot.appendChild(makeDiagnosticRow("Active item: " + String(prop(active, "ItemId", "itemId", "")), [
"active",
String(prop(active, "SourceTier", "sourceTier", "")),
String(prop(active, "WorkClass", "workClass", "")),
String(prop(active, "JobType", "jobType", ""))
]));
}
queueRoot.appendChild(makeDiagnosticRow("Queued refresh items", [
"queued",
String(Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0))
]));
recent.slice(0, 10).forEach(function (item) {
queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [
prop(item, "Ok", "ok", false) ? "ok" : "failed",
String(prop(item, "SourceTier", "sourceTier", "")),
String(prop(item, "WorkClass", "workClass", "")),
String(prop(item, "JobType", "jobType", "")),
Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms",
formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0))
]));
var itemError = String(prop(item, "Error", "error", "") || "");
if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError));
});
var counts = prop(providerHttp, "Counts", "counts", []) || [];
var providerRecent = prop(providerHttp, "Recent", "recent", []) || [];
if (counts.length === 0 && providerRecent.length === 0) {
httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup."));
return;
}
counts.forEach(function (count) {
httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [
"count",
String(prop(count, "Outcome", "outcome", "")),
String(Number(prop(count, "Count", "count", 0) || 0))
]));
});
providerRecent.slice(0, 10).forEach(function (entry) {
httpRoot.appendChild(makeDiagnosticRow(String(prop(entry, "Url", "url", "")), [
String(prop(entry, "Provider", "provider", "")),
String(prop(entry, "Outcome", "outcome", "")),
"attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0),
formatUnixTime(prop(entry, "At", "at", 0))
]));
});
}
async function loadRefreshDiagnostics() {
try {
renderRefreshDiagnostics(await M.get("/Multilang/RefreshDiagnostics"));
} catch (err) {
renderRefreshDiagnostics(null, "Unable to load refresh activity: " + String(err && err.message ? err.message : err));
}
}
async function load() {
M.setStatus(status, "Loading...");
try {
var all = await Promise.all([
M.get("/Multilang/Providers"),
M.get("/Multilang/ArticleBuiltins"),
M.get("/Multilang/AdminConfig")
]);
state.providers = all[0] || [];
state.articles = all[1] || {};
state.config = all[2] || {};
loadIntoForm();
M.setStatus(status, "");
} catch (err) {
M.setStatus(status, String(err), true);
}
}
function supportsList(list, id) {
var provider = providerById(id);
if (!provider) return false;
if (list === metadataList) return providerSupports(provider, "metadata");
if (list === artworkList) return providerSupports(provider, "artwork");
return false;
}
function hasProvider(list, id, except) {
var existing = list.querySelector('.ml-provider-chip[data-provider="' + id + '"]');
return existing && existing !== except;
}
var active = null;
var ghost = null;
var placeholder = make("li", "ml-provider-chip ml-provider-placeholder ml-chip");
function listFromPoint(x, y) {
var el = document.elementFromPoint(x, y);
if (!el || !el.closest) return null;
var list = el.closest("#ml-metadata-providers, #ml-artwork-providers");
if (list) return list;
var bucket = el.closest(".ml-provider-bucket");
return bucket ? bucket.querySelector("ul") : null;
}
function cleanupDrag() {
placeholder.remove();
if (ghost) ghost.remove();
ghost = null;
active = null;
metadataList.classList.remove("ml-bucket-list-over");
artworkList.classList.remove("ml-bucket-list-over");
}
function positionPlaceholder(list, x, y) {
if (!active || !supportsList(list, active.id) || hasProvider(list, active.id, active.sourceEl)) {
placeholder.remove();
return;
}
var chips = Array.prototype.slice.call(list.querySelectorAll(".ml-provider-chip")).filter(function (chip) {
return chip !== placeholder;
});
for (var i = 0; i < chips.length; i++) {
var rect = chips[i].getBoundingClientRect();
if (x < rect.left + rect.width / 2) {
list.insertBefore(placeholder, chips[i]);
return;
}
}
list.appendChild(placeholder);
}
function beginDrag(event) {
var chip = event.target.closest && event.target.closest(".ml-provider-chip");
if (!chip || !chip.dataset.provider) return;
var sourceList = chip.parentElement === metadataList || chip.parentElement === artworkList ? chip.parentElement : null;
var fromSource = chip.classList.contains("ml-provider-source");
if (!sourceList && !fromSource) return;
event.preventDefault();
event.stopPropagation();
var id = chip.dataset.provider;
active = { id: id, pointerId: event.pointerId, sourceEl: null, sourceList: sourceList, offsetX: 0, offsetY: 0 };
var rect = chip.getBoundingClientRect();
active.offsetX = event.clientX - rect.left;
active.offsetY = event.clientY - rect.top;
placeholder.textContent = providerName(id);
if (sourceList) {
active.sourceEl = chip;
sourceList.insertBefore(placeholder, chip);
chip.remove();
}
ghost = chip.cloneNode(true);
ghost.classList.add("ml-provider-drag-ghost");
ghost.style.position = "fixed";
ghost.style.pointerEvents = "none";
ghost.style.margin = "0";
ghost.style.zIndex = "99999";
document.body.appendChild(ghost);
moveDrag(event);
}
function moveDrag(event) {
if (!active || event.pointerId !== active.pointerId) return;
if (ghost) {
ghost.style.left = event.clientX - active.offsetX + "px";
ghost.style.top = event.clientY - active.offsetY + "px";
}
var list = listFromPoint(event.clientX, event.clientY);
metadataList.classList.toggle("ml-bucket-list-over", list === metadataList);
artworkList.classList.toggle("ml-bucket-list-over", list === artworkList);
if (list) positionPlaceholder(list, event.clientX, event.clientY);
else placeholder.remove();
}
function endDrag(event) {
if (!active || event.pointerId !== active.pointerId) return;
var list = listFromPoint(event.clientX, event.clientY);
if (list && supportsList(list, active.id) && !hasProvider(list, active.id, active.sourceEl)) {
var chip = active.sourceEl || makeProviderChip(active.id);
if (placeholder.parentElement === list) list.insertBefore(chip, placeholder);
else list.appendChild(chip);
} else if (list && active.sourceEl && active.sourceList) {
active.sourceList.insertBefore(active.sourceEl, placeholder.parentElement === active.sourceList ? placeholder : null);
} else if (active.sourceEl && active.sourceList) {
active.sourceEl.remove();
}
cleanupDrag();
}
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.config = await M.post("/Multilang/AdminConfig", collect());
loadIntoForm();
M.setStatus(status, "Saved.");
} catch (err) {
M.setStatus(status, String(err), true);
}
});
document.getElementById("ml-reload").addEventListener("click", load);
document.getElementById("ml-cache-refresh").addEventListener("click", loadCacheDiagnostics);
document.getElementById("ml-cache-clear").addEventListener("click", clearCacheEntries);
document.getElementById("ml-refresh-activity-refresh").addEventListener("click", loadRefreshDiagnostics);
document.getElementById("ml-log-enabled").addEventListener("change", function () {
if (!this.checked) document.getElementById("ml-log-verbose").checked = false;
});
document.getElementById("ml-log-verbose").addEventListener("change", function () {
if (this.checked) document.getElementById("ml-log-enabled").checked = true;
});
document.getElementById("ml-article-add").addEventListener("click", function () { addArticleRow("", "", false, false); });
document.getElementById("ml-article-repair").addEventListener("click", renderArticles);
document.getElementById("ml-article-reset").addEventListener("click", function () {
state.config.ArticleEntries = [];
renderArticles();
});
providerInputs.addEventListener("click", async function (event) {
var button = event.target.closest && event.target.closest("[data-test]");
if (!button) return;
var provider = button.dataset.test;
var input = providerInputs.querySelector('input[data-provider="' + provider + '"]');
var result = providerInputs.querySelector('[data-test-result="' + provider + '"]');
result.textContent = "Testing...";
try {
var response = await M.post("/Multilang/TestProvider", { Provider: provider, ApiKey: input.value || "" });
result.textContent = response.Ok ? "OK" : "Failed" + (response.Status ? " HTTP " + response.Status : "");
} catch (err) {
result.textContent = String(err);
}
});
document.addEventListener("dragstart", function (event) {
if (event.target.closest && event.target.closest(".ml-provider-chip")) event.preventDefault();
}, true);
page.addEventListener("pointerdown", beginDrag, true);
document.addEventListener("pointermove", moveDrag, true);
document.addEventListener("pointerup", endDrag, true);
document.addEventListener("pointercancel", cleanupDrag, true);
}
document.addEventListener("viewshow", function (event) {
if (event.target && event.target.id === "ml-admin") {
bindEvents();
load();
}
});
bindEvents();
load();
})();
</script>
</div>
</div>
@@ -0,0 +1,227 @@
<div id="ml-debug" class="page type-interior pluginConfigurationPage">
<div class="content-primary ml-root">
<link rel="stylesheet" href="/Multilang/shared.css">
<h2>Multilang Debug</h2>
<fieldset class="ml-section">
<legend>Request</legend>
<div class="ml-debug-toolbar">
<label class="ml-inline-check">
<span>View:</span>
<select id="ml-debug-mode" class="emby-select ml-select">
<option value="resolved">Resolved for current user</option>
<option value="stored">Stored facts and translations</option>
</select>
</label>
<button id="ml-debug-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button>
<button id="ml-debug-copy" is="emby-button" type="button" class="raised"><span>Copy JSON</span></button>
</div>
<div id="ml-debug-meta" class="fieldDescription ml-muted"></div>
</fieldset>
<fieldset class="ml-section">
<legend>Result</legend>
<div id="ml-debug-summary" class="ml-debug-summary"></div>
<pre id="ml-debug-output" class="ml-debug-output">Loading...</pre>
</fieldset>
<div id="ml-debug-status" class="ml-status"></div>
<script src="/Multilang/shared.js"></script>
<script>
(function () {
var M = window.Multilang;
var status = document.getElementById("ml-debug-status");
var output = document.getElementById("ml-debug-output");
var summary = document.getElementById("ml-debug-summary");
var mode = document.getElementById("ml-debug-mode");
var meta = document.getElementById("ml-debug-meta");
var lastJson = "";
function itemId() {
var match = location.pathname.match(/\/Multilang\/DebugUi\/([^/?#]+)/i);
return match ? decodeURIComponent(match[1]) : "";
}
function endpoint() {
var id = encodeURIComponent(itemId());
var locale = document.documentElement.lang || navigator.language || "";
var suffix = locale ? "?mlLocale=" + encodeURIComponent(locale) : "";
return mode.value === "stored"
? "/Multilang/Debug/" + id
: "/Multilang/Debug/" + id + "/self" + suffix;
}
function authHint(error) {
return [
"Error: " + error.message,
"",
"This page needs a Jellyfin token. Open it from inside Jellyfin, or add ?token=... / ?api_key=... to the URL.",
"",
"From a Jellyfin browser console:",
"window.open('/Multilang/DebugUi/" + itemId().replace(/'/g, "\\'") + "?token=' + ApiClient.accessToken())"
].join("\n");
}
function prop(obj, pascal, camel, fallback) {
if (!obj) return fallback;
if (obj[pascal] !== undefined) return obj[pascal];
if (obj[camel] !== undefined) return obj[camel];
return fallback;
}
function make(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function chip(text, strong) {
return make("span", strong ? "ml-diagnostic-chip ml-diagnostic-chip-strong" : "ml-diagnostic-chip", text);
}
function section(title) {
var root = make("div", "ml-debug-card");
root.appendChild(make("h3", "ml-subheading", title));
return root;
}
function labelOf(category) {
return prop(category, "Label", "label", "") || prop(category, "Id", "id", "") || "Fallback";
}
function renderCategory(data) {
var root = section("Classification");
var matched = prop(data, "MatchedCategory", "matchedCategory", null);
var effective = prop(data, "EffectiveCategory", "effectiveCategory", null);
var duplicate = !!prop(data, "DuplicateCategoryLabelResolved", "duplicateCategoryLabelResolved", false);
var evalData = prop(data, "CategoryEvaluation", "categoryEvaluation", {});
var traces = prop(evalData, "CategoryTraces", "categoryTraces", []) || [];
var metaRow = make("div", "ml-diagnostic-meta");
metaRow.append(
chip("matched: " + labelOf(matched), true),
chip("effective: " + labelOf(effective), true),
chip(duplicate ? "duplicate label: first category settings used" : "no duplicate-label redirect"),
chip(prop(evalData, "UsesCollectionAggregateFacts", "usesCollectionAggregateFacts", false) ? "collection uses child aggregate facts" : "uses item facts")
);
root.appendChild(metaRow);
traces.forEach(function (trace) {
var row = make("div", "ml-diagnostic-row");
var top = make("div", "ml-diagnostic-meta");
top.append(
chip((prop(trace, "Result", "result", false) ? "match" : "no match"), prop(trace, "Result", "result", false)),
chip(labelOf(trace), true),
chip(prop(trace, "ScopeMatches", "scopeMatches", false) ? "scope ok" : "scope mismatch"),
chip(prop(trace, "MatchAllConditions", "matchAllConditions", true) ? "conditions: all" : "conditions: any")
);
row.appendChild(top);
(prop(trace, "Requirements", "requirements", []) || []).forEach(function (req) {
var reqLine = make("div", "ml-debug-small");
var fields = (prop(req, "Fields", "fields", []) || []).join(prop(req, "UseFieldOr", "useFieldOr", false) ? " or " : " and ");
var values = (prop(req, "Expected", "expected", []) || []).join(prop(req, "UseOr", "useOr", false) ? " or " : " and ");
reqLine.textContent = (prop(req, "Result", "result", false) ? "OK: " : "NO: ") + fields + " " + prop(req, "Relation", "relation", "") + " " + values;
row.appendChild(reqLine);
(prop(req, "FieldResults", "fieldResults", []) || []).forEach(function (field) {
var actual = (prop(field, "Actual", "actual", []) || []).join(", ") || "(empty)";
row.appendChild(make("div", "ml-debug-detail", prop(field, "Field", "field", "") + " actual: " + actual));
});
});
root.appendChild(row);
});
if (traces.length === 0) root.appendChild(make("div", "ml-muted", "No user-defined classification rules."));
return root;
}
function renderFieldResolution(data) {
var root = section("Field resolution");
var resolution = prop(data, "FieldResolution", "fieldResolution", {});
["Title", "Overview", "Tagline", "Poster", "Logo", "Banner", "Thumb", "Backdrop"].forEach(function (name) {
var item = prop(resolution, name, name.charAt(0).toLowerCase() + name.slice(1), null);
if (!item) return;
var result = prop(item, "Result", "result", {});
var row = make("div", "ml-diagnostic-row");
var top = make("div", "ml-diagnostic-meta");
top.append(
chip(name, true),
chip(prop(item, "SelectedAction", "selectedAction", "")),
chip(prop(result, "Change", "change", false) ? "Multilang" : "Jellyfin"),
chip(prop(item, "Reason", "reason", ""))
);
row.appendChild(top);
(prop(item, "Attempts", "attempts", []) || []).forEach(function (attempt) {
var line = prop(attempt, "Chosen", "chosen", false) ? "chosen" : (prop(attempt, "HasValue", "hasValue", false) ? "available" : "missing");
row.appendChild(make("div", "ml-debug-detail", line + ": " + prop(attempt, "Action", "action", "") + " [" + prop(attempt, "LookupKey", "lookupKey", "") + "] - " + prop(attempt, "Reason", "reason", "")));
});
root.appendChild(row);
});
return root;
}
function renderMissing(data) {
var root = section("Missing configured-language data");
var rows = prop(data, "MissingData", "missingData", []) || [];
if (rows.length === 0) {
root.appendChild(make("div", "ml-muted", "No configured languages."));
return root;
}
rows.forEach(function (row) {
var missingText = prop(row, "MissingTextFields", "missingTextFields", []) || [];
var missingAssets = prop(row, "MissingAssetKinds", "missingAssetKinds", []) || [];
var line = make("div", "ml-diagnostic-row");
var top = make("div", "ml-diagnostic-meta");
top.append(
chip(prop(row, "Language", "language", ""), true),
chip(prop(row, "Complete", "complete", false) ? "complete" : "missing data")
);
line.appendChild(top);
line.appendChild(make("div", "ml-debug-detail", "missing text: " + (missingText.join(", ") || "none")));
line.appendChild(make("div", "ml-debug-detail", "missing assets: " + (missingAssets.join(", ") || "none")));
root.appendChild(line);
});
return root;
}
function renderSummary(data) {
summary.replaceChildren();
if (mode.value !== "resolved") return;
var top = make("div", "ml-diagnostic-meta");
top.append(
chip(prop(data, "RulesEnabled", "rulesEnabled", false) ? "rules enabled" : "rules disabled", true),
chip("sort locale: " + (prop(data, "EffectiveSortLocale", "effectiveSortLocale", "") || "invariant")),
chip("genre locale: " + (prop(data, "GenreLocale", "genreLocale", "") || "invariant"))
);
summary.append(top, renderCategory(data), renderFieldResolution(data), renderMissing(data));
}
async function load() {
M.setStatus(status, "Loading...");
meta.textContent = "Item: " + itemId() + " | " + mode.options[mode.selectedIndex].text;
try {
var data = await M.get(endpoint());
lastJson = JSON.stringify(data, null, 2);
renderSummary(data);
output.textContent = lastJson;
M.setStatus(status, "Loaded.");
} catch (err) {
lastJson = "";
summary.replaceChildren();
output.textContent = authHint(err);
M.setStatus(status, err.message, true);
}
}
document.getElementById("ml-debug-reload").addEventListener("click", load);
document.getElementById("ml-debug-copy").addEventListener("click", function () {
if (!lastJson || !navigator.clipboard) return;
navigator.clipboard.writeText(lastJson);
});
mode.addEventListener("change", load);
load();
})();
</script>
</div>
</div>
@@ -0,0 +1,813 @@
.ml-root {
max-width: 1120px;
margin: 0 auto;
padding: 1.5rem;
color: inherit;
}
html,
body {
min-height: 100%;
background: #101010;
color: rgba(255, 255, 255, .92);
color-scheme: dark;
}
.ml-section {
border-top: 1px solid rgba(255, 255, 255, .16);
margin-top: 1.2rem;
padding: 1.1rem 1rem 1rem 1.35rem;
}
.ml-section:first-of-type {
margin-top: 0;
}
.ml-section legend {
font-size: 1.35rem;
font-weight: 700;
margin-left: -.35rem;
padding-right: .75rem;
}
.ml-section summary {
cursor: pointer;
font-size: 1.08rem;
font-weight: 600;
}
.ml-grid {
display: grid;
gap: .8rem;
}
.ml-grid-2 {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.ml-row {
display: flex;
align-items: center;
gap: .75rem;
min-height: 2.6rem;
}
.ml-row > label {
min-width: 11rem;
}
.ml-input,
.ml-select,
.ml-textarea {
width: 100%;
box-sizing: border-box;
padding: .55rem .7rem;
border: 1px solid rgba(255, 255, 255, .22);
border-radius: 6px;
background: rgba(0, 0, 0, .18);
color: inherit;
color-scheme: dark;
}
.ml-select {
min-height: 2.45rem;
}
.ml-enable-row {
margin-bottom: 1rem;
}
.ml-sort-row {
display: flex;
align-items: center;
gap: .5rem;
flex-wrap: wrap;
}
.ml-sort-row .inputLabel {
margin: 0;
}
.ml-sort-row .ml-select {
width: auto;
min-width: max-content;
}
.ml-translation-option {
margin-bottom: .8rem;
}
.ml-debug-toolbar {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.ml-debug-toolbar .ml-select {
width: auto;
min-width: 16rem;
}
.ml-debug-output {
box-sizing: border-box;
width: 100%;
max-height: 70vh;
min-height: 18rem;
overflow: auto;
margin: 0;
padding: .8rem;
border: 1px solid rgba(255, 255, 255, .18);
border-radius: 6px;
background: rgba(0, 0, 0, .24);
color: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ml-debug-summary {
display: flex;
flex-direction: column;
gap: .8rem;
margin-bottom: .8rem;
}
.ml-debug-card {
display: flex;
flex-direction: column;
gap: .45rem;
}
.ml-debug-small {
font-size: .92rem;
line-height: 1.35;
}
.ml-debug-detail {
color: rgba(255, 255, 255, .7);
font-size: .86rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.ml-danger {
background: #7a1f1f !important;
border-color: #b04444 !important;
color: #fff !important;
}
.ml-danger:hover {
border-color: #d06060 !important;
}
.ml-danger[disabled],
.ml-danger[disabled]:hover {
background: #3a3a3a !important;
border-color: #555 !important;
color: rgba(255, 255, 255, .45) !important;
cursor: not-allowed !important;
opacity: .65;
}
.ml-select option,
select.emby-select option {
background: #1f1f1f;
color: rgba(255, 255, 255, .92);
}
.ml-select option:checked,
select.emby-select option:checked {
background: rgba(120, 170, 255, .35);
color: #fff;
}
.ml-textarea {
min-height: 5rem;
resize: vertical;
}
.ml-button {
min-height: 2.4rem;
border: 0;
border-radius: 6px;
padding: .5rem .9rem;
cursor: pointer;
}
.ml-button[disabled],
.ml-input[disabled] {
opacity: .55;
cursor: not-allowed;
}
.ml-provider,
.ml-rule {
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 8px;
padding: .8rem;
background: rgba(255, 255, 255, .04);
}
.ml-chip {
display: inline-flex;
align-items: center;
gap: .35rem;
margin: .2rem;
padding: .4rem .65rem;
border-radius: 999px;
background: rgba(120, 170, 255, .2);
border: 1px solid rgba(120, 170, 255, .35);
cursor: grab;
user-select: none;
}
.ml-bucket {
min-height: 4.6rem;
padding: .55rem;
border: 1px dashed rgba(255, 255, 255, .28);
border-radius: 8px;
}
.ml-bucket.ml-over {
border-color: rgba(120, 200, 255, .9);
background: rgba(120, 200, 255, .08);
}
.ml-tabs {
display: flex;
gap: .4rem;
margin: 1rem 0;
}
.ml-tab {
border: 1px solid rgba(255, 255, 255, .18);
background: transparent;
color: inherit;
border-radius: 6px;
padding: .45rem .75rem;
cursor: pointer;
}
.ml-tab[aria-selected="true"] {
background: rgba(120, 170, 255, .22);
}
.ml-status {
min-height: 1.4rem;
margin-top: .8rem;
}
.ml-muted {
opacity: .7;
}
.ml-error {
color: #ff9b9b;
}
.ml-actions {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-top: 1rem;
}
.ml-buckets {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: .8rem;
}
.ml-bucket-col {
min-width: 0;
}
.ml-bucket-title {
display: flex;
align-items: center;
gap: .45rem;
margin: .35rem 0;
font-weight: 600;
}
.ml-provider-list {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: .35rem;
min-height: 4rem;
margin: 0;
padding: .45rem;
list-style: none;
}
.ml-provider-input-row {
display: flex;
align-items: center;
gap: .5rem;
flex-wrap: wrap;
}
.ml-provider-key {
flex: 1 1 18rem;
}
.ml-provider-source {
width: var(--ml-provider-source-width, auto);
justify-content: center;
border-color: #666;
color: rgba(255, 255, 255, .92);
background: #1f1f1f;
}
.ml-provider-chip.ml-provider-placeholder {
border-style: dashed;
opacity: .55;
}
.ml-provider-drag-ghost {
opacity: .85;
}
.ml-provider-cap {
width: 1.75rem;
min-width: 1.75rem;
text-align: center;
border: 1px solid #444;
border-radius: 6px;
padding: .35rem 0;
background: #1a1a1a;
color: rgba(255, 255, 255, .92);
user-select: none;
-webkit-user-select: none;
}
.ml-provider-cap-on {
border-color: #666;
}
.ml-provider-cap-off {
opacity: .35;
}
.ml-bucket-list-over {
outline: 1px solid rgba(120, 200, 255, .9);
background: rgba(120, 200, 255, .08);
}
.ml-articles {
margin-top: .5rem;
}
.ml-articles-list {
display: flex;
flex-direction: column;
gap: .4rem;
margin-top: .5rem;
}
.ml-articles-row {
display: flex;
gap: .5rem;
align-items: center;
flex-wrap: wrap;
}
.ml-articles-lang {
width: 6rem;
}
.ml-articles-words {
flex: 1 1 18rem;
}
.ml-articles-apply {
display: inline-flex;
align-items: center;
gap: .25rem;
}
.ml-articles-apply input {
margin: 0;
}
.ml-articles-actions {
display: flex;
gap: .5rem;
flex-wrap: wrap;
margin-top: .5rem;
}
.ml-list-box {
display: flex;
flex-direction: column;
gap: .35rem;
min-height: 4.6rem;
padding: .45rem;
border: 1px dashed rgba(255, 255, 255, .28);
border-radius: 8px;
}
.ml-list-row {
display: flex;
align-items: center;
gap: .4rem;
min-height: 2.35rem;
}
.ml-list-label {
flex: 1 1 auto;
min-width: 6rem;
}
.ml-list-action {
min-height: 2rem;
padding: .35rem .6rem;
}
.ml-category-list {
display: flex;
flex-direction: column;
gap: .9rem;
margin-bottom: .75rem;
}
.ml-classification-rule {
display: grid;
grid-template-columns: 1fr auto;
gap: .75rem;
align-items: stretch;
}
.ml-category-card {
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 8px;
padding: .8rem;
background: rgba(255, 255, 255, .04);
}
.ml-classification-main {
min-width: 0;
}
.ml-classification-controls {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
gap: .4rem;
}
.ml-category-top,
.ml-category-buttons,
.ml-category-scopes,
.ml-criteria-helper {
display: flex;
align-items: center;
gap: .5rem;
flex-wrap: wrap;
}
.ml-category-top {
justify-content: flex-start;
margin-bottom: .55rem;
}
.ml-category-top .ml-input {
flex: 0 1 22rem;
}
.ml-category-scopes {
margin-bottom: 0;
}
.ml-requirements {
display: flex;
flex-direction: column;
gap: .45rem;
margin-bottom: .55rem;
padding-left: 1rem;
}
.ml-requirement-row {
display: grid;
grid-template-columns: minmax(12rem, 1fr) minmax(10rem, .75fr) minmax(14rem, 1.2fr) auto;
gap: .5rem;
align-items: start;
}
.ml-classification-main > .ml-list-action {
margin-left: 1rem;
}
.ml-category-condition-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
margin-left: 1rem;
}
.ml-category-condition-left {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.ml-match-all-check {
min-height: 2.45rem;
}
.ml-inline-check {
display: inline-flex;
align-items: center;
gap: .3rem;
}
.ml-value-dropdown {
position: relative;
min-width: 0;
}
.ml-value-button {
width: 100%;
min-height: 2.45rem;
height: 2.45rem;
box-sizing: border-box;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.ml-relation-select:disabled {
background: rgba(255, 255, 255, .06);
border-color: rgba(255, 255, 255, .12);
color: rgba(255, 255, 255, .38);
cursor: not-allowed;
opacity: .7;
}
.ml-value-panel {
position: absolute;
z-index: 1000;
width: min(24rem, 90vw);
max-height: 20rem;
overflow: auto;
margin-top: .25rem;
padding: .45rem;
border: 1px solid rgba(255, 255, 255, .22);
border-radius: 6px;
background: #1f1f1f;
box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .45);
}
.ml-value-option {
display: flex;
align-items: center;
gap: .4rem;
padding: .25rem .2rem;
}
.ml-value-separator {
border-top: 1px solid rgba(255, 255, 255, .18);
}
.ml-value-separator {
margin: .35rem 0;
}
.ml-criteria-helper {
margin-top: .5rem;
}
.ml-criteria-helper .ml-select {
flex: 1 1 10rem;
}
.ml-criteria-helper .ml-input {
flex: 1 1 8rem;
}
.ml-action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: .65rem .9rem;
}
.ml-action-row {
display: grid;
grid-template-columns: minmax(5.5rem, .65fr) minmax(9rem, 1fr);
align-items: center;
gap: .5rem;
}
.ml-action-source {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin-bottom: .8rem;
}
.ml-action-matrix {
display: flex;
flex-direction: column;
}
.ml-action-category-row {
display: grid;
grid-template-columns: minmax(7rem, 9rem) 1fr;
gap: .9rem;
padding: .9rem 0;
border-top: 1px solid rgba(255, 255, 255, .16);
}
.ml-action-category-row-fallback {
border-top-width: 2px;
}
.ml-action-category-name {
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
}
.ml-action-all-field {
display: block;
margin-top: .75rem;
font-weight: 400;
}
.ml-action-all-box {
width: 100%;
min-height: 2rem;
}
.ml-action-fields {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .55rem;
}
.ml-action-field {
min-width: 0;
}
.ml-action-field-label {
display: block;
margin-bottom: .25rem;
font-size: .9rem;
opacity: .84;
}
.ml-action-box {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .25rem;
min-height: 2.25rem;
padding: .25rem;
border: 1px dashed rgba(255, 255, 255, .34);
border-radius: 6px;
background: rgba(0, 0, 0, .18);
}
.ml-action-box.ml-over {
border-color: rgba(120, 200, 255, .9);
background: rgba(120, 200, 255, .08);
}
.ml-action-token {
display: inline-flex;
align-items: center;
gap: .25rem;
min-height: 1.6rem;
padding: .15rem .38rem;
border: 1px solid rgba(120, 170, 255, .35);
border-radius: 5px;
background: rgba(120, 170, 255, .2);
color: rgba(255, 255, 255, .94);
cursor: grab;
user-select: none;
-webkit-user-select: none;
}
.ml-action-token-source {
padding: .25rem .5rem;
}
.ml-action-token-locked {
opacity: .84;
}
.ml-action-placeholder {
border-style: dashed;
opacity: .55;
}
.ml-action-drag-ghost {
opacity: .88;
pointer-events: none;
}
.ml-cache-entries {
display: flex;
flex-direction: column;
gap: .35rem;
margin-top: .6rem;
}
.ml-subheading {
margin: 1rem 0 .35rem;
font-size: 1rem;
font-weight: 600;
}
.ml-diagnostic-row {
display: flex;
flex-direction: column;
gap: .45rem;
padding: .45rem .55rem;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 6px;
background: rgba(255, 255, 255, .035);
}
.ml-diagnostic-meta {
display: flex;
flex-wrap: wrap;
gap: .35rem;
align-items: center;
}
.ml-diagnostic-chip {
display: inline-flex;
align-items: center;
min-height: 1.45rem;
padding: .12rem .38rem;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 4px;
background: rgba(255, 255, 255, .055);
color: rgba(255, 255, 255, .72);
font-size: .86rem;
}
.ml-diagnostic-chip-strong {
border-color: rgba(120, 170, 255, .35);
background: rgba(120, 170, 255, .14);
color: rgba(255, 255, 255, .94);
}
.ml-cache-url {
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
line-height: 1.35;
}
@media (max-width: 640px) {
.ml-root {
padding: .8rem;
}
.ml-section {
padding-left: .8rem;
padding-right: .6rem;
}
.ml-row {
align-items: stretch;
flex-direction: column;
}
.ml-row > label {
min-width: 0;
}
.ml-action-row {
grid-template-columns: 1fr;
}
.ml-action-category-row {
grid-template-columns: 1fr;
}
.ml-action-fields {
grid-template-columns: 1fr;
}
.ml-classification-rule {
grid-template-columns: 1fr;
}
.ml-classification-controls {
flex-direction: row;
flex-wrap: wrap;
}
.ml-requirement-row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,99 @@
(function () {
const basePath = () => {
const path = window.location.pathname || "";
const marker = "/web/";
const index = path.indexOf(marker);
return index > 0 ? path.slice(0, index).replace(/\/$/, "") : "";
};
function token() {
try {
return window.ApiClient && window.ApiClient.accessToken ? window.ApiClient.accessToken() : "";
} catch (_) {
return "";
}
}
function headers() {
const qs = new URLSearchParams(location.search);
const t = token() || qs.get("token") || qs.get("api_key") || "";
const h = { "Content-Type": "application/json" };
if (t) {
h["X-Emby-Token"] = t;
h["X-MediaBrowser-Token"] = t;
}
return h;
}
async function request(path, options) {
const response = await fetch(`${basePath()}${path}`, {
cache: "no-store",
headers: headers(),
...options
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function splitList(value) {
return String(value || "")
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
function setStatus(el, text, error) {
if (!el) return;
el.textContent = text || "";
el.style.color = error ? "#ff9b9b" : "";
}
function chip(label) {
const el = document.createElement("span");
el.className = "ml-chip";
el.textContent = label;
el.draggable = true;
el.dataset.value = label;
el.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", label);
event.dataTransfer.effectAllowed = "move";
el.classList.add("ml-dragging");
});
el.addEventListener("dragend", () => el.classList.remove("ml-dragging"));
return el;
}
function bucketValues(bucket) {
return [...bucket.querySelectorAll(".ml-chip")].map((el) => el.dataset.value || el.textContent.trim()).filter(Boolean);
}
function renderBucket(bucket, values) {
bucket.replaceChildren(...values.map(chip));
}
function wireBucket(bucket, onDrop) {
bucket.addEventListener("dragover", (event) => {
event.preventDefault();
bucket.classList.add("ml-over");
});
bucket.addEventListener("dragleave", () => bucket.classList.remove("ml-over"));
bucket.addEventListener("drop", (event) => {
event.preventDefault();
bucket.classList.remove("ml-over");
const value = event.dataTransfer.getData("text/plain");
if (value) onDrop(value, bucket);
});
}
window.Multilang = {
get: (path) => request(path),
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
splitList,
setStatus,
chip,
bucketValues,
renderBucket,
wireBucket
};
})();
File diff suppressed because it is too large Load Diff