From 25088ce91d308cbac8655aa56d7d2c7d1ca77797 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Mon, 20 Jul 2026 02:17:08 +0000 Subject: [PATCH] Improve proxy cache behavior and diagnostics --- .../Api/MultilangController.cs | 68 ++++-- .../Configuration/PluginConfiguration.cs | 4 + .../Configuration/configPage.html | 213 ++++++++++++++---- .../Configuration/shared.css | 21 ++ .../PluginServiceRegistrator.cs | 2 + .../Services/ItemsProxyCache.cs | 7 +- .../Services/ItemsProxyPrecacheService.cs | 110 +++++++++ .../Services/ItemsProxyRequestCoalescer.cs | 68 ++++++ .../ItemsProxyRequestBuilderTests.cs | 24 ++ .../ItemsProxyRequestCoalescerTests.cs | 52 +++++ 10 files changed, 504 insertions(+), 65 deletions(-) create mode 100644 src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs create mode 100644 src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestCoalescer.cs create mode 100644 tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs diff --git a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs index a2df1f7..851a45c 100644 --- a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs +++ b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs @@ -49,6 +49,8 @@ public sealed class MultilangController : ControllerBase private readonly RefreshService _refreshService; private readonly FanartClient _fanartClient; private readonly ItemsProxyCache _itemsProxyCache; + private readonly ItemsProxyRequestCoalescer _itemsProxyRequestCoalescer; + private readonly ItemsProxyPrecacheService _itemsProxyPrecacheService; private readonly ItemsProxyTransformer _itemsProxyTransformer; private readonly AssetStorageService _assetStorage; private readonly MultilangBackupService _backupService; @@ -67,6 +69,8 @@ public sealed class MultilangController : ControllerBase RefreshService refreshService, FanartClient fanartClient, ItemsProxyCache itemsProxyCache, + ItemsProxyRequestCoalescer itemsProxyRequestCoalescer, + ItemsProxyPrecacheService itemsProxyPrecacheService, ItemsProxyTransformer itemsProxyTransformer, AssetStorageService assetStorage, MultilangBackupService backupService) @@ -84,6 +88,8 @@ public sealed class MultilangController : ControllerBase _refreshService = refreshService; _fanartClient = fanartClient; _itemsProxyCache = itemsProxyCache; + _itemsProxyRequestCoalescer = itemsProxyRequestCoalescer; + _itemsProxyPrecacheService = itemsProxyPrecacheService; _itemsProxyTransformer = itemsProxyTransformer; _assetStorage = assetStorage; _backupService = backupService; @@ -264,7 +270,7 @@ public sealed class MultilangController : ControllerBase } [HttpGet("ItemsProxy")] - public async Task ItemsProxy([FromQuery] string url, CancellationToken cancellationToken) + public async Task ItemsProxy([FromQuery] string url, [FromQuery] bool precache, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(url)) return BadRequest(new { Error = "MissingUrl" }); @@ -286,6 +292,18 @@ public sealed class MultilangController : ControllerBase var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0; var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes)); + if (!precache && cacheAllowed) + { + var baseUri = new Uri($"{Request.Scheme}://{Request.Host}{Request.PathBase}/"); + _itemsProxyPrecacheService.ObserveUserActivity( + baseUri, + userId, + token, + proxyRequest.Controls.ClientLocale, + cfg.PrecacheMovieLibraries, + cfg.PrecacheTvShowLibraries); + } + if (cacheAllowed && _itemsProxyCache.TryGet(proxyRequest.CacheKey, cacheTtl, out var cached)) { totalSw.Stop(); @@ -320,17 +338,28 @@ public sealed class MultilangController : ControllerBase } var upstreamSw = Stopwatch.StartNew(); - var http = _httpClientFactory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream); - request.Headers.TryAddWithoutValidation("Authorization", $"MediaBrowser Token=\"{token}\""); - request.Headers.TryAddWithoutValidation("X-Emby-Token", token); - request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token); - using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var upstream = await _itemsProxyRequestCoalescer.GetOrFetchAsync( + proxyRequest.NormalizedUrlForCache, + async sharedCancellationToken => + { + var http = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream); + request.Headers.TryAddWithoutValidation("Authorization", $"MediaBrowser Token=\"{token}\""); + request.Headers.TryAddWithoutValidation("X-Emby-Token", token); + request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token); + using var response = await http.SendAsync(request, sharedCancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(sharedCancellationToken).ConfigureAwait(false); + return new ItemsProxyUpstreamResponse( + (int)response.StatusCode, + body, + response.Content.Headers.ContentType?.ToString() ?? "application/json"); + }, + cancellationToken).ConfigureAwait(false); upstreamSw.Stop(); + var body = upstream.Response.Body; string[] responseItemIds = []; var transformMs = 0L; - if (response.IsSuccessStatusCode && rules.Enabled) + if (upstream.Response.IsSuccessStatusCode && rules.Enabled) { var transformSw = Stopwatch.StartNew(); var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls); @@ -341,11 +370,11 @@ public sealed class MultilangController : ControllerBase } totalSw.Stop(); - var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"; + var contentType = upstream.Response.ContentType; var bodySizeBytes = System.Text.Encoding.UTF8.GetByteCount(body); var cacheStored = false; if (cacheAllowed && - response.IsSuccessStatusCode && + upstream.Response.IsSuccessStatusCode && responseItemIds.Length > 0 && totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs)) { @@ -363,7 +392,7 @@ public sealed class MultilangController : ControllerBase if (cacheAllowed && !cacheStored && - response.IsSuccessStatusCode && + upstream.Response.IsSuccessStatusCode && responseItemIds.Length > 0) { _itemsProxyCache.StoreMicro( @@ -381,16 +410,17 @@ public sealed class MultilangController : ControllerBase proxyRequest.NormalizedUrlForCache, cacheHit: false, cacheStored: cacheStored, - statusCode: (int)response.StatusCode, + statusCode: upstream.Response.StatusCode, itemCount: responseItemIds.Length, totalMs: totalSw.ElapsedMilliseconds, upstreamMs: upstreamSw.ElapsedMilliseconds, transformMs: transformMs, - sizeBytes: bodySizeBytes); + sizeBytes: bodySizeBytes, + inFlightCoalesced: upstream.JoinedExistingRequest); return new ContentResult { - StatusCode = (int)response.StatusCode, + StatusCode = upstream.Response.StatusCode, Content = body, ContentType = contentType }; @@ -1412,6 +1442,10 @@ public sealed class AdminConfigDto public int ItemsProxyCacheMaxMiB { get; set; } + public bool PrecacheMovieLibraries { get; set; } = true; + + public bool PrecacheTvShowLibraries { get; set; } = true; + public string AssetStorageMode { get; set; } = "url"; public bool IgnoreArticlesWhenSorting { get; set; } = true; @@ -1438,6 +1472,8 @@ public sealed class AdminConfigDto ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs, ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes, ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB, + PrecacheMovieLibraries = cfg.PrecacheMovieLibraries, + PrecacheTvShowLibraries = cfg.PrecacheTvShowLibraries, AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode), IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting, JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback, @@ -1462,6 +1498,8 @@ public sealed class AdminConfigDto ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs), ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes), ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB), + PrecacheMovieLibraries = PrecacheMovieLibraries, + PrecacheTvShowLibraries = PrecacheTvShowLibraries, AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode), IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting, JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty, diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs b/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs index 7081333..99d7232 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs +++ b/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs @@ -27,6 +27,10 @@ public sealed class PluginConfiguration : BasePluginConfiguration public int ItemsProxyCacheMaxMiB { get; set; } = 50; + public bool PrecacheMovieLibraries { get; set; } = true; + + public bool PrecacheTvShowLibraries { get; set; } = true; + public string AssetStorageMode { get; set; } = "url"; public bool IgnoreArticlesWhenSorting { get; set; } = true; diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html index 54bad28..b36a2c5 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html +++ b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html @@ -78,17 +78,28 @@
Refresh activity +
Live diagnostics are kept in memory and reset when Jellyfin restarts. While a scan runs, the scan details update live. Otherwise they describe the most recently completed scan in this Jellyfin session.
-

