Initial jellyfin-multilang rewrite
This commit is contained in:
@@ -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
Reference in New Issue
Block a user