753 lines
35 KiB
HTML
753 lines
35 KiB
HTML
<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>
|