Current scan

-
-

Queue

-
-

Provider HTTP

-
+
+ Latest scan +
A completed scan is idle because no scan is running now. Its result and counters remain here until another scan starts.
+
+
+
+ Queue +
Pending entries have not started. Recently completed entries are history only and are no longer queued.
+
+
+
+ Provider HTTP +
Counts are final provider outcomes since this Jellyfin session began. A request may retry up to three times; “attempt 1” means it completed without a retry.
+
+
Items cache +
This is an in-memory cache of slow ItemsProxy responses. Entries expire after the configured TTL, are invalidated when their items change, and disappear when Jellyfin restarts.
@@ -102,14 +113,25 @@
+
+ + +
The first proxied request after 30 minutes away starts this in the background. Only libraries the user can see are included. Uncheck both to disable pre-caching.
+
-

Stored responses

-
-

Recent requests

-
+
+ Cached responses +
These complete responses were cached because they met the configured threshold. Their ages update while this page is open.
+
+
+
+ Recent proxy requests +
The 100 most recent proxy requests, kept for this Jellyfin session. “In-flight hit” waited for a matching upstream request already in progress. “Cache miss: cached” means a cache miss that was added to the full cache; “cache miss” means the response was returned without being retained.
+
+
@@ -423,6 +445,8 @@ 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.PrecacheMovieLibraries = document.getElementById("ml-precache-movies").checked; + state.config.PrecacheTvShowLibraries = document.getElementById("ml-precache-tvshows").checked; state.config.AssetStorageMode = nextStorageMode; state.config.IgnoreArticlesWhenSorting = document.getElementById("ml-ignore-articles").checked; state.config.JellyfinTitleLanguageFallback = document.getElementById("ml-jellyfin-title-language").value; @@ -447,6 +471,8 @@ 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-precache-movies").checked = state.config.PrecacheMovieLibraries !== false && state.config.precacheMovieLibraries !== false; + document.getElementById("ml-precache-tvshows").checked = state.config.PrecacheTvShowLibraries !== false && state.config.precacheTvShowLibraries !== false; document.getElementById("ml-asset-storage").value = (state.config.AssetStorageMode || state.config.assetStorageMode || "url").toLowerCase() === "local" ? "local" : "url"; document.getElementById("ml-ignore-articles").checked = state.config.IgnoreArticlesWhenSorting !== false && state.config.ignoreArticlesWhenSorting !== false; document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging; @@ -462,14 +488,51 @@ loadExportInfo(); } + function setDiagnosticsSummary(id, text) { + document.getElementById(id).textContent = text; + } + + function formatAge(seconds) { + seconds = Math.max(0, Math.floor(Number(seconds) || 0)); + if (seconds < 60) return seconds + " s old"; + var minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + " min " + (seconds % 60) + " s old"; + var hours = Math.floor(minutes / 60); + if (hours < 24) return hours + " h " + (minutes % 60) + " min old"; + return Math.floor(hours / 24) + " d " + (hours % 24) + " h old"; + } + + function makeAgeChip(seconds) { + var chip = make("span", "ml-diagnostic-chip ml-age", ""); + chip.dataset.ageSeconds = String(Math.max(0, Number(seconds) || 0)); + chip.dataset.ageRenderedAt = String(Date.now()); + updateAgeChip(chip); + return chip; + } + + function updateAgeChip(chip) { + var initialAge = Number(chip.dataset.ageSeconds || 0); + var renderedAt = Number(chip.dataset.ageRenderedAt || Date.now()); + chip.textContent = formatAge(initialAge + (Date.now() - renderedAt) / 1000); + } + + function ensureAgeTicker() { + if (page._mlAgeTicker) return; + page._mlAgeTicker = window.setInterval(function () { + Array.prototype.forEach.call(page.querySelectorAll(".ml-age"), updateAgeChip); + }, 1000); + } + function renderCacheEntries(entries, error) { var root = document.getElementById("ml-cache-entries"); root.replaceChildren(); if (error) { + setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses"); root.appendChild(make("div", "ml-error", error)); return; } + setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses (" + (entries ? entries.length : 0) + ")"); if (!entries || entries.length === 0) { root.appendChild(make("div", "ml-muted", "No cache entries.")); return; @@ -482,7 +545,7 @@ (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" + makeAgeChip(entry.AgeSeconds || entry.ageSeconds || 0) ])); }); } @@ -499,10 +562,12 @@ var root = document.getElementById("ml-proxy-requests"); root.replaceChildren(); if (error) { + setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests"); root.appendChild(make("div", "ml-error", error)); return; } + setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests (" + (entries ? entries.length : 0) + " of last 100)"); if (!entries || entries.length === 0) { root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests.")); return; @@ -510,15 +575,20 @@ entries.forEach(function (entry) { var url = entry.Url || entry.url || ""; + var cacheState = (entry.CacheHit || entry.cacheHit) + ? "cache hit" + : ((entry.InFlightCoalesced || entry.inFlightCoalesced) + ? "in-flight hit" + : ((entry.CacheStored || entry.cacheStored) ? "cache miss: cached" : "cache miss")); root.appendChild(makeDiagnosticRow(url, [ - (entry.CacheHit || entry.cacheHit) ? "hit" : ((entry.CacheStored || entry.cacheStored) ? "stored" : "miss"), + cacheState, "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" + makeAgeChip(entry.AgeSeconds || entry.ageSeconds || 0) ])); }); } @@ -527,7 +597,9 @@ 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); + var chip = part && part.nodeType === 1 + ? part + : 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); @@ -545,6 +617,7 @@ } function loadCacheDiagnostics() { + ensureAgeTicker(); loadCacheEntries(); loadProxyRequests(); } @@ -569,6 +642,18 @@ return new Date(value * 1000).toLocaleString(); } + function scanMode(value) { + return String(value || "").toLowerCase() === "missing" ? "missing data" : String(value || "full data").toLowerCase(); + } + + function scanSource(value) { + return String(value || "").toLowerCase() === "scheduled" ? "scheduled task" : String(value || "manual").toLowerCase(); + } + + function diagnosticHeading(text) { + return make("div", "ml-diagnostic-heading", text); + } + function renderRefreshDiagnostics(data, error) { var scanRoot = document.getElementById("ml-refresh-scan"); var queueRoot = document.getElementById("ml-refresh-queue"); @@ -578,6 +663,7 @@ httpRoot.replaceChildren(); if (error) { + setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan"); scanRoot.appendChild(make("div", "ml-error", error)); return; } @@ -587,25 +673,40 @@ 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 scanStartedAt = Number(prop(scan, "StartedAt", "startedAt", 0) || 0); + var scanSuccess = !!prop(scan, "Success", "success", false); + if (!scanStartedAt) { + setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan"); + scanRoot.appendChild(make("div", "ml-muted", "No scan diagnostics are available since Jellyfin last started.")); + } else { + var result = running ? "running" : (scanSuccess ? "completed" : "failed"); + setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan (" + result + ")"); + scanRoot.appendChild(makeDiagnosticRow("Started: " + formatUnixTime(scanStartedAt), [ + running ? "running now" : "not running", + "result: " + result, + "mode: " + scanMode(prop(scan, "Mode", "mode", "")), + "source: " + scanSource(prop(scan, "Source", "source", "")), + "finished: " + formatUnixTime(prop(scan, "FinishedAt", "finishedAt", 0)), + "duration: " + Number(prop(scan, "ElapsedMs", "elapsedMs", 0) || 0) + " ms" + ])); + scanRoot.appendChild(makeDiagnosticRow("Scan counters", [ + "items checked: " + Number(prop(scan, "Items", "items", 0) || 0), + "eligible: " + Number(prop(scan, "Due", "due", 0) || 0), + "fetched: " + Number(prop(scan, "Refreshed", "refreshed", 0) || 0), + "not due: " + Number(prop(scan, "SkippedNotDue", "skippedNotDue", 0) || 0), + "no provider data: " + Number(prop(scan, "SkippedNoData", "skippedNoData", 0) || 0), + "new: " + Number(prop(scan, "New", "new", 0) || 0), + "removed: " + Number(prop(scan, "Deleted", "deleted", 0) || 0), + "progress: " + Number(prop(scan, "CurrentIndex", "currentIndex", 0) || 0) + "/" + Number(prop(scan, "Items", "items", 0) || 0) + ])); + } 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", "") || "") + scanRoot.appendChild(makeDiagnosticRow(String(prop(scan, "CurrentName", "currentName", "") || currentItem), [ + running ? "current item" : "last item processed", + "kind: " + String(prop(scan, "CurrentKind", "currentKind", "") || "unknown"), + "action: " + String(prop(scan, "CurrentAction", "currentAction", "") || "unknown"), + "ID: " + currentItem ])); } var scanError = String(prop(scan, "Error", "error", "") || ""); @@ -614,26 +715,37 @@ var active = prop(queue, "Active", "active", null); var queued = prop(queue, "Queued", "queued", []) || []; var recent = prop(queue, "Recent", "recent", []) || []; + setDiagnosticsSummary("ml-refresh-queue-summary", "Queue (" + Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0) + " pending)"); 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", "")) + "running now", + "source: " + String(prop(active, "SourceTier", "sourceTier", "")), + "type: " + String(prop(active, "WorkClass", "workClass", "")), + "refresh: " + String(prop(active, "JobType", "jobType", "")) ])); } - queueRoot.appendChild(makeDiagnosticRow("Queued refresh items", [ - "queued", - String(Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0)) - ])); + if (queued.length === 0) { + queueRoot.appendChild(make("div", "ml-muted", "No pending refresh items.")); + } else { + queueRoot.appendChild(diagnosticHeading("Pending refresh items")); + queued.slice(0, 10).forEach(function (item) { + queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [ + "pending", + "source: " + String(prop(item, "SourceTier", "sourceTier", "")), + "type: " + String(prop(item, "WorkClass", "workClass", "")), + "refresh: " + String(prop(item, "JobType", "jobType", "")) + ])); + }); + } + if (recent.length > 0) queueRoot.appendChild(diagnosticHeading("Recently completed refreshes (not pending)")); 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)) + prop(item, "Ok", "ok", false) ? "completed" : "failed", + "source: " + String(prop(item, "SourceTier", "sourceTier", "")), + "type: " + String(prop(item, "WorkClass", "workClass", "")), + "refresh: " + String(prop(item, "JobType", "jobType", "")), + "duration: " + Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms", + "finished: " + formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0)) ])); var itemError = String(prop(item, "Error", "error", "") || ""); if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError)); @@ -641,23 +753,26 @@ var counts = prop(providerHttp, "Counts", "counts", []) || []; var providerRecent = prop(providerHttp, "Recent", "recent", []) || []; + setDiagnosticsSummary("ml-provider-http-summary", "Provider HTTP (" + counts.length + " outcome totals, " + providerRecent.length + " recent)"); if (counts.length === 0 && providerRecent.length === 0) { httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup.")); return; } + if (counts.length > 0) httpRoot.appendChild(diagnosticHeading("Outcome totals since plugin startup")); counts.forEach(function (count) { httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [ - "count", + "final outcome", String(prop(count, "Outcome", "outcome", "")), - String(Number(prop(count, "Count", "count", 0) || 0)) + "count: " + String(Number(prop(count, "Count", "count", 0) || 0)) ])); }); + if (providerRecent.length > 0) httpRoot.appendChild(diagnosticHeading("Most recent provider requests")); 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)) + "completed on attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0), + "at " + formatUnixTime(prop(entry, "At", "at", 0)) ])); }); } diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/shared.css b/src/Jellyfin.Plugin.Multilang/Configuration/shared.css index 9a90876..40265b5 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/shared.css +++ b/src/Jellyfin.Plugin.Multilang/Configuration/shared.css @@ -764,6 +764,27 @@ select.emby-select option:checked { font-weight: 600; } +.ml-diagnostics { + margin-top: 1rem; +} + +.ml-diagnostics > summary { + cursor: pointer; + font-size: 1rem; + font-weight: 600; +} + +.ml-diagnostics > .fieldDescription { + margin-top: .45rem; +} + +.ml-diagnostic-heading { + margin-top: .35rem; + color: rgba(255, 255, 255, .72); + font-size: .9rem; + font-weight: 600; +} + .ml-diagnostic-row { display: flex; flex-direction: column; diff --git a/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs b/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs index f07aa66..a7f97f4 100644 --- a/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs +++ b/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs @@ -26,6 +26,8 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs index 34dfe9f..abf1877 100644 --- a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs @@ -33,6 +33,8 @@ public sealed class ItemsProxyCache public required bool CacheStored { get; init; } + public required bool InFlightCoalesced { get; init; } + public required int StatusCode { get; init; } public required int ItemCount { get; init; } @@ -229,7 +231,8 @@ public sealed class ItemsProxyCache long totalMs, long upstreamMs, long transformMs, - long sizeBytes) + long sizeBytes, + bool inFlightCoalesced = false) { lock (_lock) { @@ -239,6 +242,7 @@ public sealed class ItemsProxyCache CreatedUtc = DateTimeOffset.UtcNow, CacheHit = cacheHit, CacheStored = cacheStored, + InFlightCoalesced = inFlightCoalesced, StatusCode = statusCode, ItemCount = itemCount, TotalMs = totalMs, @@ -265,6 +269,7 @@ public sealed class ItemsProxyCache AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds, e.CacheHit, e.CacheStored, + e.InFlightCoalesced, e.StatusCode, e.ItemCount, e.TotalMs, diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs new file mode 100644 index 0000000..9715045 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs @@ -0,0 +1,110 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Multilang.Services; + +public sealed class ItemsProxyPrecacheService +{ + private static readonly TimeSpan IdleThreshold = TimeSpan.FromMinutes(30); + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly object _lock = new(); + private readonly Dictionary _lastSeen = new(StringComparer.Ordinal); + private readonly HashSet _pendingUsers = new(StringComparer.Ordinal); + + public ItemsProxyPrecacheService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public void ObserveUserActivity( + Uri baseUri, + string userId, + string token, + string clientLocale, + bool precacheMovieLibraries, + bool precacheTvShowLibraries) + { + if (!precacheMovieLibraries && !precacheTvShowLibraries) + return; + + var now = DateTimeOffset.UtcNow; + lock (_lock) + { + var wasRecentlyActive = _lastSeen.TryGetValue(userId, out var lastSeen) && now - lastSeen < IdleThreshold; + _lastSeen[userId] = now; + if (wasRecentlyActive || !_pendingUsers.Add(userId)) + return; + } + + _ = PrecacheAsync(baseUri, userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries); + } + + private async Task PrecacheAsync( + Uri baseUri, + string userId, + string token, + string clientLocale, + bool precacheMovieLibraries, + bool precacheTvShowLibraries) + { + try + { + var views = await GetJsonAsync(new Uri(baseUri, $"Users/{userId}/Views"), token).ConfigureAwait(false); + var items = views["Items"]?.AsArray() ?? []; + var targets = items + .Select(view => new + { + Id = view?["Id"]?.GetValue() ?? string.Empty, + CollectionType = view?["CollectionType"]?.GetValue() ?? string.Empty + }) + .Where(view => !string.IsNullOrWhiteSpace(view.Id)) + .Where(view => + (precacheMovieLibraries && view.CollectionType.Equals("movies", StringComparison.OrdinalIgnoreCase)) || + (precacheTvShowLibraries && view.CollectionType.Equals("tvshows", StringComparison.OrdinalIgnoreCase))) + .ToArray(); + + foreach (var target in targets) + { + var itemType = target.CollectionType.Equals("movies", StringComparison.OrdinalIgnoreCase) ? "Movie" : "Series"; + var itemsUrl = $"/Users/{userId}/Items?ParentId={Uri.EscapeDataString(target.Id)}&IncludeItemTypes={itemType}&Recursive=true&SortBy=SortName&SortOrder=Ascending&StartIndex=0&Limit=100"; + var endpoint = "Multilang/ItemsProxy?url=" + Uri.EscapeDataString(itemsUrl) + "&precache=true"; + if (!string.IsNullOrWhiteSpace(clientLocale)) + endpoint += "&mlLocale=" + Uri.EscapeDataString(clientLocale); + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, endpoint)); + AddTokenHeaders(request, token); + using var response = await _httpClientFactory.CreateClient().SendAsync(request, CancellationToken.None).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + _logger.LogWarning("Multilang pre-cache failed user={UserId} library={LibraryId} status={StatusCode}", userId, target.Id, (int)response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Multilang pre-cache failed for user={UserId}", userId); + } + finally + { + lock (_lock) + _pendingUsers.Remove(userId); + } + } + + private async Task GetJsonAsync(Uri uri, string token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + AddTokenHeaders(request, token); + using var response = await _httpClientFactory.CreateClient().SendAsync(request, CancellationToken.None).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(CancellationToken.None).ConfigureAwait(false); + return JsonNode.Parse(body)?.AsObject() ?? throw new InvalidOperationException("Jellyfin views response was not a JSON object."); + } + + private static void AddTokenHeaders(HttpRequestMessage request, string token) + { + request.Headers.TryAddWithoutValidation("Authorization", $"MediaBrowser Token=\"{token}\""); + request.Headers.TryAddWithoutValidation("X-Emby-Token", token); + request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token); + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestCoalescer.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestCoalescer.cs new file mode 100644 index 0000000..6e0ad2c --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestCoalescer.cs @@ -0,0 +1,68 @@ +namespace Jellyfin.Plugin.Multilang.Services; + +public readonly record struct ItemsProxyUpstreamResponse(int StatusCode, string Body, string ContentType) +{ + public bool IsSuccessStatusCode => StatusCode is >= 200 and < 300; +} + +public readonly record struct CoalescedItemsProxyResponse(ItemsProxyUpstreamResponse Response, bool JoinedExistingRequest); + +public sealed class ItemsProxyRequestCoalescer +{ + private sealed class InFlightRequest + { + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private readonly object _lock = new(); + private readonly Dictionary _requests = new(StringComparer.Ordinal); + + public async Task GetOrFetchAsync( + string key, + Func> fetch, + CancellationToken cancellationToken) + { + InFlightRequest request; + var joinedExistingRequest = true; + lock (_lock) + { + if (_requests.TryGetValue(key, out var existing) && existing is not null) + { + request = existing; + } + else + { + request = new InFlightRequest(); + _requests.Add(key, request); + joinedExistingRequest = false; + _ = CompleteAsync(key, request, fetch); + } + } + + var response = await request.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + return new CoalescedItemsProxyResponse(response, joinedExistingRequest); + } + + private async Task CompleteAsync( + string key, + InFlightRequest request, + Func> fetch) + { + try + { + request.Completion.TrySetResult(await fetch(CancellationToken.None).ConfigureAwait(false)); + } + catch (Exception ex) + { + request.Completion.TrySetException(ex); + } + finally + { + lock (_lock) + { + if (_requests.TryGetValue(key, out var current) && ReferenceEquals(current, request)) + _requests.Remove(key); + } + } + } +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs index 0f14b12..4c9eca0 100644 --- a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs @@ -68,6 +68,30 @@ public sealed class ItemsProxyRequestBuilderTests Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey); } + [Fact] + public void BuildUsesOneUpstreamKeyForDifferentLocalPagingControls() + { + var request = HttpRequest(); + + var first = ItemsProxyRequestBuilder.Build( + request, + "/Items?SortBy=SortName&StartIndex=0&Limit=100", + "token", + UserId, + multilangEnabled: true); + var second = ItemsProxyRequestBuilder.Build( + request, + "/Items?SortBy=SortName&StartIndex=100&Limit=100", + "token", + UserId, + multilangEnabled: true); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache); + Assert.NotEqual(first.CacheKey, second.CacheKey); + } + [Fact] public void BuildLeavesSortAndPagingUpstreamWhenMultilangDisabled() { diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs new file mode 100644 index 0000000..3c5c4c7 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs @@ -0,0 +1,52 @@ +using Jellyfin.Plugin.Multilang.Services; + +namespace Jellyfin.Plugin.Multilang.Tests; + +public sealed class ItemsProxyRequestCoalescerTests +{ + [Fact] + public async Task MatchingRequestsShareOneInFlightFetch() + { + var coalescer = new ItemsProxyRequestCoalescer(); + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fetches = 0; + + Task Fetch(CancellationToken _) + { + fetches++; + return response.Task; + } + + var first = coalescer.GetOrFetchAsync("user|/Items", Fetch, CancellationToken.None); + var second = coalescer.GetOrFetchAsync("user|/Items", Fetch, CancellationToken.None); + + Assert.Equal(1, fetches); + response.SetResult(new ItemsProxyUpstreamResponse(200, "{}", "application/json")); + + var firstResult = await first; + var secondResult = await second; + Assert.False(firstResult.JoinedExistingRequest); + Assert.True(secondResult.JoinedExistingRequest); + Assert.Equal("{}", firstResult.Response.Body); + Assert.Equal(firstResult.Response, secondResult.Response); + } + + [Fact] + public async Task CancellingOneWaiterDoesNotCancelTheSharedFetch() + { + var coalescer = new ItemsProxyRequestCoalescer(); + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + + var cancelledWaiter = coalescer.GetOrFetchAsync("user|/Items", _ => response.Task, cancellation.Token); + var activeWaiter = coalescer.GetOrFetchAsync("user|/Items", _ => response.Task, CancellationToken.None); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => cancelledWaiter); + response.SetResult(new ItemsProxyUpstreamResponse(200, "{}", "application/json")); + + var result = await activeWaiter; + Assert.True(result.JoinedExistingRequest); + Assert.Equal(200, result.Response.StatusCode); + } +}