Initial jellyfin-multilang rewrite

This commit is contained in:
ajp_anton
2026-05-31 18:28:04 +00:00
commit 63dbc3a6d4
32 changed files with 9326 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
bin/
obj/
publish/
.vs/
.vscode/
__pycache__/
*.user
*.suo
deploy.local.json
# Local planning/deployment/reference files
PROJECT.md
Update.py
old-attempt/
# Generated artifacts
*.pyc
*.sqlite
+138
View File
@@ -0,0 +1,138 @@
# Jellyfin Multilang
Multilang is a Jellyfin plugin that lets different users see different metadata
languages without changing Jellyfin's stored metadata.
The plugin is built for Jellyfin 10.11.9 and is currently early beta software.
## What It Does
Multilang can fetch and display metadata and artwork in chosen languages for:
- Titles
- Overviews
- Taglines
- Posters
- Logos
- Banners
- Thumbnails
- Backdrops
In addition, it can translate genres into the user's Jellyfin display language.
## Admin Features
Admins configure the data that Multilang can use:
- Choose which languages should be exposed for metadata and artwork fetching.
- Configure TMDb and Fanart.tv provider keys.
- Set metadata and artwork provider priority.
- Configure leading articles for sorting, such as `the`, `a`, `an`, or
language-specific equivalents.
- Configure scheduled refresh timing for missing and existing metadata.
- Set thresholds for what requests to cache.
- View cache and recent request diagnostics.
- Enable logging when troubleshooting.
## User Features
Each user can enable or disable Multilang for themselves.
Users can create classification rules that group items into categories. For
example, a user can classify Finnish films, Swedish films, Spanish films, or
movies produced in a specific country.
For each category, the user can choose an ordered fallback list for each field.
For example:
- Use the Finnish title, then Swedish if that isn't available, then Jellyfin's default as the final fallback.
- Use the original title instead of any set language.
- Use a Swedish overview, then English if that isn't available.
- Use an original-language poster, then Jellyfin's default poster if that isn't available.
- Clear a field if no selected language has data.
Title and poster always force Jellyfin as a final fallback so browsing remains
usable.
Users can also choose which language's alphabet and sorting rules should be used
when Multilang sorts item lists.
## How It Works
The admin chooses which languages Multilang should make available. These
languages decide what metadata and artwork the plugin fetches from providers.
Each user then creates their own rules for what languages to display, when to
display them, and which fields should use them. The available choices are:
- Languages selected by the admin.
- The `Original` language according to TMDb.
- Jellyfin's own default metadata and artwork.
Multilang keeps fetched text metadata in its own SQLite database. Downloaded or
proxied artwork is handled by the plugin separately from Jellyfin's normal
metadata storage. Jellyfin's own library metadata is not overwritten.
The plugin changes the Jellyfin web UI by injecting JavaScript into Jellyfin's
main web page. This works in a browser and in the official Jellyfin desktop app,
because both use the Jellyfin web UI. It is not guaranteed to work in clients
that do not use the web UI.
The injected script makes selected UI requests go through Multilang's plugin API
instead of directly to Jellyfin. The plugin fetches the original response from
Jellyfin, applies the current user's rules, and returns a modified response to
the UI.
Some requests can be slow because Multilang may need to ask Jellyfin for a
larger list than the UI will finally display. This is needed so the plugin can
translate, filter, sort, and slice the list using the user's own rules.
Multilang caches slow transformed responses for a configurable time and also
uses a very short burst cache for repeated requests during navigation. Cache
entries are invalidated when affected items are refreshed.
## Original-Language Support
Multilang stores metadata and artwork tagged as `Original`.
This is useful when a user wants the original title and matching original poster
for languages they do not otherwise want as their main metadata language.
## Genres
Jellyfin stores and displays genres by name. If metadata was fetched in English,
the genre may be `Drama`; if it was fetched in Finnish, the genre may be
`Draama`. Jellyfin treats those names as separate genres, and it displays the
stored name regardless of the user's display language.
Multilang avoids this by storing TMDb genre IDs for each item. The ID stays the
same across languages, so the plugin can display the genre name in the user's
current Jellyfin display language when a translation is available.
## Debugging
The plugin includes lightweight admin/debug tools:
- Cache diagnostics
- Recent request diagnostics
- Refresh queue/provider diagnostics
- Per-item debug view showing which category matched and which field action won
These are intended for troubleshooting configuration or missing metadata, not
for normal daily use.
## Current Status
This plugin is usable but still under active testing. The most important areas
to test are:
- Large library browsing
- Sorting and filtering
- Collections
- Shows, seasons, and episodes
- Scheduled scans
- Complex per-user classification rules
## AI Assistance
This codebase was written with substantial help from AI tools, under human
direction and testing.
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "9.0.116",
"rollForward": "latestFeature"
}
}
+27
View File
@@ -0,0 +1,27 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D1AD3B53-6123-4F98-87D5-517B5BF6ADAF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Multilang", "src\Jellyfin.Plugin.Multilang\Jellyfin.Plugin.Multilang.csproj", "{138DB6FA-E217-41B3-A9D1-F24FB724DD44}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{138DB6FA-E217-41B3-A9D1-F24FB724DD44} = {D1AD3B53-6123-4F98-87D5-517B5BF6ADAF}
EndGlobalSection
EndGlobal
File diff suppressed because it is too large Load Diff
@@ -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
@@ -0,0 +1,747 @@
using System.Globalization;
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using Jellyfin.Plugin.Multilang.Services.Providers;
using MediaBrowser.Common.Configuration;
using Microsoft.Data.Sqlite;
namespace Jellyfin.Plugin.Multilang.Data;
public sealed class TranslationStore
{
private readonly string _dbPath;
public TranslationStore(IApplicationPaths appPaths)
{
var dir = Path.Combine(appPaths.DataPath, "multilang");
Directory.CreateDirectory(dir);
Directory.CreateDirectory(Path.Combine(dir, "assets"));
_dbPath = Path.Combine(dir, "translations.sqlite");
Initialize();
}
public static string ToItemId32(Guid id)
=> id.ToString("N", CultureInfo.InvariantCulture).ToLowerInvariant();
public static long NowUnixUtc()
=> DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public static string JsonArray(IEnumerable<string> values)
=> JsonSerializer.Serialize(values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
public SqliteConnection Open()
=> new($"Data Source={_dbPath};Cache=Shared");
private void Initialize()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS facts (
item_id TEXT PRIMARY KEY,
tmdb_id TEXT NOT NULL,
kind TEXT NOT NULL,
original_title TEXT NOT NULL DEFAULT '',
original_language TEXT NOT NULL DEFAULT '',
original_language_all TEXT NOT NULL DEFAULT '',
origin_countries_json TEXT NOT NULL DEFAULT '[]',
production_countries_json TEXT NOT NULL DEFAULT '[]',
spoken_languages_json TEXT NOT NULL DEFAULT '[]',
audio_track_language TEXT NOT NULL DEFAULT '',
genre_tmdb_ids_json TEXT NOT NULL DEFAULT '[]',
missing_checked_at INTEGER NOT NULL DEFAULT 0,
full_checked_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_facts_tmdb ON facts(tmdb_id);
CREATE INDEX IF NOT EXISTS idx_facts_kind ON facts(kind);
CREATE INDEX IF NOT EXISTS idx_facts_missing ON facts(missing_checked_at);
CREATE INDEX IF NOT EXISTS idx_facts_full ON facts(full_checked_at);
CREATE TABLE IF NOT EXISTS translations (
item_id TEXT NOT NULL,
lang TEXT NOT NULL,
field TEXT NOT NULL,
text TEXT NOT NULL,
PRIMARY KEY (item_id, lang, field)
);
CREATE INDEX IF NOT EXISTS idx_translations_item ON translations(item_id);
CREATE TABLE IF NOT EXISTS assets (
item_id TEXT NOT NULL,
lang TEXT NOT NULL,
kind TEXT NOT NULL,
path TEXT NOT NULL,
path_low TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY (item_id, lang, kind)
);
CREATE INDEX IF NOT EXISTS idx_assets_item ON assets(item_id);
CREATE TABLE IF NOT EXISTS genres (
tmdb_id INTEGER NOT NULL,
media TEXT NOT NULL,
lang TEXT NOT NULL,
name TEXT NOT NULL,
name_norm TEXT NOT NULL,
PRIMARY KEY (tmdb_id, media, lang)
);
CREATE INDEX IF NOT EXISTS idx_genres_media_lang ON genres(media, lang);
CREATE INDEX IF NOT EXISTS idx_genres_name ON genres(name_norm, media, lang);
CREATE TABLE IF NOT EXISTS user_rules (
user_id TEXT PRIMARY KEY,
rules_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_display_langs (
user_id TEXT PRIMARY KEY,
langs_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS scan_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_scan_started INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0);
";
cmd.ExecuteNonQuery();
EnsureColumn(con, "assets", "path_low", "TEXT NOT NULL DEFAULT ''");
}
private static void EnsureColumn(SqliteConnection con, string table, string column, string definition)
{
using (var info = con.CreateCommand())
{
info.CommandText = $"PRAGMA table_info({table});";
using var reader = info.ExecuteReader();
while (reader.Read())
{
if (reader.GetString(1).Equals(column, StringComparison.OrdinalIgnoreCase))
return;
}
}
using var alter = con.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {definition};";
alter.ExecuteNonQuery();
}
public HashSet<string> GetTrackedItemIds()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT item_id FROM facts;";
using var reader = cmd.ExecuteReader();
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
ids.Add(reader.GetString(0));
return ids;
}
public void CleanupNotInLibrary(IReadOnlySet<string> liveItemIds)
{
using var con = Open();
con.Open();
using var tx = con.BeginTransaction();
using var select = con.CreateCommand();
select.Transaction = tx;
select.CommandText = "SELECT item_id FROM facts;";
using var reader = select.ExecuteReader();
var stale = new List<string>();
while (reader.Read())
{
var itemId = reader.GetString(0);
if (!liveItemIds.Contains(itemId))
stale.Add(itemId);
}
reader.Close();
foreach (var itemId in stale)
{
using var delete = con.CreateCommand();
delete.Transaction = tx;
delete.CommandText = @"
DELETE FROM facts WHERE item_id = $item_id;
DELETE FROM translations WHERE item_id = $item_id;
DELETE FROM assets WHERE item_id = $item_id;";
delete.Parameters.AddWithValue("$item_id", itemId);
delete.ExecuteNonQuery();
}
tx.Commit();
}
public long GetLastScanStarted()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT last_scan_started FROM scan_state WHERE id = 1;";
return cmd.ExecuteScalar() is long value ? value : 0;
}
public void SetLastScanStarted(long value)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
INSERT INTO scan_state(id, last_scan_started)
VALUES(1, $value)
ON CONFLICT(id) DO UPDATE SET last_scan_started = excluded.last_scan_started;";
cmd.Parameters.AddWithValue("$value", value);
cmd.ExecuteNonQuery();
}
public FactsStatus GetFactsStatus(string itemId)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT missing_checked_at, full_checked_at FROM facts WHERE item_id = $item_id;";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
return reader.Read()
? new FactsStatus(true, reader.GetInt64(0), reader.GetInt64(1))
: new FactsStatus(false, 0, 0);
}
public HashSet<string> GetExistingFactsItemIds(IEnumerable<string> itemIds)
{
var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (ids.Length == 0)
return result;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var idParams = AddParams(cmd, ids, "$id");
cmd.CommandText = $"SELECT item_id FROM facts WHERE item_id IN ({idParams});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
result.Add(reader.GetString(0));
return result;
}
public void UpsertFacts(RefreshItemInfo item, TmdbMetadata metadata, long missingCheckedAt, long fullCheckedAt)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
INSERT INTO facts(
item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at)
VALUES(
$item_id, $tmdb_id, $kind, $original_title, $original_language, $original_language_all,
$origin_countries_json, $production_countries_json, $spoken_languages_json, $audio_track_language,
$genre_tmdb_ids_json, $missing_checked_at, $full_checked_at)
ON CONFLICT(item_id) DO UPDATE SET
tmdb_id = excluded.tmdb_id,
kind = excluded.kind,
original_title = excluded.original_title,
original_language = excluded.original_language,
original_language_all = excluded.original_language_all,
origin_countries_json = excluded.origin_countries_json,
production_countries_json = excluded.production_countries_json,
spoken_languages_json = excluded.spoken_languages_json,
audio_track_language = excluded.audio_track_language,
genre_tmdb_ids_json = excluded.genre_tmdb_ids_json,
missing_checked_at = excluded.missing_checked_at,
full_checked_at = CASE WHEN excluded.full_checked_at > 0 THEN excluded.full_checked_at ELSE facts.full_checked_at END;";
cmd.Parameters.AddWithValue("$item_id", item.ItemId);
cmd.Parameters.AddWithValue("$tmdb_id", item.TmdbId);
cmd.Parameters.AddWithValue("$kind", item.Kind);
cmd.Parameters.AddWithValue("$original_title", metadata.OriginalTitle);
cmd.Parameters.AddWithValue("$original_language", metadata.OriginalLanguage);
cmd.Parameters.AddWithValue("$original_language_all", metadata.OriginalLanguage);
cmd.Parameters.AddWithValue("$origin_countries_json", JsonArray(metadata.OriginCountries));
cmd.Parameters.AddWithValue("$production_countries_json", JsonArray(metadata.ProductionCountries));
cmd.Parameters.AddWithValue("$spoken_languages_json", JsonArray(metadata.SpokenLanguages));
cmd.Parameters.AddWithValue("$audio_track_language", item.AudioLanguage ?? string.Empty);
cmd.Parameters.AddWithValue("$genre_tmdb_ids_json", JsonSerializer.Serialize(metadata.GenreIds));
cmd.Parameters.AddWithValue("$missing_checked_at", missingCheckedAt);
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
cmd.ExecuteNonQuery();
}
public void MarkFactsChecked(RefreshItemInfo item, long missingCheckedAt, long fullCheckedAt)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
INSERT INTO facts(
item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at)
VALUES(
$item_id, $tmdb_id, $kind, '', '', '',
'[]', '[]', '[]', '',
'[]', $missing_checked_at, $full_checked_at)
ON CONFLICT(item_id) DO UPDATE SET
tmdb_id = excluded.tmdb_id,
kind = excluded.kind,
missing_checked_at = excluded.missing_checked_at,
full_checked_at = CASE WHEN excluded.full_checked_at > 0 THEN excluded.full_checked_at ELSE facts.full_checked_at END;";
cmd.Parameters.AddWithValue("$item_id", item.ItemId);
cmd.Parameters.AddWithValue("$tmdb_id", item.TmdbId);
cmd.Parameters.AddWithValue("$kind", item.Kind);
cmd.Parameters.AddWithValue("$missing_checked_at", missingCheckedAt);
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
cmd.ExecuteNonQuery();
}
public FactsData? GetFacts(string itemId)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json,
audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at
FROM facts
WHERE item_id = $item_id;";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
return reader.Read()
? new FactsData(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetString(4),
reader.GetString(5),
reader.GetString(6),
reader.GetString(7),
reader.GetString(8),
reader.GetString(9),
reader.GetString(10),
reader.GetInt64(11),
reader.GetInt64(12))
: null;
}
public Dictionary<string, FactsData> GetFactsForItems(IEnumerable<string> itemIds)
{
var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var result = new Dictionary<string, FactsData>(StringComparer.OrdinalIgnoreCase);
if (ids.Length == 0)
return result;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var idParams = AddParams(cmd, ids, "$id");
cmd.CommandText = $@"
SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json,
audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at
FROM facts
WHERE item_id IN ({idParams});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var facts = new FactsData(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetString(4),
reader.GetString(5),
reader.GetString(6),
reader.GetString(7),
reader.GetString(8),
reader.GetString(9),
reader.GetString(10),
reader.GetInt64(11),
reader.GetInt64(12));
result[facts.ItemId] = facts;
}
return result;
}
public object[] GetTranslationsForDebug(string itemId)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT lang, field, text FROM translations WHERE item_id = $item_id ORDER BY lang, field;";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
var rows = new List<object>();
while (reader.Read())
rows.Add(new { Lang = reader.GetString(0), Field = reader.GetString(1), Text = reader.GetString(2) });
return rows.ToArray();
}
public void UpsertTranslation(string itemId, string lang, string field, string text)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
INSERT INTO translations(item_id, lang, field, text)
VALUES($item_id, $lang, $field, $text)
ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;";
cmd.Parameters.AddWithValue("$item_id", itemId);
cmd.Parameters.AddWithValue("$lang", lang);
cmd.Parameters.AddWithValue("$field", field);
cmd.Parameters.AddWithValue("$text", text);
cmd.ExecuteNonQuery();
}
public void UpsertAsset(string itemId, string lang, string kind, string path, long updatedAt)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at)
VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at)
ON CONFLICT(item_id, lang, kind) DO UPDATE SET
path = excluded.path,
path_low = excluded.path_low,
updated_at = excluded.updated_at;";
cmd.Parameters.AddWithValue("$item_id", itemId);
cmd.Parameters.AddWithValue("$lang", lang);
cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$path", path);
cmd.Parameters.AddWithValue("$path_low", path.ToLowerInvariant());
cmd.Parameters.AddWithValue("$updated_at", updatedAt);
cmd.ExecuteNonQuery();
}
public void UpsertGenre(int tmdbId, string media, string lang, string name)
{
if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name))
return;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
INSERT INTO genres(tmdb_id, media, lang, name, name_norm)
VALUES($tmdb_id, $media, $lang, $name, $name_norm)
ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET
name = excluded.name,
name_norm = excluded.name_norm;";
cmd.Parameters.AddWithValue("$tmdb_id", tmdbId);
cmd.Parameters.AddWithValue("$media", media.Trim().ToLowerInvariant());
cmd.Parameters.AddWithValue("$lang", lang.Trim());
cmd.Parameters.AddWithValue("$name", name.Trim());
cmd.Parameters.AddWithValue("$name_norm", name.Trim().ToLowerInvariant());
cmd.ExecuteNonQuery();
}
public GenreRow[] GetGenres(string media)
{
if (string.IsNullOrWhiteSpace(media))
return [];
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
SELECT tmdb_id, media, lang, name
FROM genres
WHERE media = $media
ORDER BY tmdb_id, lang;";
cmd.Parameters.AddWithValue("$media", media.Trim().ToLowerInvariant());
using var reader = cmd.ExecuteReader();
var rows = new List<GenreRow>();
while (reader.Read())
rows.Add(new GenreRow(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)));
return rows.ToArray();
}
public Dictionary<int, string> GetGenreNames(string media, string lang)
{
if (string.IsNullOrWhiteSpace(media))
return [];
var rows = GetGenres(media)
.GroupBy(r => r.TmdbId)
.Select(g => new { Id = g.Key, Name = PickGenreName(g, lang) })
.Where(g => !string.IsNullOrWhiteSpace(g.Name))
.ToDictionary(g => g.Id, g => g.Name, EqualityComparer<int>.Default);
return rows;
}
private static string PickGenreName(IEnumerable<GenreRow> rows, string lang)
{
var candidates = rows.ToArray();
var requested = (lang ?? string.Empty).Trim();
var requestedBase = requested.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
return candidates.FirstOrDefault(r => r.Lang.Equals(requested, StringComparison.OrdinalIgnoreCase)).Name ??
candidates.FirstOrDefault(r => !string.IsNullOrWhiteSpace(requestedBase) && r.Lang.Equals(requestedBase, StringComparison.OrdinalIgnoreCase)).Name ??
candidates.FirstOrDefault(r => r.Lang.Equals("en-US", StringComparison.OrdinalIgnoreCase)).Name ??
candidates.FirstOrDefault(r => r.Lang.Equals("en", StringComparison.OrdinalIgnoreCase)).Name ??
candidates.FirstOrDefault().Name;
}
public Dictionary<string, Dictionary<string, Dictionary<string, string>>> GetTranslations(
IEnumerable<string> itemIds,
IEnumerable<string> languages)
{
var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var result = new Dictionary<string, Dictionary<string, Dictionary<string, string>>>(StringComparer.OrdinalIgnoreCase);
if (ids.Length == 0 || langs.Length == 0)
return result;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var idParams = AddParams(cmd, ids, "$id");
var langParams = AddParams(cmd, langs, "$lang");
cmd.CommandText = $@"
SELECT item_id, lang, field, text
FROM translations
WHERE item_id IN ({idParams}) AND lang IN ({langParams});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var itemId = reader.GetString(0);
var lang = reader.GetString(1);
var field = reader.GetString(2);
var text = reader.GetString(3);
if (!result.TryGetValue(itemId, out var byLang))
{
byLang = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
result[itemId] = byLang;
}
if (!byLang.TryGetValue(lang, out var byField))
{
byField = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
byLang[lang] = byField;
}
byField[field] = text;
}
return result;
}
public Dictionary<string, Dictionary<string, Dictionary<string, string>>> GetAssets(
IEnumerable<string> itemIds,
IEnumerable<string> languages)
{
var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var result = new Dictionary<string, Dictionary<string, Dictionary<string, string>>>(StringComparer.OrdinalIgnoreCase);
if (ids.Length == 0 || langs.Length == 0)
return result;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var idParams = AddParams(cmd, ids, "$id");
var langParams = AddParams(cmd, langs, "$asset_lang");
cmd.CommandText = $@"
SELECT item_id, kind, lang, path
FROM assets
WHERE item_id IN ({idParams}) AND lang IN ({langParams});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var itemId = reader.GetString(0);
var kind = reader.GetString(1);
var lang = reader.GetString(2);
var path = reader.GetString(3);
if (!result.TryGetValue(itemId, out var byKind))
{
byKind = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
result[itemId] = byKind;
}
if (!byKind.TryGetValue(kind, out var byLang))
{
byLang = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
byKind[kind] = byLang;
}
byLang[lang] = path;
}
return result;
}
public void SaveUserRules(string userId, UserRulesDocument rules)
{
using var con = Open();
con.Open();
using var tx = con.BeginTransaction();
using (var cmd = con.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO user_rules(user_id, rules_json, updated_at)
VALUES($user_id, $rules_json, $updated_at)
ON CONFLICT(user_id) DO UPDATE SET rules_json = excluded.rules_json, updated_at = excluded.updated_at;";
cmd.Parameters.AddWithValue("$user_id", userId);
cmd.Parameters.AddWithValue("$rules_json", JsonSerializer.Serialize(rules));
cmd.Parameters.AddWithValue("$updated_at", NowUnixUtc());
cmd.ExecuteNonQuery();
}
using (var cmd = con.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO user_display_langs(user_id, langs_json, updated_at)
VALUES($user_id, $langs_json, $updated_at)
ON CONFLICT(user_id) DO UPDATE SET langs_json = excluded.langs_json, updated_at = excluded.updated_at;";
cmd.Parameters.AddWithValue("$user_id", userId);
cmd.Parameters.AddWithValue("$langs_json", JsonSerializer.Serialize(rules.FallbackLanguages));
cmd.Parameters.AddWithValue("$updated_at", NowUnixUtc());
cmd.ExecuteNonQuery();
}
tx.Commit();
}
public UserRulesDocument? GetUserRules(string userId)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT rules_json FROM user_rules WHERE user_id = $user_id;";
cmd.Parameters.AddWithValue("$user_id", userId);
var json = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<UserRulesDocument>(json);
}
private static string AddParams(SqliteCommand cmd, IReadOnlyList<string> values, string prefix)
{
var names = new string[values.Count];
for (var i = 0; i < values.Count; i++)
{
var name = $"{prefix}{i}";
names[i] = name;
cmd.Parameters.AddWithValue(name, values[i]);
}
return string.Join(",", names);
}
}
public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt);
public readonly record struct GenreRow(int TmdbId, string Media, string Lang, string Name);
public sealed record FactsData(
string ItemId,
string TmdbId,
string Kind,
string OriginalTitle,
string OriginalLanguage,
string OriginalLanguageAll,
string OriginCountriesJson,
string ProductionCountriesJson,
string SpokenLanguagesJson,
string AudioTrackLanguage,
string GenreTmdbIdsJson,
long MissingCheckedAt,
long FullCheckedAt);
public sealed class UserRulesDocument
{
public bool Enabled { get; set; } = true;
public string SortLocale { get; set; } = "Auto";
public string[] FallbackLanguages { get; set; } = [];
public Dictionary<string, string[]> FallbackFieldActions { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
["title"] = ["Jellyfin"],
["overview"] = ["Jellyfin"],
["tagline"] = ["Jellyfin"],
["poster"] = ["Jellyfin"],
["logo"] = ["Jellyfin"],
["banner"] = ["Jellyfin"],
["thumb"] = ["Jellyfin"],
["backdrop"] = ["Jellyfin"]
};
public bool TrustTmdbCollections { get; set; } = true;
public UserCategoryRule[] Categories { get; set; } = [];
public UserRuleEntry[] Rules { get; set; } = [];
}
public sealed class UserRuleEntry
{
public string Text { get; set; } = string.Empty;
public string[] Languages { get; set; } = [];
public string[] Scopes { get; set; } = [];
}
public sealed class UserCategoryRule
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Label { get; set; } = string.Empty;
public string CriteriaText { get; set; } = string.Empty;
public UserCategoryRequirement[] Requirements { get; set; } = [];
public bool MatchAllConditions { get; set; } = true;
public string[] Scopes { get; set; } = ["M", "S", "C"];
public Dictionary<string, string> FieldActions { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
["title"] = "Fallback",
["overview"] = "Fallback",
["tagline"] = "Fallback",
["poster"] = "Fallback",
["logo"] = "Fallback",
["banner"] = "Fallback",
["thumb"] = "Fallback",
["backdrop"] = "Fallback"
};
public Dictionary<string, string[]> FieldActionLists { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
["title"] = ["Jellyfin"],
["overview"] = ["Jellyfin"],
["tagline"] = ["Jellyfin"],
["poster"] = ["Jellyfin"],
["logo"] = ["Jellyfin"],
["banner"] = ["Jellyfin"],
["thumb"] = ["Jellyfin"],
["backdrop"] = ["Jellyfin"]
};
}
public sealed class UserCategoryRequirement
{
public string Field { get; set; } = string.Empty;
public string[] Fields { get; set; } = [];
public bool UseFieldOr { get; set; }
public string Relation { get; set; } = string.Empty;
public bool UseOr { get; set; }
public string[] Values { get; set; } = [];
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Jellyfin.Plugin.Multilang</RootNamespace>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion>
<Authors>ajp_anton</Authors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.9">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.9">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\userRulesPage.html" />
<EmbeddedResource Include="Configuration\debugPage.html" />
<EmbeddedResource Include="Configuration\shared.css" />
<EmbeddedResource Include="Configuration\shared.js" />
<EmbeddedResource Include="wwwroot\inject.js">
<LogicalName>Jellyfin.Plugin.Multilang.inject.js</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
using System.Globalization;
using Jellyfin.Plugin.Multilang.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.Multilang;
public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public static Plugin? Instance { get; private set; }
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public override string Name => "Multilang";
public override Guid Id => Guid.Parse("9d7c61a5-7a1b-4d1e-9b79-4c8c6d2e8a2a");
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = "Multilang",
DisplayName = "Multilang",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.configPage.html",
GetType().Namespace),
EnableInMainMenu = true,
MenuSection = "plugins",
MenuIcon = "translate"
};
yield return new PluginPageInfo
{
Name = "MultilangUser",
DisplayName = "Multilang User Rules",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.userRulesPage.html",
GetType().Namespace)
};
}
}
@@ -0,0 +1,32 @@
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.ScheduledTasks;
using Jellyfin.Plugin.Multilang.Services;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Multilang;
public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
{
services.AddMvc().AddApplicationPart(typeof(Plugin).Assembly);
services.AddSingleton<TranslationStore>();
services.AddSingleton<ProviderCatalog>();
services.AddSingleton<SortArticleCatalog>();
services.AddSingleton<TmdbClient>();
services.AddSingleton<FanartClient>();
services.AddSingleton<RefreshCoordinator>();
services.AddSingleton<RefreshService>();
services.AddSingleton<ItemsProxyCache>();
services.AddSingleton<IScheduledTask, InjectStartupTask>();
services.AddSingleton<IScheduledTask, RefreshMissingTask>();
services.AddSingleton<IScheduledTask, RefreshFullTask>();
}
}
@@ -0,0 +1,328 @@
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Data;
namespace Jellyfin.Plugin.Multilang.Rules;
public sealed record CategoryRuleTrace(
string Id,
string Label,
string Kind,
string[] Scopes,
bool ScopeMatches,
bool MatchAllConditions,
bool UsesStructuredRequirements,
bool Result,
RequirementTrace[] Requirements,
string CriteriaText);
public sealed record RequirementTrace(
string[] Fields,
bool UseFieldOr,
string Relation,
bool UseOr,
string[] Expected,
bool Result,
RequirementFieldTrace[] FieldResults);
public sealed record RequirementFieldTrace(
string Field,
string[] Actual,
bool Result);
public static class CategoryRuleEvaluator
{
public static bool Matches(UserCategoryRule category, FactsData facts)
{
return Trace(category, facts).Result;
}
public static CategoryRuleTrace Trace(UserCategoryRule category, FactsData facts)
{
var scopeMatches = ScopeMatches(category, facts.Kind);
if (!scopeMatches)
{
return new CategoryRuleTrace(
category.Id,
category.Label,
facts.Kind,
category.Scopes ?? [],
false,
category.MatchAllConditions,
category.Requirements is { Length: > 0 },
false,
[],
category.CriteriaText ?? string.Empty);
}
if (category.Requirements is { Length: > 0 })
{
var requirements = category.Requirements.Select(requirement => TraceRequirement(requirement, facts)).ToArray();
var result = category.MatchAllConditions
? requirements.All(requirement => requirement.Result)
: requirements.Any(requirement => requirement.Result);
return new CategoryRuleTrace(
category.Id,
category.Label,
facts.Kind,
category.Scopes ?? [],
true,
category.MatchAllConditions,
true,
result,
requirements,
category.CriteriaText ?? string.Empty);
}
var criteria = (category.CriteriaText ?? string.Empty).Trim();
var criteriaResult = criteria.Length > 0 && EvaluateCriteria(criteria, facts);
return new CategoryRuleTrace(
category.Id,
category.Label,
facts.Kind,
category.Scopes ?? [],
true,
category.MatchAllConditions,
false,
criteriaResult,
[],
criteria);
}
public static string[] ParseJsonStringArray(string json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static bool ScopeMatches(UserCategoryRule category, string kind)
{
var scopes = category.Scopes ?? [];
if (scopes.Length == 0)
return true;
var wanted = kind switch
{
"movie" => "M",
"collection" => "C",
"tv" or "tvseason" or "tvepisode" => "S",
_ => string.Empty
};
return scopes.Contains(wanted, StringComparer.OrdinalIgnoreCase);
}
private static bool EvaluateCriteria(string criteria, FactsData facts)
{
var orGroups = SplitByOperator(criteria, "or");
foreach (var group in orGroups)
{
var terms = SplitByOperator(group, "and");
if (terms.All(term => EvaluateTerm(term, facts)))
return true;
}
return false;
}
private static bool EvaluateTerm(string term, FactsData facts)
{
term = term.Trim();
if (term.StartsWith("not ", StringComparison.OrdinalIgnoreCase))
return !EvaluateTerm(term[4..], facts);
var relation = FindRelation(term);
if (relation is null)
return false;
var field = term[..relation.Value.Index].Trim();
var rawValue = term[(relation.Value.Index + relation.Value.Token.Length)..].Trim().Trim('"', '\'');
var result = relation.Value.Token switch
{
"contains" => FieldValues(field, facts, all: true).Any(v => ValueMatches(v, rawValue, IsLanguageField(field))),
"not_contains" => !FieldValues(field, facts, all: true).Any(v => ValueMatches(v, rawValue, IsLanguageField(field))),
"is" => FieldValues(field, facts, all: false) is { Length: 1 } values && ValueMatches(values[0], rawValue, IsLanguageField(field)),
"is_not" => !(FieldValues(field, facts, all: false) is { Length: 1 } values && ValueMatches(values[0], rawValue, IsLanguageField(field))),
_ => false
};
return result;
}
private static bool EvaluateRequirement(UserCategoryRequirement requirement, FactsData facts)
=> TraceRequirement(requirement, facts).Result;
private static RequirementTrace TraceRequirement(UserCategoryRequirement requirement, FactsData facts)
{
var fields = RequirementFields(requirement);
var relation = (requirement.Relation ?? string.Empty).Trim();
var expected = (requirement.Values ?? [])
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (fields.Length == 0 || relation.Length == 0 || expected.Length == 0)
{
return new RequirementTrace(
fields,
requirement.UseFieldOr,
relation,
requirement.UseOr,
expected,
false,
[]);
}
var fieldResults = fields
.Select(field => TraceRequirementField(field, relation, expected, requirement.UseOr, facts))
.ToArray();
var result = requirement.UseFieldOr
? fieldResults.Any(field => field.Result)
: fieldResults.All(field => field.Result);
return new RequirementTrace(
fields,
requirement.UseFieldOr,
relation,
requirement.UseOr,
expected,
result,
fieldResults);
}
private static bool EvaluateRequirementField(string field, string relation, string[] expected, bool useOr, FactsData facts)
=> TraceRequirementField(field, relation, expected, useOr, facts).Result;
private static RequirementFieldTrace TraceRequirementField(string field, string relation, string[] expected, bool useOr, FactsData facts)
{
var actual = FieldValues(field, facts, all: true)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var result = relation switch
{
"is" => RequirementIs(actual, expected, useOr),
"is_not" => !RequirementIs(actual, expected, useOr),
"contains" => RequirementContains(actual, expected, useOr),
"not_contains" => !RequirementContains(actual, expected, useOr),
_ => false
};
return new RequirementFieldTrace(field, actual, result);
}
private static string[] RequirementFields(UserCategoryRequirement requirement)
{
var fields = (requirement.Fields ?? [])
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (fields.Length > 0)
return fields;
var field = (requirement.Field ?? string.Empty).Trim();
return field.Length == 0 ? [] : [field];
}
private static bool RequirementIs(string[] actual, string[] expected, bool useOr)
{
if (expected.Length == 1)
return actual.Length == 1 && EqualsValue(actual[0], expected[0]);
if (useOr)
return actual.Length == 1 && expected.Any(value => EqualsValue(actual[0], value));
return actual.Length == expected.Length &&
expected.All(expectedValue => actual.Any(actualValue => EqualsValue(actualValue, expectedValue)));
}
private static bool RequirementContains(string[] actual, string[] expected, bool useOr)
{
if (expected.Length == 1)
return actual.Any(actualValue => EqualsValue(actualValue, expected[0]));
return useOr
? expected.Any(expectedValue => actual.Any(actualValue => EqualsValue(actualValue, expectedValue)))
: expected.All(expectedValue => actual.Any(actualValue => EqualsValue(actualValue, expectedValue)));
}
private static bool EqualsValue(string actual, string expected)
=> string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
private static (string Token, int Index)? FindRelation(string term)
{
foreach (var token in new[] { "not_contains", "contains", "is_not", "is" })
{
var needle = " " + token + " ";
var index = term.IndexOf(needle, StringComparison.OrdinalIgnoreCase);
if (index >= 0)
return (token, index + 1);
}
return null;
}
private static string[] FieldValues(string field, FactsData facts, bool all)
{
field = field.Trim().ToLowerInvariant();
return field switch
{
"origin_countries" => ParseJsonStringArray(facts.OriginCountriesJson),
"production_countries" => ParseJsonStringArray(facts.ProductionCountriesJson),
"spoken_languages" => ParseJsonStringArray(facts.SpokenLanguagesJson),
"original_language" => string.IsNullOrWhiteSpace(facts.OriginalLanguage) ? [] : [facts.OriginalLanguage],
"audio_language" => string.IsNullOrWhiteSpace(facts.AudioTrackLanguage) ? [] : [facts.AudioTrackLanguage],
_ => []
};
}
private static bool IsLanguageField(string field)
=> field.Equals("spoken_languages", StringComparison.OrdinalIgnoreCase) ||
field.Equals("original_language", StringComparison.OrdinalIgnoreCase) ||
field.Equals("audio_language", StringComparison.OrdinalIgnoreCase);
private static bool ValueMatches(string actual, string expected, bool language)
{
if (expected.Equals("empty", StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(actual);
if (!language)
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
if (expected.Contains('-', StringComparison.Ordinal))
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) ||
actual.StartsWith(expected + "-", StringComparison.OrdinalIgnoreCase);
}
private static string[] SplitByOperator(string text, string op)
{
var parts = new List<string>();
var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var current = new List<string>();
foreach (var token in tokens)
{
if (token.Equals(op, StringComparison.OrdinalIgnoreCase))
{
parts.Add(string.Join(' ', current));
current.Clear();
}
else
{
current.Add(token);
}
}
if (current.Count > 0)
parts.Add(string.Join(' ', current));
return parts.Where(p => p.Trim().Length > 0).ToArray();
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using Jellyfin.Plugin.Multilang.Services.Injection;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.ScheduledTasks;
public sealed class InjectStartupTask : IScheduledTask, IConfigurableScheduledTask
{
private readonly IApplicationPaths _appPaths;
private readonly ILogger<InjectStartupTask> _logger;
public InjectStartupTask(IApplicationPaths appPaths, ILogger<InjectStartupTask> logger)
{
_appPaths = appPaths;
_logger = logger;
}
public string Name => "Multilang: Inject Web Script";
public string Key => "MultilangInjectWebScript";
public string Description => "Injects Multilang script into the Jellyfin web UI.";
public string Category => "Multilang";
public bool IsHidden => true;
public bool IsEnabled => true;
public bool IsLogged => false;
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
=> [new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger }];
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var assembly = Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version?.ToString() ?? "0";
var stamp = !string.IsNullOrWhiteSpace(assembly.Location) && File.Exists(assembly.Location)
? File.GetLastWriteTimeUtc(assembly.Location).Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)
: version;
var scriptTag = $"<script defer src=\"../Multilang/inject.js?v={Uri.EscapeDataString(version + "-" + stamp)}\"></script>";
WebScriptInjector.EnsureInjected(_appPaths.WebPath, scriptTag, _logger);
progress.Report(100);
return Task.CompletedTask;
}
}
@@ -0,0 +1,28 @@
using Jellyfin.Plugin.Multilang.Services.Refresh;
using MediaBrowser.Model.Tasks;
namespace Jellyfin.Plugin.Multilang.ScheduledTasks;
public sealed class RefreshFullTask : IScheduledTask, IConfigurableScheduledTask
{
private readonly RefreshService _refreshService;
public RefreshFullTask(RefreshService refreshService)
{
_refreshService = refreshService;
}
public string Name => "Multilang: Full Refresh";
public string Key => "MultilangRewriteRefreshFull";
public string Description => "Refreshes multilingual metadata and facts using configured providers and languages.";
public string Category => "Multilang";
public bool IsHidden => false;
public bool IsEnabled => true;
public bool IsLogged => true;
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
=> [new TaskTriggerInfo { Type = TaskTriggerInfoType.WeeklyTrigger, DayOfWeek = DayOfWeek.Sunday, TimeOfDayTicks = TimeSpan.FromHours(4).Ticks }];
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
=> _refreshService.RunScanAsync(RefreshJobType.Full, progress, cancellationToken, "scheduled");
}
@@ -0,0 +1,28 @@
using Jellyfin.Plugin.Multilang.Services.Refresh;
using MediaBrowser.Model.Tasks;
namespace Jellyfin.Plugin.Multilang.ScheduledTasks;
public sealed class RefreshMissingTask : IScheduledTask, IConfigurableScheduledTask
{
private readonly RefreshService _refreshService;
public RefreshMissingTask(RefreshService refreshService)
{
_refreshService = refreshService;
}
public string Name => "Multilang: Refresh Missing Data";
public string Key => "MultilangRewriteRefreshMissing";
public string Description => "Fetches missing multilingual metadata using configured providers and languages.";
public string Category => "Multilang";
public bool IsHidden => false;
public bool IsEnabled => true;
public bool IsLogged => true;
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
=> [new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(6).Ticks }];
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
=> _refreshService.RunScanAsync(RefreshJobType.Missing, progress, cancellationToken, "scheduled");
}
@@ -0,0 +1,51 @@
using System.Text;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.Services.Injection;
internal static class WebScriptInjector
{
private const string StartComment = "<!-- BEGIN Multilang Plugin -->";
private const string EndComment = "<!-- END Multilang Plugin -->";
public static bool EnsureInjected(string webRootPath, string scriptTag, ILogger logger)
{
if (string.IsNullOrWhiteSpace(webRootPath))
return false;
var indexPath = Path.Combine(webRootPath, "index.html");
if (!File.Exists(indexPath))
{
logger.LogWarning("Multilang inject: index.html not found at {Path}", indexPath);
return false;
}
var content = File.ReadAllText(indexPath);
var block = $"{StartComment}\n{scriptTag}\n{EndComment}\n";
if (content.Contains(block, StringComparison.Ordinal))
return false;
content = RemoveOldBlock(content);
var insertAt = content.IndexOf("</body>", StringComparison.OrdinalIgnoreCase);
if (insertAt < 0)
{
logger.LogWarning("Multilang inject: </body> tag not found in index.html");
return false;
}
File.WriteAllText(indexPath, content.Insert(insertAt, block + Environment.NewLine), Encoding.UTF8);
logger.LogInformation("Multilang inject: updated index.html");
return true;
}
private static string RemoveOldBlock(string content)
{
var start = content.IndexOf(StartComment, StringComparison.Ordinal);
if (start < 0)
return content;
var end = content.IndexOf(EndComment, start, StringComparison.Ordinal);
if (end < 0)
return content;
return content.Remove(start, end + EndComment.Length - start);
}
}
@@ -0,0 +1,309 @@
namespace Jellyfin.Plugin.Multilang.Services;
public sealed class ItemsProxyCache
{
private sealed class Entry
{
public required string Key { get; init; }
public required string UserId { get; init; }
public required string Url { get; init; }
public required HashSet<string> ItemIds { get; init; }
public required string Body { get; init; }
public required string ContentType { get; init; }
public required DateTimeOffset CreatedUtc { get; init; }
public required long DurationMs { get; init; }
public required long SizeBytes { get; init; }
}
private sealed class RecentRequest
{
public required string Url { get; init; }
public required DateTimeOffset CreatedUtc { get; init; }
public required bool CacheHit { get; init; }
public required bool CacheStored { get; init; }
public required int StatusCode { get; init; }
public required int ItemCount { get; init; }
public required long TotalMs { get; init; }
public required long UpstreamMs { get; init; }
public required long TransformMs { get; init; }
public required long SizeBytes { get; init; }
}
private readonly object _lock = new();
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly Dictionary<string, Entry> _microEntries = new(StringComparer.Ordinal);
private readonly Queue<RecentRequest> _recentRequests = new();
private long _totalBytes;
private const int MaxRecentRequests = 100;
private const int MaxMicroEntries = 50;
private static readonly TimeSpan MicroTtl = TimeSpan.FromSeconds(3);
public void ClearAll()
{
lock (_lock)
{
_entries.Clear();
_microEntries.Clear();
_totalBytes = 0;
}
}
public void InvalidateItems(IEnumerable<string> itemIds)
{
var ids = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (ids.Count == 0)
return;
lock (_lock)
{
foreach (var key in _entries.Values
.Where(e => e.ItemIds.Overlaps(ids))
.Select(e => e.Key)
.ToArray())
{
Remove(key);
}
foreach (var key in _microEntries.Values
.Where(e => e.ItemIds.Overlaps(ids))
.Select(e => e.Key)
.ToArray())
{
_microEntries.Remove(key);
}
}
}
public bool TryGet(string key, TimeSpan ttl, out CachedItemsProxyResponse response)
{
lock (_lock)
{
PurgeExpired(ttl);
if (!_entries.TryGetValue(key, out var entry))
{
PurgeExpired(_microEntries, MicroTtl);
if (!_microEntries.TryGetValue(key, out var microEntry))
{
response = default;
return false;
}
response = new CachedItemsProxyResponse(microEntry.Body, microEntry.ContentType, microEntry.ItemIds.Count, microEntry.SizeBytes);
return true;
}
response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemIds.Count, entry.SizeBytes);
return true;
}
}
public bool Store(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes, TimeSpan ttl)
{
if (maxBytes <= 0 || ttl <= TimeSpan.Zero)
return false;
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
if (sizeBytes > maxBytes)
return false;
lock (_lock)
{
PurgeExpired(ttl);
Remove(key);
while (_totalBytes + sizeBytes > maxBytes && _entries.Count > 0)
{
var victim = _entries.Values.OrderBy(e => e.CreatedUtc).First();
Remove(victim.Key);
}
if (_totalBytes + sizeBytes > maxBytes)
return false;
_entries[key] = new Entry
{
Key = key,
UserId = userId,
Url = url,
ItemIds = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase),
Body = body,
ContentType = contentType,
CreatedUtc = DateTimeOffset.UtcNow,
DurationMs = durationMs,
SizeBytes = sizeBytes
};
_totalBytes += sizeBytes;
return true;
}
}
public bool StoreMicro(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes)
{
if (maxBytes <= 0)
return false;
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
if (sizeBytes > maxBytes)
return false;
lock (_lock)
{
PurgeExpired(_microEntries, MicroTtl);
_microEntries.Remove(key);
while (_microEntries.Count >= MaxMicroEntries)
{
var victim = _microEntries.Values.OrderBy(e => e.CreatedUtc).First();
_microEntries.Remove(victim.Key);
}
_microEntries[key] = new Entry
{
Key = key,
UserId = userId,
Url = url,
ItemIds = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase),
Body = body,
ContentType = contentType,
CreatedUtc = DateTimeOffset.UtcNow,
DurationMs = durationMs,
SizeBytes = sizeBytes
};
return true;
}
}
public object[] GetEntries()
{
lock (_lock)
{
var now = DateTimeOffset.UtcNow;
return _entries.Values
.OrderByDescending(e => e.CreatedUtc)
.Select(e => new
{
e.UserId,
e.Url,
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.DurationMs,
e.SizeBytes,
ItemCount = e.ItemIds.Count
})
.Cast<object>()
.ToArray();
}
}
public void RecordRequest(
string url,
bool cacheHit,
bool cacheStored,
int statusCode,
int itemCount,
long totalMs,
long upstreamMs,
long transformMs,
long sizeBytes)
{
lock (_lock)
{
_recentRequests.Enqueue(new RecentRequest
{
Url = url,
CreatedUtc = DateTimeOffset.UtcNow,
CacheHit = cacheHit,
CacheStored = cacheStored,
StatusCode = statusCode,
ItemCount = itemCount,
TotalMs = totalMs,
UpstreamMs = upstreamMs,
TransformMs = transformMs,
SizeBytes = sizeBytes
});
while (_recentRequests.Count > MaxRecentRequests)
_recentRequests.Dequeue();
}
}
public object[] GetRecentRequests()
{
lock (_lock)
{
var now = DateTimeOffset.UtcNow;
return _recentRequests
.Reverse()
.Select(e => new
{
e.Url,
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.CacheHit,
e.CacheStored,
e.StatusCode,
e.ItemCount,
e.TotalMs,
e.UpstreamMs,
e.TransformMs,
e.SizeBytes
})
.Cast<object>()
.ToArray();
}
}
private void PurgeExpired(TimeSpan ttl)
{
if (ttl <= TimeSpan.Zero || _entries.Count == 0)
return;
var now = DateTimeOffset.UtcNow;
foreach (var key in _entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray())
Remove(key);
}
private static void PurgeExpired(Dictionary<string, Entry> entries, TimeSpan ttl)
{
if (ttl <= TimeSpan.Zero || entries.Count == 0)
return;
var now = DateTimeOffset.UtcNow;
foreach (var key in entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray())
entries.Remove(key);
}
private void Remove(string key)
{
if (!_entries.Remove(key, out var existing))
return;
_totalBytes = Math.Max(0, _totalBytes - existing.SizeBytes);
}
}
public readonly record struct CachedItemsProxyResponse(string Body, string ContentType, int ItemCount, long SizeBytes);
@@ -0,0 +1,246 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
namespace Jellyfin.Plugin.Multilang.Services;
public sealed record ItemsProxyControls(
string SortBy,
string SortOrder,
string NameStartsWith,
int StartIndex,
int Limit,
string ClientLocale,
string GenreIds);
public static class ItemsProxySorting
{
private readonly record struct SortSpec(string Field, SortKind Kind);
private enum SortKind
{
Text,
Number,
Date,
SortName,
SeriesSortName,
Random
}
public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles)
{
if (!TryGetItemsArray(root, out var itemsArray))
return;
var items = itemsArray.OfType<JsonObject>().ToList();
var specs = BuildSortSpecs(controls.SortBy);
if (specs.Length > 0 && items.Count > 1)
items = Sort(items, specs, controls.SortOrder, culture, titleArticles);
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticles);
var filteredCount = items.Count;
items = Slice(items, controls.StartIndex, controls.Limit);
itemsArray.Clear();
foreach (var item in items)
itemsArray.Add(item);
if (root is JsonObject obj && obj.ContainsKey("TotalRecordCount"))
{
obj["TotalRecordCount"] = filteredCount;
obj["StartIndex"] = Math.Clamp(controls.StartIndex, 0, filteredCount);
}
}
private static bool TryGetItemsArray(JsonNode root, out JsonArray items)
{
if (root is JsonObject obj && obj["Items"] is JsonArray array)
{
items = array;
return true;
}
if (root is JsonArray direct)
{
items = direct;
return true;
}
items = [];
return false;
}
private static SortSpec[] BuildSortSpecs(string sortBy)
{
var fields = (sortBy ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(f => f.Length > 0)
.ToList();
if (fields.Count == 0)
return [];
if (!fields.Any(f => f.Equals("SortName", StringComparison.OrdinalIgnoreCase)))
fields.Add("SortName");
if (!fields.Any(f => f.Equals("ProductionYear", StringComparison.OrdinalIgnoreCase)))
fields.Add("ProductionYear");
return fields.Select(f => new SortSpec(f, KindFor(f))).ToArray();
}
private static SortKind KindFor(string field)
=> field.ToLowerInvariant() switch
{
"sortname" => SortKind.SortName,
"seriessortname" => SortKind.SeriesSortName,
"random" => SortKind.Random,
"isfolder" or "productionyear" or "playcount" or "communityrating" or "criticrating" or "runtimeticks" or "runtime" or "indexnumber" or "parentindexnumber" => SortKind.Number,
"premieredate" or "datecreated" or "dateplayed" => SortKind.Date,
_ => SortKind.Text
};
private static List<JsonObject> Sort(List<JsonObject> items, SortSpec[] specs, string sortOrder, CultureInfo culture, string[] titleArticles)
{
var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true;
var comparer = StringComparer.Create(culture, true);
var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticles)).ToArray() }).ToList();
values.Sort((left, right) =>
{
for (var i = 0; i < specs.Length; i++)
{
var a = left.Values[i];
var b = right.Values[i];
if (!a.HasValue && !b.HasValue)
continue;
if (!a.HasValue)
return descending ? 1 : -1;
if (!b.HasValue)
return descending ? -1 : 1;
var cmp = specs[i].Kind is SortKind.Text or SortKind.SortName or SortKind.SeriesSortName
? comparer.Compare(a.Text, b.Text)
: a.Number.CompareTo(b.Number);
if (cmp != 0)
return descending ? -cmp : cmp;
}
return 0;
});
return values.Select(v => v.Item).ToList();
}
private static SortValue ValueFor(JsonObject item, SortSpec spec, CultureInfo culture, string[] titleArticles)
{
return spec.Kind switch
{
SortKind.Random => new SortValue(true, string.Empty, Random.Shared.NextDouble()),
SortKind.SortName => new SortValue(true, NormalizeSortText(GetString(item, "Name"), titleArticles, culture), 0),
SortKind.SeriesSortName => new SortValue(true, NormalizeSortText(GetString(item, "SeriesName"), titleArticles, culture), 0),
SortKind.Number => TryGetNumber(item, spec.Field, out var number) ? new SortValue(true, string.Empty, number) : default,
SortKind.Date => TryGetDate(item, spec.Field, out var ticks) ? new SortValue(true, string.Empty, ticks) : default,
_ => !string.IsNullOrWhiteSpace(GetString(item, spec.Field)) ? new SortValue(true, GetString(item, spec.Field), 0) : default
};
}
private readonly record struct SortValue(bool HasValue, string Text, double Number);
private static List<JsonObject> FilterByNameStartsWith(List<JsonObject> items, string prefix, CultureInfo culture, string[] titleArticles)
{
prefix = (prefix ?? string.Empty).Trim();
if (prefix.Length == 0)
return items;
return items
.Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticles), prefix, CompareOptions.IgnoreCase))
.ToList();
}
private static List<JsonObject> Slice(List<JsonObject> items, int startIndex, int limit)
{
startIndex = Math.Clamp(startIndex, 0, items.Count);
if (limit > 0)
return items.Skip(startIndex).Take(limit).ToList();
return startIndex > 0 ? items.Skip(startIndex).ToList() : items;
}
private static string NormalizeSortText(string value, string[] titleArticles, CultureInfo culture)
=> PadNumberSequences(StripArticle(value, titleArticles)).ToLower(culture);
private static string StripArticle(string value, string[] titleArticles)
{
var text = (value ?? string.Empty).Trim();
foreach (var article in titleArticles)
{
var word = article.Trim();
if (word.Length == 0 || text.Length <= word.Length)
continue;
if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase))
return text[(word.Length + 1)..].TrimStart();
}
return text;
}
private static string PadNumberSequences(string input)
{
if (!input.Any(char.IsDigit))
return input;
var sb = new StringBuilder(input.Length + 8);
for (var i = 0; i < input.Length;)
{
if (!char.IsDigit(input[i]))
{
sb.Append(input[i++]);
continue;
}
var start = i;
while (i < input.Length && char.IsDigit(input[i]))
i++;
var len = i - start;
if (len < 10)
sb.Append('0', 10 - len);
sb.Append(input, start, len);
}
return sb.ToString();
}
private static string GetString(JsonObject item, string property)
=> item.TryGetPropertyValue(property, out var node) && node is not null ? node.GetValueKind() == System.Text.Json.JsonValueKind.String ? node.GetValue<string>() : node.ToString() : string.Empty;
private static bool TryGetNumber(JsonObject item, string property, out double value)
{
value = 0;
if (!item.TryGetPropertyValue(property, out var node) || node is null)
return false;
if (node is JsonValue jsonValue)
{
if (jsonValue.TryGetValue<double>(out value))
return true;
if (jsonValue.TryGetValue<bool>(out var b))
{
value = b ? 1 : 0;
return true;
}
if (jsonValue.TryGetValue<string>(out var s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value))
return true;
}
return double.TryParse(node.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out value);
}
private static bool TryGetDate(JsonObject item, string property, out double value)
{
value = 0;
var raw = GetString(item, property);
if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date))
return false;
value = date.UtcTicks;
return true;
}
}
@@ -0,0 +1,25 @@
namespace Jellyfin.Plugin.Multilang.Services;
public sealed record ProviderInfo(
string Id,
string Name,
bool SupportsMetadata,
bool SupportsArtwork,
string ApiKeyField,
string ApiKeyPlaceholder);
public sealed class ProviderCatalog
{
private static readonly ProviderInfo[] Items =
[
new("tmdb", "TMDb", SupportsMetadata: true, SupportsArtwork: true, "TmdbApiKey", "Mandatory API key"),
new("fanart", "Fanart.tv", SupportsMetadata: false, SupportsArtwork: true, "FanartApiKey", "Optional API key")
];
private readonly Dictionary<string, ProviderInfo> _byId =
Items.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ProviderInfo> Providers => Items;
public bool IsKnown(string id) => _byId.ContainsKey(id);
}
@@ -0,0 +1,100 @@
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public sealed class FanartClient
{
private readonly IHttpClientFactory _httpClientFactory;
public FanartClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<bool> TestApiKeyAsync(string apiKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(apiKey))
return false;
var http = _httpClientFactory.CreateClient();
var url = $"https://webservice.fanart.tv/v3/movies/550?api_key={Uri.EscapeDataString(apiKey)}";
using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
public async Task<FanartImages?> FetchMovieImagesAsync(
string tmdbId,
string apiKey,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tmdbId) || string.IsNullOrWhiteSpace(apiKey))
return null;
var http = _httpClientFactory.CreateClient();
var url = $"https://webservice.fanart.tv/v3/movies/{Uri.EscapeDataString(tmdbId)}?api_key={Uri.EscapeDataString(apiKey)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
return null;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await System.Text.Json.JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var posters = Simplify(Extract(doc.RootElement, "movieposter"));
var logos = Simplify(Extract(doc.RootElement, "hdmovielogo"));
foreach (var kv in Simplify(Extract(doc.RootElement, "movielogo")))
{
if (!logos.ContainsKey(kv.Key))
logos[kv.Key] = kv.Value;
}
return new FanartImages(
Posters: posters,
Logos: logos,
Banners: Simplify(Extract(doc.RootElement, "moviebanner")),
Thumbs: Simplify(Extract(doc.RootElement, "moviethumb")));
}
private static Dictionary<string, (string Url, int Likes)> Extract(System.Text.Json.JsonElement root, string property)
{
var result = new Dictionary<string, (string Url, int Likes)>(StringComparer.OrdinalIgnoreCase);
if (!root.TryGetProperty(property, out var value) || value.ValueKind != System.Text.Json.JsonValueKind.Array)
return result;
foreach (var image in value.EnumerateArray())
{
if (image.ValueKind != System.Text.Json.JsonValueKind.Object ||
!image.TryGetProperty("lang", out var langElement) ||
langElement.ValueKind != System.Text.Json.JsonValueKind.String ||
!image.TryGetProperty("url", out var urlElement) ||
urlElement.ValueKind != System.Text.Json.JsonValueKind.String)
continue;
var lang = (langElement.GetString() ?? string.Empty).Trim();
var imageUrl = (urlElement.GetString() ?? string.Empty).Trim();
if (lang.Length == 0 || lang.Equals("00", StringComparison.OrdinalIgnoreCase) || imageUrl.Length == 0)
continue;
var likes = 0;
if (image.TryGetProperty("likes", out var likesElement))
{
if (likesElement.ValueKind == System.Text.Json.JsonValueKind.Number)
likesElement.TryGetInt32(out likes);
else if (likesElement.ValueKind == System.Text.Json.JsonValueKind.String)
int.TryParse(likesElement.GetString(), out likes);
}
if (!result.TryGetValue(lang, out var current) || likes > current.Likes)
result[lang] = (imageUrl, likes);
}
return result;
}
private static Dictionary<string, string> Simplify(Dictionary<string, (string Url, int Likes)> images)
=> images.ToDictionary(k => k.Key, v => v.Value.Url, StringComparer.OrdinalIgnoreCase);
}
public sealed record FanartImages(
Dictionary<string, string> Posters,
Dictionary<string, string> Logos,
Dictionary<string, string> Banners,
Dictionary<string, string> Thumbs);
@@ -0,0 +1,28 @@
using System.Diagnostics;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public sealed class FetchRateLimiter
{
private readonly TimeSpan _minimumSpacing;
private long _lastStartTicks;
public FetchRateLimiter(TimeSpan minimumSpacing)
{
_minimumSpacing = minimumSpacing;
}
public async Task WaitAsync(CancellationToken cancellationToken)
{
var last = Interlocked.Read(ref _lastStartTicks);
if (last != 0)
{
var elapsed = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - last) / (double)Stopwatch.Frequency);
var remaining = _minimumSpacing - elapsed;
if (remaining > TimeSpan.Zero)
await Task.Delay(remaining, cancellationToken).ConfigureAwait(false);
}
Interlocked.Exchange(ref _lastStartTicks, Stopwatch.GetTimestamp());
}
}
@@ -0,0 +1,141 @@
using System.Net;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public enum ProviderFailureKind
{
Retryable,
Terminal
}
public sealed record ProviderHttpDiagnostics(
ProviderHttpCount[] Counts,
ProviderHttpRecent[] Recent);
public sealed record ProviderHttpCount(
string Provider,
string Outcome,
int Count);
public sealed record ProviderHttpRecent(
string Provider,
string Url,
string Outcome,
int Attempt,
long At);
public static class ProviderHttp
{
private static readonly object DiagnosticsLock = new();
private static readonly Dictionary<string, int> Counts = new(StringComparer.OrdinalIgnoreCase);
private static readonly Queue<ProviderHttpRecent> Recent = new();
public static ProviderFailureKind Classify(HttpStatusCode code)
=> code is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
(int)code >= 500
? ProviderFailureKind.Retryable
: ProviderFailureKind.Terminal;
public static async Task<HttpResponseMessage> SendWithRetryAsync(
HttpClient http,
HttpRequestMessage request,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var response = await http.SendAsync(Clone(request), cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts)
{
Record(request, "HTTP " + (int)response.StatusCode, attempt);
return response;
}
var delay = GetRetryDelay(response, attempt);
response.Dispose();
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (attempt >= maxAttempts)
{
Record(request, ex.GetType().Name, attempt);
throw;
}
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false);
}
}
throw new HttpRequestException("Provider request failed after retries.");
}
public static ProviderHttpDiagnostics GetDiagnostics()
{
lock (DiagnosticsLock)
{
return new ProviderHttpDiagnostics(
Counts
.Select(kvp =>
{
var index = kvp.Key.IndexOf('|', StringComparison.Ordinal);
var provider = index >= 0 ? kvp.Key[..index] : kvp.Key;
var outcome = index >= 0 ? kvp.Key[(index + 1)..] : string.Empty;
return new ProviderHttpCount(provider, outcome, kvp.Value);
})
.OrderBy(c => c.Provider)
.ThenBy(c => c.Outcome)
.ToArray(),
Recent.Reverse().ToArray());
}
}
private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
=> response.Headers.RetryAfter?.Delta ??
(response.Headers.RetryAfter?.Date is { } date
? date - DateTimeOffset.UtcNow
: TimeSpan.FromMilliseconds(250 * attempt));
private static void Record(HttpRequestMessage request, string outcome, int attempt)
{
var provider = ProviderName(request.RequestUri);
var key = provider + "|" + outcome;
var recent = new ProviderHttpRecent(
provider,
SafeUrl(request.RequestUri),
outcome,
attempt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
lock (DiagnosticsLock)
{
Counts[key] = Counts.TryGetValue(key, out var count) ? count + 1 : 1;
Recent.Enqueue(recent);
while (Recent.Count > 50)
Recent.Dequeue();
}
}
private static string ProviderName(Uri? uri)
{
var host = uri?.Host ?? string.Empty;
if (host.Contains("themoviedb", StringComparison.OrdinalIgnoreCase) || host.Contains("tmdb", StringComparison.OrdinalIgnoreCase))
return "TMDb";
if (host.Contains("fanart", StringComparison.OrdinalIgnoreCase))
return "Fanart.tv";
return string.IsNullOrWhiteSpace(host) ? "unknown" : host;
}
private static string SafeUrl(Uri? uri)
=> uri is null ? string.Empty : uri.GetLeftPart(UriPartial.Path);
private static HttpRequestMessage Clone(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
return clone;
}
}
@@ -0,0 +1,201 @@
using System.Net;
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Services.Refresh;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public sealed class TmdbClient
{
private readonly IHttpClientFactory _httpClientFactory;
public TmdbClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<TmdbMetadata?> FetchMetadataAsync(
RefreshItemInfo item,
string language,
string apiKey,
FetchRateLimiter rateLimiter,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("TMDb API key is required.");
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
var http = _httpClientFactory.CreateClient();
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}?api_key={Uri.EscapeDataString(apiKey)}&language={Uri.EscapeDataString(language)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return new TmdbMetadata(
Title: ExtractTitle(doc.RootElement, item.Kind),
Overview: ExtractString(doc.RootElement, "overview"),
Tagline: ExtractString(doc.RootElement, "tagline"),
OriginalTitle: ExtractOriginalTitle(doc.RootElement, item.Kind),
OriginalLanguage: ExtractString(doc.RootElement, "original_language"),
OriginCountries: ExtractStringArray(doc.RootElement, "origin_country"),
ProductionCountries: ExtractIsoFromObjectArray(doc.RootElement, "production_countries", "iso_3166_1"),
SpokenLanguages: ExtractIsoFromObjectArray(doc.RootElement, "spoken_languages", "iso_639_1"),
Genres: ExtractGenres(doc.RootElement));
}
public async Task<TmdbImages?> FetchImagesAsync(
RefreshItemInfo item,
string apiKey,
FetchRateLimiter rateLimiter,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("TMDb API key is required.");
if (item.Kind is "tvepisode")
return new TmdbImages([], [], []);
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
var http = _httpClientFactory.CreateClient();
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}/images?api_key={Uri.EscapeDataString(apiKey)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return new TmdbImages(
Posters: ExtractImageUrls(doc.RootElement, "posters"),
Logos: ExtractImageUrls(doc.RootElement, "logos"),
Backdrops: ExtractImageUrls(doc.RootElement, "backdrops"));
}
private static string BuildPath(RefreshItemInfo item)
=> item.Kind switch
{
"tvseason" => $"tv/{item.TmdbId}/season/{item.SeasonNumber}",
"tvepisode" => $"tv/{item.TmdbId}/season/{item.SeasonNumber}/episode/{item.EpisodeNumber}",
_ => $"{item.Kind}/{item.TmdbId}"
};
private static string ExtractTitle(JsonElement root, string kind)
=> kind is "tv" or "collection" or "tvseason" or "tvepisode"
? ExtractString(root, "name")
: ExtractString(root, "title");
private static string ExtractOriginalTitle(JsonElement root, string kind)
=> kind switch
{
"tv" => ExtractString(root, "original_name"),
"movie" => ExtractString(root, "original_title"),
"collection" => FirstNonEmpty(ExtractString(root, "original_name"), ExtractString(root, "name")),
"tvseason" => ExtractString(root, "name"),
"tvepisode" => ExtractString(root, "name"),
_ => ExtractString(root, "original_title")
};
private static string ExtractString(JsonElement root, string property)
=> root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() ?? string.Empty
: string.Empty;
private static string[] ExtractStringArray(JsonElement root, string property)
{
if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(el => el.ValueKind == JsonValueKind.String)
.Select(el => el.GetString() ?? string.Empty)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string[] ExtractIsoFromObjectArray(JsonElement root, string property, string isoProperty)
{
if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(el => el.ValueKind == JsonValueKind.Object)
.Select(el => el.TryGetProperty(isoProperty, out var iso) && iso.ValueKind == JsonValueKind.String ? iso.GetString() ?? string.Empty : string.Empty)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static TmdbGenre[] ExtractGenres(JsonElement root)
{
if (!root.TryGetProperty("genres", out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(el => el.ValueKind == JsonValueKind.Object && el.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number)
.Select(el => new TmdbGenre(
el.GetProperty("id").GetInt32(),
el.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty))
.Where(g => g.Id > 0 && !string.IsNullOrWhiteSpace(g.Name))
.GroupBy(g => g.Id)
.Select(g => g.First())
.ToArray();
}
private static Dictionary<string, string> ExtractImageUrls(JsonElement root, string property)
{
if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var image in value.EnumerateArray())
{
if (image.ValueKind != JsonValueKind.Object ||
!image.TryGetProperty("file_path", out var pathElement) ||
pathElement.ValueKind != JsonValueKind.String)
continue;
var path = pathElement.GetString();
if (string.IsNullOrWhiteSpace(path))
continue;
var iso = image.TryGetProperty("iso_639_1", out var isoElement) && isoElement.ValueKind == JsonValueKind.String
? isoElement.GetString() ?? string.Empty
: string.Empty;
if (string.IsNullOrWhiteSpace(iso) || result.ContainsKey(iso))
continue;
result[iso] = "https://image.tmdb.org/t/p/original" + path;
}
return result;
}
private static string FirstNonEmpty(params string[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
}
public sealed record TmdbMetadata(
string Title,
string Overview,
string Tagline,
string OriginalTitle,
string OriginalLanguage,
string[] OriginCountries,
string[] ProductionCountries,
string[] SpokenLanguages,
TmdbGenre[] Genres)
{
public int[] GenreIds { get; } = Genres.Select(g => g.Id).Distinct().ToArray();
}
public sealed record TmdbGenre(int Id, string Name);
public sealed record TmdbImages(
Dictionary<string, string> Posters,
Dictionary<string, string> Logos,
Dictionary<string, string> Backdrops);
@@ -0,0 +1,181 @@
using Jellyfin.Plugin.Multilang.Services;
using System.Diagnostics;
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public sealed class RefreshCoordinator
{
private sealed class QueueEntry
{
public required string ItemId { get; init; }
public required RefreshSourceTier SourceTier { get; set; }
public required RefreshWorkClass WorkClass { get; init; }
public required RefreshJobType JobType { get; set; }
public required Func<RefreshJobType, CancellationToken, Task<bool>> Work { get; set; }
public required TaskCompletionSource<bool> Completion { get; init; }
public long Sequence { get; init; }
}
private readonly object _lock = new();
private readonly Dictionary<string, QueueEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
private readonly Queue<RefreshRecentItem> _recent = new();
private readonly SemaphoreSlim _signal = new(0, int.MaxValue);
private readonly ItemsProxyCache _itemsProxyCache;
private long _sequence;
private QueueEntry? _active;
public SemaphoreSlim ScanMutex { get; } = new(1, 1);
public RefreshCoordinator(ItemsProxyCache itemsProxyCache)
{
_itemsProxyCache = itemsProxyCache;
_ = Task.Run(WorkerLoop);
}
public Task<bool> EnqueueAsync(
string itemId,
RefreshSourceTier sourceTier,
RefreshWorkClass workClass,
RefreshJobType jobType,
Func<RefreshJobType, CancellationToken, Task<bool>> work,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(itemId))
return Task.FromResult(false);
if (cancellationToken.IsCancellationRequested)
return Task.FromCanceled<bool>(cancellationToken);
lock (_lock)
{
if (_entries.TryGetValue(itemId, out var existing))
{
if (jobType > existing.JobType)
{
existing.JobType = jobType;
existing.Work = work;
}
if (sourceTier > existing.SourceTier)
existing.SourceTier = sourceTier;
return existing.Completion.Task;
}
var entry = new QueueEntry
{
ItemId = itemId,
SourceTier = sourceTier,
WorkClass = workClass,
JobType = jobType,
Work = work,
Completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously),
Sequence = ++_sequence
};
_entries.Add(itemId, entry);
_signal.Release();
return entry.Completion.Task;
}
}
private async Task WorkerLoop()
{
while (true)
{
await _signal.WaitAsync().ConfigureAwait(false);
QueueEntry? entry;
lock (_lock)
entry = DequeueLocked();
if (entry is null)
continue;
var sw = Stopwatch.StartNew();
var ok = false;
var error = string.Empty;
try
{
lock (_lock)
_active = entry;
ok = await entry.Work(entry.JobType, CancellationToken.None).ConfigureAwait(false);
if (ok)
_itemsProxyCache.InvalidateItems([entry.ItemId]);
entry.Completion.TrySetResult(ok);
}
catch (Exception ex)
{
error = ex.Message;
entry.Completion.TrySetException(ex);
}
finally
{
sw.Stop();
lock (_lock)
{
if (ReferenceEquals(_active, entry))
_active = null;
_entries.Remove(entry.ItemId);
_recent.Enqueue(new RefreshRecentItem(
entry.ItemId,
entry.SourceTier.ToString(),
entry.WorkClass.ToString(),
entry.JobType.ToString(),
ok,
sw.ElapsedMilliseconds,
error,
DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
while (_recent.Count > 50)
_recent.Dequeue();
}
}
}
}
private QueueEntry? DequeueLocked()
{
if (_entries.Count == 0)
return null;
return _entries.Values
.OrderByDescending(e => e.SourceTier)
.ThenBy(e => e.WorkClass)
.ThenBy(e => e.Sequence)
.First();
}
public RefreshQueueDiagnostics GetDiagnostics()
{
lock (_lock)
{
var queued = _entries.Values
.Where(e => !ReferenceEquals(e, _active))
.OrderByDescending(e => e.SourceTier)
.ThenBy(e => e.WorkClass)
.ThenBy(e => e.Sequence)
.Select(ToDiagnostics)
.ToArray();
return new RefreshQueueDiagnostics(
queued.Length,
queued,
_active is null ? null : ToDiagnostics(_active),
_recent.Reverse().ToArray());
}
}
private static RefreshQueueEntry ToDiagnostics(QueueEntry entry)
=> new(
entry.ItemId,
entry.SourceTier.ToString(),
entry.WorkClass.ToString(),
entry.JobType.ToString(),
entry.Sequence);
}
@@ -0,0 +1,85 @@
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public enum RefreshJobType
{
Aggregate = 0,
Missing = 1,
Full = 2
}
public enum RefreshSourceTier
{
Background = 0,
Manual = 1,
OnTheFly = 2
}
public enum RefreshWorkClass
{
Movies = 0,
Collections = 1,
Episodes = 2,
Seasons = 3,
Series = 4
}
public sealed record RefreshItemInfo(
string ItemId,
string TmdbId,
string Kind,
RefreshWorkClass WorkClass,
int SeasonNumber,
int EpisodeNumber,
string? AudioLanguage,
string DisplayName);
public sealed record RefreshQueueEntry(
string ItemId,
string SourceTier,
string WorkClass,
string JobType,
long Sequence);
public sealed record RefreshRecentItem(
string ItemId,
string SourceTier,
string WorkClass,
string JobType,
bool Ok,
long DurationMs,
string Error,
long FinishedAt);
public sealed record RefreshQueueDiagnostics(
int QueuedCount,
RefreshQueueEntry[] Queued,
RefreshQueueEntry? Active,
RefreshRecentItem[] Recent);
public sealed record RefreshScanDiagnostics(
bool Running,
string Mode,
string Source,
long StartedAt,
long FinishedAt,
long ElapsedMs,
int Items,
int Due,
int Refreshed,
int SkippedNotDue,
int SkippedNoData,
int New,
int Deleted,
int CurrentIndex,
string CurrentItemId,
string CurrentName,
string CurrentKind,
string CurrentAction,
bool Success,
string Error);
public sealed record RefreshDiagnostics(
long LastScanStarted,
RefreshScanDiagnostics Scan,
RefreshQueueDiagnostics Queue,
object ProviderHttp);
@@ -0,0 +1,748 @@
using System.Diagnostics;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Providers;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public sealed class RefreshService
{
private const string OriginalLanguageTag = "Original";
private readonly ILibraryManager _libraryManager;
private readonly TranslationStore _store;
private readonly TmdbClient _tmdbClient;
private readonly FanartClient _fanartClient;
private readonly RefreshCoordinator _coordinator;
private readonly ILogger<RefreshService> _logger;
private readonly object _diagnosticsLock = new();
private RefreshScanDiagnostics _scanDiagnostics = EmptyScanDiagnostics();
public RefreshService(
ILibraryManager libraryManager,
TranslationStore store,
TmdbClient tmdbClient,
FanartClient fanartClient,
RefreshCoordinator coordinator,
ILogger<RefreshService> logger)
{
_libraryManager = libraryManager;
_store = store;
_tmdbClient = tmdbClient;
_fanartClient = fanartClient;
_coordinator = coordinator;
_logger = logger;
}
public RefreshDiagnostics GetDiagnostics()
=> new(
_store.GetLastScanStarted(),
GetScanDiagnostics(),
_coordinator.GetDiagnostics(),
ProviderHttp.GetDiagnostics());
private RefreshScanDiagnostics GetScanDiagnostics()
{
lock (_diagnosticsLock)
return _scanDiagnostics;
}
private void SetScanDiagnostics(RefreshScanDiagnostics diagnostics)
{
lock (_diagnosticsLock)
_scanDiagnostics = diagnostics;
}
private static RefreshScanDiagnostics EmptyScanDiagnostics()
=> new(false, string.Empty, string.Empty, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, string.Empty, string.Empty, string.Empty, string.Empty, true, string.Empty);
public async Task RunScanAsync(RefreshJobType jobType, IProgress<double> progress, CancellationToken cancellationToken, string runSource)
{
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
ValidateRefreshConfiguration(cfg);
await _coordinator.ScanMutex.WaitAsync(cancellationToken).ConfigureAwait(false);
var startedAt = TranslationStore.NowUnixUtc();
var sw = Stopwatch.StartNew();
var mode = jobType.ToString();
var source = runSource ?? string.Empty;
var itemCount = 0;
var dueCount = 0;
var refreshedCount = 0;
var skippedNotDueCount = 0;
var skippedNoDataCount = 0;
var newCount = 0;
var deletedCount = 0;
var currentIndex = 0;
var currentItemId = string.Empty;
var currentName = string.Empty;
var currentKind = string.Empty;
var currentAction = "initializing";
void UpdateRunningScan(string action = "")
{
if (!string.IsNullOrWhiteSpace(action))
currentAction = action;
SetScanDiagnostics(new RefreshScanDiagnostics(
true,
mode,
source,
startedAt,
0,
sw.ElapsedMilliseconds,
itemCount,
dueCount,
refreshedCount,
skippedNotDueCount,
skippedNoDataCount,
newCount,
deletedCount,
currentIndex,
currentItemId,
currentName,
currentKind,
currentAction,
true,
string.Empty));
}
void SetCurrent(RefreshItemInfo item, int index, string action)
{
currentIndex = index;
currentItemId = item.ItemId;
currentName = item.DisplayName;
currentKind = item.Kind;
currentAction = action;
}
void CompleteScan(bool success, string error)
=> SetScanDiagnostics(new RefreshScanDiagnostics(
false,
mode,
source,
startedAt,
TranslationStore.NowUnixUtc(),
sw.ElapsedMilliseconds,
itemCount,
dueCount,
refreshedCount,
skippedNotDueCount,
skippedNoDataCount,
newCount,
deletedCount,
currentIndex,
currentItemId,
currentName,
currentKind,
success ? "complete" : "failed",
success,
error));
try
{
UpdateRunningScan();
var items = GetAllItemsWithTmdbId();
var now = TranslationStore.NowUnixUtc();
var missingCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForMissingData)).TotalSeconds;
var fullCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForExistingData)).TotalSeconds;
var ordered = items.OrderBy(i => i.WorkClass).ToList();
itemCount = ordered.Count;
var liveIds = ordered.Select(i => i.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var trackedBefore = _store.GetTrackedItemIds();
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count();
_store.CleanupNotInLibrary(liveIds);
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
var index = 0;
UpdateRunningScan();
foreach (var item in ordered)
{
cancellationToken.ThrowIfCancellationRequested();
index++;
SetCurrent(item, index, "checking");
var status = _store.GetFactsStatus(item.ItemId);
var isNew = !status.Exists;
var due = jobType == RefreshJobType.Full
? !status.Exists || status.FullCheckedAt < fullCutoff
: !status.Exists || status.MissingCheckedAt < missingCutoff;
if (!due)
{
skippedNotDueCount++;
if (cfg.VerboseLogging && cfg.EnableLogging)
{
_logger.LogInformation(
"Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due",
index,
ordered.Count,
item.ItemId,
item.DisplayName,
item.Kind);
}
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan("skipped-not-due");
continue;
}
var scanIndex = index;
var actualJobType = jobType == RefreshJobType.Full || !status.Exists ? RefreshJobType.Full : RefreshJobType.Missing;
dueCount++;
SetCurrent(item, scanIndex, "fetching");
Log(
cfg.EnableLogging,
LogLevel.Information,
"Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId} mode={Mode} new={New}",
scanIndex,
ordered.Count,
item.ItemId,
item.DisplayName,
item.Kind,
item.TmdbId,
actualJobType,
isNew);
var ok = await _coordinator.EnqueueAsync(
item.ItemId,
RefreshSourceTier.Background,
item.WorkClass,
actualJobType,
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, ordered.Count, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
if (ok)
refreshedCount++;
else
skippedNoDataCount++;
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan(ok ? "refreshed" : "skipped-no-data");
}
sw.Stop();
_store.SetLastScanStarted(now);
CompleteScan(true, string.Empty);
_logger.LogInformation(
"Multilang refresh complete mode={Mode} source={Source} elapsed={ElapsedMs}ms items={Items} due={Due} refreshed={Refreshed} skippedNotDue={SkippedNotDue} skippedNoData={SkippedNoData} new={New} deleted={Deleted}",
jobType,
runSource,
sw.ElapsedMilliseconds,
ordered.Count,
dueCount,
refreshedCount,
skippedNotDueCount,
skippedNoDataCount,
newCount,
deletedCount);
}
catch (Exception ex)
{
sw.Stop();
CompleteScan(false, ex.Message);
throw;
}
finally
{
_coordinator.ScanMutex.Release();
}
}
public async Task RefreshItemAsync(Guid itemId, bool includeChildren, CancellationToken cancellationToken)
{
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
ValidateRefreshConfiguration(cfg);
var root = _libraryManager.GetItemById(itemId);
if (root is null)
{
Log(cfg.EnableLogging, LogLevel.Warning, "Manual refresh requested for missing item {ItemId}", itemId);
return;
}
var items = BuildItemInfos(GetItemAndChildren(root, includeChildren)).ToList();
var total = items.Count;
var index = 0;
_logger.LogInformation("Manual Multilang refresh started item={ItemId} name={Name} items={Items} includeChildren={IncludeChildren}", itemId, root.Name ?? string.Empty, total, includeChildren);
foreach (var item in items)
{
index++;
var scanIndex = index;
Log(
cfg.EnableLogging,
LogLevel.Information,
"Manual Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}",
scanIndex,
total,
item.ItemId,
item.DisplayName,
item.Kind,
item.TmdbId);
var ok = await _coordinator.EnqueueAsync(
item.ItemId,
RefreshSourceTier.Manual,
item.WorkClass,
RefreshJobType.Full,
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, total, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
Log(
cfg.EnableLogging,
ok ? LogLevel.Information : LogLevel.Warning,
"Manual Multilang refresh item {Status} [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind}",
ok ? "complete" : "skipped",
scanIndex,
total,
item.ItemId,
item.DisplayName,
item.Kind);
}
}
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
{
var cfg = Plugin.Instance?.Configuration;
if (cfg is null)
return false;
try
{
ValidateRefreshConfiguration(cfg);
}
catch (InvalidOperationException)
{
return false;
}
var ids = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (ids.Length == 0)
return false;
var existingFacts = _store.GetExistingFactsItemIds(ids);
var queuedAny = false;
var sourceTier = ids.Length <= 5 ? RefreshSourceTier.OnTheFly : RefreshSourceTier.Background;
foreach (var itemId in ids)
{
if (existingFacts.Contains(itemId))
continue;
if (!Guid.TryParseExact(itemId, "N", out var guid))
continue;
var item = _libraryManager.GetItemById(guid);
if (item is null)
continue;
var info = BuildItemInfos([item]).FirstOrDefault();
if (info is null)
continue;
queuedAny = true;
_ = _coordinator.EnqueueAsync(
info.ItemId,
sourceTier,
info.WorkClass,
RefreshJobType.Full,
async (actualJobType, ct) => await RefreshItemCoreAsync(info, cfg, actualJobType, 0, 0, ct).ConfigureAwait(false),
CancellationToken.None)
.ContinueWith(
task =>
{
if (task.Exception is not null)
_logger.LogWarning(task.Exception, "On-demand Multilang refresh failed for item={ItemId}", info.ItemId);
},
TaskContinuationOptions.OnlyOnFaulted);
_logger.LogInformation("Queued on-demand Multilang refresh item={ItemId} tier={Tier}", info.ItemId, sourceTier);
}
return queuedAny;
}
public RefreshItemInfo? GetRefreshItemInfo(Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
return item is null ? null : BuildItemInfos([item]).FirstOrDefault();
}
private async Task<bool> RefreshItemCoreAsync(
RefreshItemInfo item,
PluginConfiguration cfg,
RefreshJobType jobType,
int scanIndex,
int totalItems,
CancellationToken cancellationToken)
{
var langs = NormalizeLanguages(cfg.Languages);
if (langs.Length == 0)
throw new InvalidOperationException("No Multilang languages are configured.");
if (cfg.VerboseLogging && cfg.EnableLogging)
_logger.LogInformation("[{Index}/{Total}] Fetching item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.Kind, item.TmdbId);
var now = TranslationStore.NowUnixUtc();
var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(250));
var wroteAny = false;
TmdbMetadata? firstMeta = null;
var fetchedMetadata = new Dictionary<string, TmdbMetadata>(StringComparer.OrdinalIgnoreCase);
foreach (var lang in langs)
{
var meta = await _tmdbClient.FetchMetadataAsync(item, lang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (meta is null)
{
Log(cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb 404, skipping item={ItemId} name=\"{Name}\" tmdb={TmdbId} kind={Kind}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.TmdbId, item.Kind);
_store.MarkFactsChecked(item, now, jobType == RefreshJobType.Full ? now : 0);
return false;
}
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb metadata ok item={ItemId} lang={Lang}", scanIndex, totalItems, item.ItemId, lang);
_store.UpsertTranslation(item.ItemId, lang, "title", meta.Title);
_store.UpsertTranslation(item.ItemId, lang, "overview", meta.Overview);
_store.UpsertTranslation(item.ItemId, lang, "tagline", meta.Tagline);
StoreGenres(item, lang, meta);
fetchedMetadata[lang] = meta;
firstMeta ??= meta;
if (!wroteAny)
{
_store.UpsertFacts(item, meta, now, jobType == RefreshJobType.Full ? now : 0);
wroteAny = true;
}
}
var artworkLanguages = langs;
if (firstMeta is not null && !string.IsNullOrWhiteSpace(firstMeta.OriginalLanguage))
{
var originalLang = firstMeta.OriginalLanguage.Trim();
var existingLang = langs.FirstOrDefault(lang => SameLanguageBase(lang, originalLang));
Log(
cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Original language item={ItemId} lang={OriginalLang} metadataSource={Source}",
scanIndex,
totalItems,
item.ItemId,
originalLang,
existingLang is not null ? "configured-language:" + existingLang : "extra-fetch");
var originalMeta = existingLang is not null
? fetchedMetadata[existingLang]
: await _tmdbClient.FetchMetadataAsync(item, originalLang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (originalMeta is not null)
{
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "title", originalMeta.Title);
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "overview", originalMeta.Overview);
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "tagline", originalMeta.Tagline);
StoreGenres(item, OriginalLanguageTag, originalMeta);
Log(
cfg.VerboseLogging && cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Original metadata stored item={ItemId} lang={OriginalLang} titlePresent={TitlePresent} overviewPresent={OverviewPresent} taglinePresent={TaglinePresent}",
scanIndex,
totalItems,
item.ItemId,
originalLang,
!string.IsNullOrWhiteSpace(originalMeta.Title),
!string.IsNullOrWhiteSpace(originalMeta.Overview),
!string.IsNullOrWhiteSpace(originalMeta.Tagline));
}
else
{
Log(
cfg.EnableLogging,
LogLevel.Warning,
"[{Index}/{Total}] Original metadata unavailable item={ItemId} lang={OriginalLang}",
scanIndex,
totalItems,
item.ItemId,
originalLang);
}
artworkLanguages = AppendOriginalLanguage(langs, originalLang);
Log(
cfg.VerboseLogging && cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Artwork language set item={ItemId} langs={Languages}",
scanIndex,
totalItems,
item.ItemId,
string.Join(",", artworkLanguages));
}
await StoreArtworkAsync(item, cfg, artworkLanguages, limiter, now, cancellationToken).ConfigureAwait(false);
Log(cfg.EnableLogging, LogLevel.Information, "Multilang refresh item complete item={ItemId} kind={Kind} tmdb={TmdbId} mode={Mode}", item.ItemId, item.Kind, item.TmdbId, jobType);
return true;
}
private void StoreGenres(RefreshItemInfo item, string lang, TmdbMetadata metadata)
{
var media = GenreMediaFor(item.Kind);
if (media is null)
return;
foreach (var genre in metadata.Genres)
_store.UpsertGenre(genre.Id, media, lang, genre.Name);
}
private static string? GenreMediaFor(string kind)
=> kind.Equals("movie", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("collection", StringComparison.OrdinalIgnoreCase)
? "movie"
: kind.Equals("tv", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("tvseason", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("tvepisode", StringComparison.OrdinalIgnoreCase)
? "tv"
: null;
private async Task StoreArtworkAsync(
RefreshItemInfo item,
PluginConfiguration cfg,
string[] languages,
FetchRateLimiter limiter,
long updatedAt,
CancellationToken cancellationToken)
{
if (item.Kind is "tvepisode")
return;
var providers = cfg.Providers
.Where(p => p.ArtworkOrder >= 0)
.OrderBy(p => p.ArtworkOrder)
.Select(p => p.Id.Trim().ToLowerInvariant())
.ToArray();
if (providers.Length == 0)
return;
TmdbImages? tmdbImages = null;
FanartImages? fanartImages = null;
var written = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var provider in providers)
{
cancellationToken.ThrowIfCancellationRequested();
if (provider.Equals("tmdb", StringComparison.OrdinalIgnoreCase))
{
tmdbImages ??= await _tmdbClient.FetchImagesAsync(item, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (tmdbImages is null)
continue;
StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written);
StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written);
StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
continue;
}
if (provider.Equals("fanart", StringComparison.OrdinalIgnoreCase) && item.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase))
{
fanartImages ??= await _fanartClient.FetchMovieImagesAsync(item.TmdbId, cfg.FanartApiKey, cancellationToken).ConfigureAwait(false);
if (fanartImages is null)
continue;
StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written);
StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written);
StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written);
StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
}
}
}
private void StoreImageMap(
string itemId,
IEnumerable<string> languages,
string kind,
IReadOnlyDictionary<string, string> images,
long updatedAt,
HashSet<string> written)
{
foreach (var lang in languages)
{
var storeLang = lang.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase)
? OriginalLanguageTag
: lang;
var key = kind + ":" + storeLang;
if (written.Contains(key))
continue;
var iso = ToIso639(lang);
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
continue;
_store.UpsertAsset(itemId, storeLang, kind, url, updatedAt);
written.Add(key);
}
}
private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
=> languages
.Concat([OriginalLanguageTag + ":" + originalLanguage])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static bool SameLanguageBase(string left, string right)
=> ToIso639(left).Equals(ToIso639(right), StringComparison.OrdinalIgnoreCase);
private static string ToIso639(string language)
{
if (language.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase))
language = language[(OriginalLanguageTag.Length + 1)..];
var index = language.IndexOf('-', StringComparison.Ordinal);
return (index > 0 ? language[..index] : language).Trim().ToLowerInvariant();
}
private List<RefreshItemInfo> GetAllItemsWithTmdbId()
{
var query = new InternalItemsQuery
{
IncludeItemTypes =
[
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.BoxSet,
BaseItemKind.Season,
BaseItemKind.Episode
],
Recursive = true
};
return BuildItemInfos(_libraryManager.GetItemList(query)).ToList();
}
private IEnumerable<RefreshItemInfo> BuildItemInfos(IEnumerable<BaseItem> items)
{
foreach (var item in items)
{
var itemId = TranslationStore.ToItemId32(item.Id);
var typeName = item.GetType().Name;
if (typeName.Equals("Season", StringComparison.OrdinalIgnoreCase))
{
if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out _))
yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvseason", RefreshWorkClass.Seasons, seasonNumber, 0, string.Empty, item.Name ?? string.Empty);
continue;
}
if (typeName.Equals("Episode", StringComparison.OrdinalIgnoreCase))
{
if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out var episodeNumber))
yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvepisode", RefreshWorkClass.Episodes, seasonNumber, episodeNumber, string.Empty, item.Name ?? string.Empty);
continue;
}
var providerIds = item.ProviderIds ?? new Dictionary<string, string>();
providerIds.TryGetValue("Tmdb", out var tmdbId);
providerIds.TryGetValue("TmdbCollection", out var tmdbCollectionId);
if (typeName.Equals("Series", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(tmdbId))
{
yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "tv", RefreshWorkClass.Series, 0, 0, string.Empty, item.Name ?? string.Empty);
continue;
}
if (typeName.Equals("BoxSet", StringComparison.OrdinalIgnoreCase))
{
var collectionId = FirstNonEmpty(tmdbCollectionId, tmdbId);
if (!string.IsNullOrWhiteSpace(collectionId))
yield return new RefreshItemInfo(itemId, collectionId.Trim(), "collection", RefreshWorkClass.Collections, 0, 0, string.Empty, item.Name ?? string.Empty);
continue;
}
if (!string.IsNullOrWhiteSpace(tmdbId))
yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "movie", RefreshWorkClass.Movies, 0, 0, string.Empty, item.Name ?? string.Empty);
}
}
private List<BaseItem> GetItemAndChildren(BaseItem root, bool includeChildren)
{
var items = new List<BaseItem> { root };
if (!includeChildren)
return items;
items.AddRange(_libraryManager.GetItemList(new InternalItemsQuery
{
ParentId = root.Id,
Recursive = true
}));
return items;
}
private bool TryResolveEpisodeContext(BaseItem item, out string seriesTmdbId, out int seasonNumber, out int episodeNumber)
{
seriesTmdbId = string.Empty;
seasonNumber = item.ParentIndexNumber ?? 0;
episodeNumber = item.IndexNumber ?? 0;
var typeName = item.GetType().Name;
var isSeason = typeName.Equals("Season", StringComparison.OrdinalIgnoreCase);
var seasonGuid = !isSeason ? TryGetGuid(item, "SeasonId") ?? (item.ParentId == Guid.Empty ? null : item.ParentId) : null;
var seriesGuid = TryGetGuid(item, "SeriesId") ?? (isSeason && item.ParentId != Guid.Empty ? item.ParentId : null);
var season = isSeason ? item : (seasonGuid.HasValue ? _libraryManager.GetItemById(seasonGuid.Value) : null);
if (!seriesGuid.HasValue && season is not null && season.ParentId != Guid.Empty)
seriesGuid = season.ParentId;
var series = seriesGuid.HasValue ? _libraryManager.GetItemById(seriesGuid.Value) : null;
if (isSeason)
{
seasonNumber = item.IndexNumber ?? 0;
}
if (seasonNumber <= 0 || (!isSeason && episodeNumber <= 0) || series is null)
return false;
var providerIds = series.ProviderIds ?? new Dictionary<string, string>();
if (!providerIds.TryGetValue("Tmdb", out var tmdbId) || string.IsNullOrWhiteSpace(tmdbId))
return false;
seriesTmdbId = tmdbId.Trim();
return true;
}
private static Guid? TryGetGuid(BaseItem item, string propertyName)
{
var property = item.GetType().GetProperty(propertyName);
var value = property?.GetValue(item);
return value switch
{
Guid guid when guid != Guid.Empty => guid,
string text when Guid.TryParse(text, out var guid) && guid != Guid.Empty => guid,
_ => (Guid?)null
};
}
private void Log(bool enabled, LogLevel level, string message, params object[] args)
{
if (enabled)
_logger.Log(level, message, args);
}
private static void ValidateRefreshConfiguration(PluginConfiguration cfg)
{
var metadataProviders = cfg.Providers
.Where(p => p.MetadataOrder >= 0)
.OrderBy(p => p.MetadataOrder)
.Select(p => p.Id)
.ToArray();
if (!metadataProviders.Contains("tmdb", StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("TMDb must be enabled as a metadata provider before refresh can run.");
if (string.IsNullOrWhiteSpace(cfg.TmdbApiKey))
throw new InvalidOperationException("TMDb API key is required before refresh can run.");
if (NormalizeLanguages(cfg.Languages).Length == 0)
throw new InvalidOperationException("At least one Multilang language must be configured before refresh can run.");
}
private static string[] NormalizeLanguages(IEnumerable<string> languages)
=> languages.Select(l => (l ?? string.Empty).Trim())
.Where(l => l.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
}
@@ -0,0 +1,22 @@
namespace Jellyfin.Plugin.Multilang.Services;
public sealed class SortArticleCatalog
{
private static readonly Dictionary<string, string[]> BuiltIns = new(StringComparer.OrdinalIgnoreCase)
{
["en"] = ["a", "an", "the"],
["en-US"] = ["a", "an", "the"],
["en-GB"] = ["a", "an", "the"],
["fi"] = [],
["fi-FI"] = [],
["sv"] = ["en", "ett"],
["sv-SE"] = ["en", "ett"],
["fr"] = ["l'", "le", "la", "les", "un", "une", "des"],
["de"] = ["der", "die", "das", "ein", "eine"],
["es"] = ["el", "la", "los", "las", "un", "una"],
["it"] = ["il", "lo", "la", "gli", "le", "un", "una"]
};
public IReadOnlyDictionary<string, string[]> GetBuiltIns()
=> BuiltIns;
}
@@ -0,0 +1,443 @@
(() => {
const BASE_PATH = window.location.pathname.split("/web/")[0] || "";
const MENU_ID = "mlUserRulesMenuItem";
const USER_RULES_ROUTE = "multilang/user-rules";
let lastMenuItemId = "";
let userRulesRenderRun = 0;
function getClientToken() {
try {
return window.ApiClient?.accessToken?.() || "";
} catch (_) {
return "";
}
}
function hashParts() {
const raw = (location.hash || "").replace(/^#\/?/, "");
const queryIndex = raw.indexOf("?");
const path = (queryIndex >= 0 ? raw.slice(0, queryIndex) : raw).toLowerCase();
const query = queryIndex >= 0 ? raw.slice(queryIndex + 1) : "";
return { path, params: new URLSearchParams(query) };
}
function getCurrentUserId() {
const fromHash = hashParts().params.get("userId");
if (fromHash) return fromHash;
try {
return window.ApiClient?.getCurrentUserId?.() || "";
} catch (_) {
return "";
}
}
function userRulesHref() {
const params = new URLSearchParams();
const userId = getCurrentUserId();
if (userId) params.set("userId", userId);
params.set("multilang", "1");
return `${BASE_PATH}/web/#/${USER_RULES_ROUTE}?${params.toString()}`;
}
function isUserRulesRoute() {
const parts = hashParts();
return parts.path === USER_RULES_ROUTE && parts.params.get("multilang") === "1";
}
function pathSegments(pathname) {
return String(pathname || "")
.toLowerCase()
.split("/")
.filter(Boolean);
}
function looksLikeItemId(value) {
return /^[0-9a-f]{32}$/.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
}
function isUserItemsEndpoint(segments) {
const usersIndex = segments.lastIndexOf("users");
if (usersIndex < 0 || segments[usersIndex + 2] !== "items") return false;
const tail = segments.slice(usersIndex + 3);
if (tail.length === 0) return true;
if (tail.length !== 1) return false;
return looksLikeItemId(tail[0]) || tail[0] === "latest" || tail[0] === "resume" || tail[0] === "nextup";
}
function isRootItemsEndpoint(segments) {
const itemsIndex = segments.lastIndexOf("items");
if (itemsIndex < 0) return false;
const tail = segments.slice(itemsIndex + 1);
if (tail.length === 0) return true;
if (tail.length !== 1) return false;
return looksLikeItemId(tail[0]) || tail[0] === "latest" || tail[0] === "resume" || tail[0] === "nextup";
}
function isShowsListEndpoint(segments) {
const showsIndex = segments.lastIndexOf("shows");
if (showsIndex < 0) return false;
const tail = segments.slice(showsIndex + 1);
if (tail.length === 1) return tail[0] === "nextup";
if (tail.length === 2 && looksLikeItemId(tail[0])) return tail[1] === "episodes" || tail[1] === "seasons";
return false;
}
function isGenresEndpoint(segments) {
const genresIndex = segments.lastIndexOf("genres");
if (genresIndex < 0) return false;
const tail = segments.slice(genresIndex + 1);
return tail.length === 0 || (tail.length === 1 && looksLikeItemId(tail[0]));
}
function shouldProxyEndpoint(pathname) {
const segments = pathSegments(pathname);
return isUserItemsEndpoint(segments) ||
isRootItemsEndpoint(segments) ||
isShowsListEndpoint(segments) ||
isGenresEndpoint(segments);
}
function getItemsProxyUrl(urlInput) {
const url = urlInput instanceof Request ? urlInput.url : String(urlInput || "");
if (!url) return null;
const u = new URL(url, window.location.origin);
if (u.origin !== window.location.origin) return null;
const lower = u.pathname.toLowerCase();
if (lower.includes("/multilang/")) return null;
if (lower.includes("/images/") || lower.endsWith("/images")) return null;
if (!shouldProxyEndpoint(u.pathname)) return null;
const locale = document.documentElement?.lang?.trim() || navigator.language || "";
const token = getClientToken();
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : "";
const localeParam = locale ? `&mlLocale=${encodeURIComponent(locale)}` : "";
return `${BASE_PATH}/Multilang/ItemsProxy?url=${encodeURIComponent(`${u.pathname}${u.search}`)}${localeParam}${tokenParam}`;
}
function ensureUserRulesLink() {
let menuItem = document.getElementById(MENU_ID);
if (!(location.hash || "").toLowerCase().includes("mypreferences")) {
menuItem?.remove();
return;
}
const href = userRulesHref();
if (menuItem) {
menuItem.href = href;
wireUserRulesLink(menuItem);
return;
}
const links = [...document.querySelectorAll('a[href^="#/mypreferences"]')];
const insertAfter = links[links.length - 1];
if (!insertAfter) return;
const cloned = insertAfter.cloneNode(true);
cloned.id = MENU_ID;
cloned.dataset.mlUserRulesLink = "1";
cloned.href = href;
cloned.removeAttribute("title");
cloned.removeAttribute("aria-label");
[...cloned.classList]
.filter((c) => c.startsWith("lnk"))
.forEach((c) => cloned.classList.remove(c));
const textEl = cloned.querySelector(".listItemBodyText");
if (textEl) textEl.textContent = "Metadata language rules";
cloned.querySelectorAll(".material-icons").forEach((el) => el.remove());
const iconEl = document.createElement("span");
iconEl.className = "material-icons listItemIcon listItemIcon-transparent translate";
iconEl.setAttribute("aria-hidden", "true");
(cloned.querySelector(".listItemBody") || cloned).before(iconEl);
wireUserRulesLink(cloned);
insertAfter.parentElement.insertBefore(cloned, insertAfter.nextSibling);
}
function wireUserRulesLink(link) {
if (link.dataset.mlUserRulesClick === "1") return;
link.dataset.mlUserRulesClick = "1";
link.addEventListener("click", (event) => {
openUserRulesPage(event);
}, true);
}
function visiblePage() {
const pages = [...document.querySelectorAll(".mainAnimatedPage, .page")];
return pages.reverse().find((page) => {
if (page.closest("#ml-user") || page.classList.contains("hide")) return false;
const style = window.getComputedStyle(page);
return style.display !== "none" && style.visibility !== "hidden";
});
}
function userRulesHost() {
return ensureUserRulesShell().querySelector("#ml-user-host");
}
function ensureUserRulesShell() {
let shell = document.getElementById("ml-user-shell");
if (shell) {
positionUserRulesShell(shell);
shell.hidden = false;
return shell;
}
shell = document.createElement("div");
shell.id = "ml-user-shell";
shell.className = "mainAnimatedPage type-interior page";
shell.style.position = "fixed";
shell.style.left = "0";
shell.style.right = "0";
shell.style.bottom = "0";
shell.style.zIndex = "100";
shell.style.overflow = "auto";
shell.style.background = "var(--theme-body-bg, var(--background-color, #101010))";
shell.style.color = "inherit";
const host = document.createElement("div");
host.id = "ml-user-host";
host.className = "content-primary";
shell.appendChild(host);
document.body.appendChild(shell);
positionUserRulesShell(shell);
return shell;
}
function positionUserRulesShell(shell) {
const header = document.querySelector(".skinHeader, .headerTop, header");
const top = header ? Math.max(0, Math.round(header.getBoundingClientRect().bottom)) : 0;
shell.style.top = `${top}px`;
}
function closeUserRulesShellIfInactive() {
if (isUserRulesRoute()) return;
document.getElementById("ml-user-shell")?.remove();
}
function openUserRulesPage(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
event?.stopImmediatePropagation?.();
history.pushState({}, "", userRulesHref());
scheduleUserRulesPageRender();
}
function handleUserRulesClick(event) {
const link = event.target?.closest?.(`#${MENU_ID}, [data-ml-user-rules-link='1'], a[href*="multilang=1"]`);
if (!link) return;
openUserRulesPage(event);
}
function executeScript(script) {
return new Promise((resolve, reject) => {
const next = document.createElement("script");
[...script.attributes].forEach((attr) => next.setAttribute(attr.name, attr.value));
if (script.src) {
next.onload = () => resolve();
next.onerror = () => reject(new Error(`Failed to load ${script.src}`));
next.src = script.src;
} else {
next.text = script.textContent || "";
}
document.body.appendChild(next);
if (!script.src) resolve();
});
}
async function renderUserRulesPage(run) {
if (!isUserRulesRoute() || run !== userRulesRenderRun) return;
const host = userRulesHost();
if (!host) return;
if (host.dataset.mlUserRulesRendered === "1" && document.getElementById("ml-user")) return;
host.dataset.mlUserRulesRendered = "1";
host.innerHTML = "<div class=\"fieldDescription\">Loading Multilang settings...</div>";
const response = await fetch(`${BASE_PATH}/web/configurationpage?name=MultilangUser`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const fragment = await response.text();
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
const parsed = new DOMParser().parseFromString(fragment, "text/html");
const sourcePage = parsed.getElementById("ml-user");
const sourceContent = sourcePage?.querySelector?.(".content-primary");
if (!sourceContent) throw new Error("Multilang user page fragment missing");
const scripts = [...sourceContent.querySelectorAll("script")];
scripts.forEach((script) => script.remove());
const page = document.createElement("div");
page.id = "ml-user";
page.className = "ml-root";
while (sourceContent.firstChild) page.appendChild(sourceContent.firstChild);
host.replaceChildren(page);
for (const script of scripts) {
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
await executeScript(script);
}
}
function scheduleUserRulesPageRender() {
userRulesRenderRun++;
if (!isUserRulesRoute()) {
closeUserRulesShellIfInactive();
return;
}
const run = userRulesRenderRun;
[0, 100, 350].forEach((delay) => {
setTimeout(() => {
renderUserRulesPage(run).catch((err) => console.error("[Multilang] failed to render user rules page", err));
}, delay);
});
}
function captureLastItemId(target) {
const el = target?.closest?.("[data-id]");
const id = (el?.getAttribute("data-id") || "").match(/[0-9a-f]{32}/i)?.[0];
if (id) lastMenuItemId = id;
}
function collectDialogItemIds(form) {
const scan = (value) => String(value || "").match(/[0-9a-f]{32}/ig) || [];
return [...new Set([...scan(form?.querySelector?.(".fldSelectedItemIds")?.value), ...scan(lastMenuItemId), ...scan(location.hash)])];
}
function refreshItems(itemIds) {
const token = getClientToken();
const headers = token ? { "X-Emby-Token": token, "X-MediaBrowser-Token": token } : {};
itemIds.forEach((itemId) => {
fetch(`${BASE_PATH}/Multilang/RefreshItem/${itemId}?includeChildren=true${token ? `&token=${encodeURIComponent(token)}` : ""}`, { method: "POST", headers });
});
}
function openDebugItem(itemIds) {
const itemId = itemIds[0];
if (!itemId) return;
const token = getClientToken();
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
window.open(`${BASE_PATH}/Multilang/DebugUi/${encodeURIComponent(itemId)}${tokenParam}`, "_blank", "noopener");
}
function ensureRefreshDialogOption(root) {
const container = root?.closest?.(".formDialog") || root?.querySelector?.(".formDialog");
const title = container?.querySelector?.(".formDialogHeaderTitle");
const select = container?.querySelector?.("#selectMetadataRefreshMode");
if (!container || !title || !/refresh metadata/i.test(title.textContent || "") || !select || select.querySelector("option[value='multilang']")) return;
const option = document.createElement("option");
option.value = "multilang";
option.textContent = "Refresh Multilang metadata";
select.appendChild(option);
if (!container.querySelector("[data-ml-debug-item]")) {
const debugButton = document.createElement("button");
debugButton.type = "button";
debugButton.className = "raised button-submit emby-button";
debugButton.dataset.mlDebugItem = "1";
debugButton.textContent = "Debug Multilang";
debugButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
openDebugItem(collectDialogItemIds(container));
}, true);
(select.closest(".inputContainer") || select.parentElement || container).appendChild(debugButton);
}
const form = container.querySelector("form");
if (!form || form.dataset.mlHooked === "1") return;
form.dataset.mlHooked = "1";
form.addEventListener("submit", (event) => {
if (select.value !== "multilang") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
refreshItems(collectDialogItemIds(form));
(container.closest(".dialogContainer") || container).remove();
document.querySelectorAll(".dialogBackdrop,.dialog-overlay,.dialog-backdrop").forEach((el) => el.remove());
}, true);
}
function mlUrlFromImageUrl(rawUrl) {
const urlText = String(rawUrl || "").replace(/&amp;/gi, "&");
if (!/tag=ml(?::|%3a|%253a)/i.test(urlText)) return "";
const u = new URL(urlText, window.location.origin);
const tag = u.searchParams.get("tag") || "";
if (!tag.startsWith("ml:")) return "";
let url = decodeURIComponent(tag.slice(3)).trim();
if (!url) return "";
if (!url.startsWith("http://") && !url.startsWith("https://")) {
if (!url.startsWith("/")) url = "/" + url;
if (BASE_PATH && !url.toLowerCase().startsWith(BASE_PATH.toLowerCase() + "/")) url = BASE_PATH + url;
}
return url;
}
function rewriteElementImages(el) {
if (!el || el.nodeType !== 1) return;
["src", "data-src"].forEach((attr) => {
const current = el.getAttribute(attr);
const next = mlUrlFromImageUrl(current);
if (next) el.setAttribute(attr, next);
});
const style = el.getAttribute("style");
if (style) {
const next = style.replace(/url\((['"]?)([^'")]+)\1\)/g, (match, quote, url) => {
const ml = mlUrlFromImageUrl(url);
return ml ? `url("${ml}")` : match;
});
if (next !== style) el.setAttribute("style", next);
}
}
function scanNode(node) {
if (!node || node.nodeType !== 1) return;
rewriteElementImages(node);
node.querySelectorAll("[style],[src],[data-src]").forEach(rewriteElementImages);
ensureRefreshDialogOption(node);
}
const originalFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
const method = (init && init.method) || (input instanceof Request ? input.method : "GET");
if (String(method).toUpperCase() !== "GET") return originalFetch(input, init);
const proxyUrl = getItemsProxyUrl(input);
if (!proxyUrl) return originalFetch(input, init);
if (input instanceof Request) return originalFetch(new Request(proxyUrl, input));
return originalFetch(proxyUrl, init);
};
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
const proxyUrl = typeof method === "string" && method.toUpperCase() === "GET" ? getItemsProxyUrl(url) : null;
return proxyUrl ? originalXhrOpen.call(this, method, proxyUrl, arguments[2], arguments[3], arguments[4]) : originalXhrOpen.apply(this, arguments);
};
const observer = new MutationObserver((mutations) => mutations.forEach((m) => {
if (m.type === "childList") m.addedNodes.forEach(scanNode);
if (m.type === "attributes") rewriteElementImages(m.target);
}));
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "src", "data-src"] });
document.addEventListener("pointerdown", (event) => captureLastItemId(event.target));
document.addEventListener("click", handleUserRulesClick, true);
document.addEventListener("viewshow", () => requestAnimationFrame(() => {
ensureUserRulesLink();
scheduleUserRulesPageRender();
}));
window.addEventListener("hashchange", () => requestAnimationFrame(() => {
ensureUserRulesLink();
scheduleUserRulesPageRender();
}));
requestAnimationFrame(() => {
scanNode(document.documentElement);
ensureUserRulesLink();
scheduleUserRulesPageRender();
});
})();