Initial jellyfin-multilang rewrite
This commit is contained in:
+18
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "9.0.116",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(/&/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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user