Improve proxy cache behavior and diagnostics

This commit is contained in:
ajp_anton
2026-07-20 02:17:08 +00:00
parent a97e2bb37f
commit 4bd6277635
10 changed files with 503 additions and 64 deletions
@@ -49,6 +49,8 @@ public sealed class MultilangController : ControllerBase
private readonly RefreshService _refreshService; private readonly RefreshService _refreshService;
private readonly FanartClient _fanartClient; private readonly FanartClient _fanartClient;
private readonly ItemsProxyCache _itemsProxyCache; private readonly ItemsProxyCache _itemsProxyCache;
private readonly ItemsProxyRequestCoalescer _itemsProxyRequestCoalescer;
private readonly ItemsProxyPrecacheService _itemsProxyPrecacheService;
private readonly ItemsProxyTransformer _itemsProxyTransformer; private readonly ItemsProxyTransformer _itemsProxyTransformer;
private readonly AssetStorageService _assetStorage; private readonly AssetStorageService _assetStorage;
private readonly MultilangBackupService _backupService; private readonly MultilangBackupService _backupService;
@@ -67,6 +69,8 @@ public sealed class MultilangController : ControllerBase
RefreshService refreshService, RefreshService refreshService,
FanartClient fanartClient, FanartClient fanartClient,
ItemsProxyCache itemsProxyCache, ItemsProxyCache itemsProxyCache,
ItemsProxyRequestCoalescer itemsProxyRequestCoalescer,
ItemsProxyPrecacheService itemsProxyPrecacheService,
ItemsProxyTransformer itemsProxyTransformer, ItemsProxyTransformer itemsProxyTransformer,
AssetStorageService assetStorage, AssetStorageService assetStorage,
MultilangBackupService backupService) MultilangBackupService backupService)
@@ -84,6 +88,8 @@ public sealed class MultilangController : ControllerBase
_refreshService = refreshService; _refreshService = refreshService;
_fanartClient = fanartClient; _fanartClient = fanartClient;
_itemsProxyCache = itemsProxyCache; _itemsProxyCache = itemsProxyCache;
_itemsProxyRequestCoalescer = itemsProxyRequestCoalescer;
_itemsProxyPrecacheService = itemsProxyPrecacheService;
_itemsProxyTransformer = itemsProxyTransformer; _itemsProxyTransformer = itemsProxyTransformer;
_assetStorage = assetStorage; _assetStorage = assetStorage;
_backupService = backupService; _backupService = backupService;
@@ -264,7 +270,7 @@ public sealed class MultilangController : ControllerBase
} }
[HttpGet("ItemsProxy")] [HttpGet("ItemsProxy")]
public async Task<IActionResult> ItemsProxy([FromQuery] string url, CancellationToken cancellationToken) public async Task<IActionResult> ItemsProxy([FromQuery] string url, [FromQuery] bool precache, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))
return BadRequest(new { Error = "MissingUrl" }); return BadRequest(new { Error = "MissingUrl" });
@@ -286,6 +292,18 @@ public sealed class MultilangController : ControllerBase
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0; var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0;
var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes)); 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)) if (cacheAllowed && _itemsProxyCache.TryGet(proxyRequest.CacheKey, cacheTtl, out var cached))
{ {
totalSw.Stop(); totalSw.Stop();
@@ -320,16 +338,27 @@ public sealed class MultilangController : ControllerBase
} }
var upstreamSw = Stopwatch.StartNew(); var upstreamSw = Stopwatch.StartNew();
var upstream = await _itemsProxyRequestCoalescer.GetOrFetchAsync(
proxyRequest.NormalizedUrlForCache,
async sharedCancellationToken =>
{
var http = _httpClientFactory.CreateClient(); var http = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream); using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream);
request.Headers.TryAddWithoutValidation("X-Emby-Token", token); request.Headers.TryAddWithoutValidation("X-Emby-Token", token);
request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token); request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await http.SendAsync(request, sharedCancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).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(); upstreamSw.Stop();
var body = upstream.Response.Body;
string[] responseItemIds = []; string[] responseItemIds = [];
var transformMs = 0L; var transformMs = 0L;
if (response.IsSuccessStatusCode && rules.Enabled) if (upstream.Response.IsSuccessStatusCode && rules.Enabled)
{ {
var transformSw = Stopwatch.StartNew(); var transformSw = Stopwatch.StartNew();
var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls); var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls);
@@ -340,11 +369,11 @@ public sealed class MultilangController : ControllerBase
} }
totalSw.Stop(); 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 bodySizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
var cacheStored = false; var cacheStored = false;
if (cacheAllowed && if (cacheAllowed &&
response.IsSuccessStatusCode && upstream.Response.IsSuccessStatusCode &&
responseItemIds.Length > 0 && responseItemIds.Length > 0 &&
totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs)) totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs))
{ {
@@ -362,7 +391,7 @@ public sealed class MultilangController : ControllerBase
if (cacheAllowed && if (cacheAllowed &&
!cacheStored && !cacheStored &&
response.IsSuccessStatusCode && upstream.Response.IsSuccessStatusCode &&
responseItemIds.Length > 0) responseItemIds.Length > 0)
{ {
_itemsProxyCache.StoreMicro( _itemsProxyCache.StoreMicro(
@@ -380,16 +409,17 @@ public sealed class MultilangController : ControllerBase
proxyRequest.NormalizedUrlForCache, proxyRequest.NormalizedUrlForCache,
cacheHit: false, cacheHit: false,
cacheStored: cacheStored, cacheStored: cacheStored,
statusCode: (int)response.StatusCode, statusCode: upstream.Response.StatusCode,
itemCount: responseItemIds.Length, itemCount: responseItemIds.Length,
totalMs: totalSw.ElapsedMilliseconds, totalMs: totalSw.ElapsedMilliseconds,
upstreamMs: upstreamSw.ElapsedMilliseconds, upstreamMs: upstreamSw.ElapsedMilliseconds,
transformMs: transformMs, transformMs: transformMs,
sizeBytes: bodySizeBytes); sizeBytes: bodySizeBytes,
inFlightCoalesced: upstream.JoinedExistingRequest);
return new ContentResult return new ContentResult
{ {
StatusCode = (int)response.StatusCode, StatusCode = upstream.Response.StatusCode,
Content = body, Content = body,
ContentType = contentType ContentType = contentType
}; };
@@ -1443,6 +1473,10 @@ public sealed class AdminConfigDto
public int ItemsProxyCacheMaxMiB { get; set; } public int ItemsProxyCacheMaxMiB { get; set; }
public bool PrecacheMovieLibraries { get; set; } = true;
public bool PrecacheTvShowLibraries { get; set; } = true;
public string AssetStorageMode { get; set; } = "url"; public string AssetStorageMode { get; set; } = "url";
public bool IgnoreArticlesWhenSorting { get; set; } = true; public bool IgnoreArticlesWhenSorting { get; set; } = true;
@@ -1469,6 +1503,8 @@ public sealed class AdminConfigDto
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs, ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes, ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB, ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
PrecacheMovieLibraries = cfg.PrecacheMovieLibraries,
PrecacheTvShowLibraries = cfg.PrecacheTvShowLibraries,
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode), AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting, IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback, JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback,
@@ -1493,6 +1529,8 @@ public sealed class AdminConfigDto
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs), ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes), ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB), ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
PrecacheMovieLibraries = PrecacheMovieLibraries,
PrecacheTvShowLibraries = PrecacheTvShowLibraries,
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode), AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting, IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty, JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty,
@@ -27,6 +27,10 @@ public sealed class PluginConfiguration : BasePluginConfiguration
public int ItemsProxyCacheMaxMiB { get; set; } = 50; 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 string AssetStorageMode { get; set; } = "url";
public bool IgnoreArticlesWhenSorting { get; set; } = true; public bool IgnoreArticlesWhenSorting { get; set; } = true;
@@ -78,17 +78,28 @@
<fieldset class="ml-section"> <fieldset class="ml-section">
<legend>Refresh activity</legend> <legend>Refresh activity</legend>
<div class="fieldDescription">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.</div>
<button id="ml-refresh-activity-refresh" is="emby-button" type="button" class="raised"><span>Refresh activity</span></button> <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> <details class="ml-diagnostics" open>
<summary id="ml-refresh-scan-summary">Latest scan</summary>
<div class="fieldDescription">A completed scan is idle because no scan is running now. Its result and counters remain here until another scan starts.</div>
<div id="ml-refresh-scan" class="ml-cache-entries"></div> <div id="ml-refresh-scan" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Queue</h3> </details>
<details class="ml-diagnostics">
<summary id="ml-refresh-queue-summary">Queue</summary>
<div class="fieldDescription">Pending entries have not started. Recently completed entries are history only and are no longer queued.</div>
<div id="ml-refresh-queue" class="ml-cache-entries"></div> <div id="ml-refresh-queue" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Provider HTTP</h3> </details>
<details class="ml-diagnostics">
<summary id="ml-provider-http-summary">Provider HTTP</summary>
<div class="fieldDescription">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.</div>
<div id="ml-provider-http" class="ml-cache-entries"></div> <div id="ml-provider-http" class="ml-cache-entries"></div>
</details>
</fieldset> </fieldset>
<fieldset class="ml-section"> <fieldset class="ml-section">
<legend>Items cache</legend> <legend>Items cache</legend>
<div class="fieldDescription">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.</div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-cache-threshold">ItemsProxy cache threshold ms</label> <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"> <input id="ml-cache-threshold" type="number" class="emby-input ml-input" min="0" step="100">
@@ -102,14 +113,25 @@
<label class="inputLabel inputLabelUnfocused" for="ml-cache-max">ItemsProxy cache max MiB</label> <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"> <input id="ml-cache-max" type="number" class="emby-input ml-input" min="1" step="1">
</div> </div>
<div class="inputContainer">
<label><input id="ml-precache-movies" type="checkbox"> Pre-cache movie library pages for returning users</label>
<label><input id="ml-precache-tvshows" type="checkbox"> Pre-cache TV show library pages for returning users</label>
<div class="fieldDescription">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.</div>
</div>
<div class="ml-actions"> <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-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> <button id="ml-cache-clear" is="emby-button" type="button" class="raised ml-danger"><span>Clear cache</span></button>
</div> </div>
<h3 class="ml-subheading">Stored responses</h3> <details class="ml-diagnostics" open>
<summary id="ml-cache-entries-summary">Cached responses</summary>
<div class="fieldDescription">These complete responses were cached because they met the configured threshold. Their ages update while this page is open.</div>
<div id="ml-cache-entries" class="ml-cache-entries"></div> <div id="ml-cache-entries" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Recent requests</h3> </details>
<details class="ml-diagnostics">
<summary id="ml-proxy-requests-summary">Recent proxy requests</summary>
<div class="fieldDescription">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.</div>
<div id="ml-proxy-requests" class="ml-cache-entries"></div> <div id="ml-proxy-requests" class="ml-cache-entries"></div>
</details>
</fieldset> </fieldset>
<fieldset class="ml-section"> <fieldset class="ml-section">
@@ -423,6 +445,8 @@
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").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.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").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.AssetStorageMode = nextStorageMode;
state.config.IgnoreArticlesWhenSorting = document.getElementById("ml-ignore-articles").checked; state.config.IgnoreArticlesWhenSorting = document.getElementById("ml-ignore-articles").checked;
state.config.JellyfinTitleLanguageFallback = document.getElementById("ml-jellyfin-title-language").value; 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-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000;
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120; document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50; 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-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-ignore-articles").checked = state.config.IgnoreArticlesWhenSorting !== false && state.config.ignoreArticlesWhenSorting !== false;
document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging; document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging;
@@ -462,14 +488,51 @@
loadExportInfo(); 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) { function renderCacheEntries(entries, error) {
var root = document.getElementById("ml-cache-entries"); var root = document.getElementById("ml-cache-entries");
root.replaceChildren(); root.replaceChildren();
if (error) { if (error) {
setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses");
root.appendChild(make("div", "ml-error", error)); root.appendChild(make("div", "ml-error", error));
return; return;
} }
setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses (" + (entries ? entries.length : 0) + ")");
if (!entries || entries.length === 0) { if (!entries || entries.length === 0) {
root.appendChild(make("div", "ml-muted", "No cache entries.")); root.appendChild(make("div", "ml-muted", "No cache entries."));
return; return;
@@ -482,7 +545,7 @@
(entry.ItemCount || entry.itemCount || 0) + " items", (entry.ItemCount || entry.itemCount || 0) + " items",
(entry.DurationMs || entry.durationMs || 0) + " ms", (entry.DurationMs || entry.durationMs || 0) + " ms",
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB", ((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"); var root = document.getElementById("ml-proxy-requests");
root.replaceChildren(); root.replaceChildren();
if (error) { if (error) {
setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests");
root.appendChild(make("div", "ml-error", error)); root.appendChild(make("div", "ml-error", error));
return; return;
} }
setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests (" + (entries ? entries.length : 0) + " of last 100)");
if (!entries || entries.length === 0) { if (!entries || entries.length === 0) {
root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests.")); root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests."));
return; return;
@@ -510,15 +575,20 @@
entries.forEach(function (entry) { entries.forEach(function (entry) {
var url = entry.Url || entry.url || ""; 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, [ root.appendChild(makeDiagnosticRow(url, [
(entry.CacheHit || entry.cacheHit) ? "hit" : ((entry.CacheStored || entry.cacheStored) ? "stored" : "miss"), cacheState,
"HTTP " + (entry.StatusCode || entry.statusCode || 0), "HTTP " + (entry.StatusCode || entry.statusCode || 0),
(entry.ItemCount || entry.itemCount || 0) + " items", (entry.ItemCount || entry.itemCount || 0) + " items",
"total " + (entry.TotalMs || entry.totalMs || 0) + " ms", "total " + (entry.TotalMs || entry.totalMs || 0) + " ms",
"upstream " + (entry.UpstreamMs || entry.upstreamMs || 0) + " ms", "upstream " + (entry.UpstreamMs || entry.upstreamMs || 0) + " ms",
"transform " + (entry.TransformMs || entry.transformMs || 0) + " ms", "transform " + (entry.TransformMs || entry.transformMs || 0) + " ms",
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB", ((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 row = make("div", "ml-diagnostic-row");
var top = make("div", "ml-diagnostic-meta"); var top = make("div", "ml-diagnostic-meta");
parts.forEach(function (part, index) { 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); top.appendChild(chip);
}); });
var urlEl = make("div", "ml-cache-url", url); var urlEl = make("div", "ml-cache-url", url);
@@ -545,6 +617,7 @@
} }
function loadCacheDiagnostics() { function loadCacheDiagnostics() {
ensureAgeTicker();
loadCacheEntries(); loadCacheEntries();
loadProxyRequests(); loadProxyRequests();
} }
@@ -569,6 +642,18 @@
return new Date(value * 1000).toLocaleString(); 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) { function renderRefreshDiagnostics(data, error) {
var scanRoot = document.getElementById("ml-refresh-scan"); var scanRoot = document.getElementById("ml-refresh-scan");
var queueRoot = document.getElementById("ml-refresh-queue"); var queueRoot = document.getElementById("ml-refresh-queue");
@@ -578,6 +663,7 @@
httpRoot.replaceChildren(); httpRoot.replaceChildren();
if (error) { if (error) {
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan");
scanRoot.appendChild(make("div", "ml-error", error)); scanRoot.appendChild(make("div", "ml-error", error));
return; return;
} }
@@ -587,25 +673,40 @@
var queue = prop(data, "Queue", "queue", {}); var queue = prop(data, "Queue", "queue", {});
var providerHttp = prop(data, "ProviderHttp", "providerHttp", {}); var providerHttp = prop(data, "ProviderHttp", "providerHttp", {});
var running = !!prop(scan, "Running", "running", false); var running = !!prop(scan, "Running", "running", false);
scanRoot.appendChild(makeDiagnosticRow("Last scan started: " + formatUnixTime(prop(data, "LastScanStarted", "lastScanStarted", 0)), [ var scanStartedAt = Number(prop(scan, "StartedAt", "startedAt", 0) || 0);
running ? "running" : "idle", var scanSuccess = !!prop(scan, "Success", "success", false);
String(prop(scan, "Mode", "mode", "") || "none"), if (!scanStartedAt) {
String(prop(scan, "Source", "source", "") || "none"), setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan");
"items " + Number(prop(scan, "Items", "items", 0) || 0), scanRoot.appendChild(make("div", "ml-muted", "No scan diagnostics are available since Jellyfin last started."));
"due " + Number(prop(scan, "Due", "due", 0) || 0), } else {
"refreshed " + Number(prop(scan, "Refreshed", "refreshed", 0) || 0), var result = running ? "running" : (scanSuccess ? "completed" : "failed");
"skipped " + (Number(prop(scan, "SkippedNotDue", "skippedNotDue", 0) || 0) + Number(prop(scan, "SkippedNoData", "skippedNoData", 0) || 0)), setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan (" + result + ")");
"new " + Number(prop(scan, "New", "new", 0) || 0), scanRoot.appendChild(makeDiagnosticRow("Started: " + formatUnixTime(scanStartedAt), [
"deleted " + Number(prop(scan, "Deleted", "deleted", 0) || 0), running ? "running now" : "not running",
"current " + Number(prop(scan, "CurrentIndex", "currentIndex", 0) || 0) + "/" + Number(prop(scan, "Items", "items", 0) || 0), "result: " + result,
String(prop(scan, "CurrentAction", "currentAction", "") || "idle"), "mode: " + scanMode(prop(scan, "Mode", "mode", "")),
Number(prop(scan, "ElapsedMs", "elapsedMs", 0) || 0) + " ms" "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", "") || ""); var currentItem = String(prop(scan, "CurrentItemId", "currentItemId", "") || "");
if (currentItem) { if (currentItem) {
scanRoot.appendChild(makeDiagnosticRow("Current item: " + currentItem, [ scanRoot.appendChild(makeDiagnosticRow(String(prop(scan, "CurrentName", "currentName", "") || currentItem), [
String(prop(scan, "CurrentName", "currentName", "") || ""), running ? "current item" : "last item processed",
String(prop(scan, "CurrentKind", "currentKind", "") || "") "kind: " + String(prop(scan, "CurrentKind", "currentKind", "") || "unknown"),
"action: " + String(prop(scan, "CurrentAction", "currentAction", "") || "unknown"),
"ID: " + currentItem
])); ]));
} }
var scanError = String(prop(scan, "Error", "error", "") || ""); var scanError = String(prop(scan, "Error", "error", "") || "");
@@ -614,26 +715,37 @@
var active = prop(queue, "Active", "active", null); var active = prop(queue, "Active", "active", null);
var queued = prop(queue, "Queued", "queued", []) || []; var queued = prop(queue, "Queued", "queued", []) || [];
var recent = prop(queue, "Recent", "recent", []) || []; var recent = prop(queue, "Recent", "recent", []) || [];
setDiagnosticsSummary("ml-refresh-queue-summary", "Queue (" + Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0) + " pending)");
if (active) { if (active) {
queueRoot.appendChild(makeDiagnosticRow("Active item: " + String(prop(active, "ItemId", "itemId", "")), [ queueRoot.appendChild(makeDiagnosticRow("Active item: " + String(prop(active, "ItemId", "itemId", "")), [
"active", "running now",
String(prop(active, "SourceTier", "sourceTier", "")), "source: " + String(prop(active, "SourceTier", "sourceTier", "")),
String(prop(active, "WorkClass", "workClass", "")), "type: " + String(prop(active, "WorkClass", "workClass", "")),
String(prop(active, "JobType", "jobType", "")) "refresh: " + String(prop(active, "JobType", "jobType", ""))
])); ]));
} }
queueRoot.appendChild(makeDiagnosticRow("Queued refresh items", [ if (queued.length === 0) {
"queued", queueRoot.appendChild(make("div", "ml-muted", "No pending refresh items."));
String(Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0)) } 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) { recent.slice(0, 10).forEach(function (item) {
queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [ queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [
prop(item, "Ok", "ok", false) ? "ok" : "failed", prop(item, "Ok", "ok", false) ? "completed" : "failed",
String(prop(item, "SourceTier", "sourceTier", "")), "source: " + String(prop(item, "SourceTier", "sourceTier", "")),
String(prop(item, "WorkClass", "workClass", "")), "type: " + String(prop(item, "WorkClass", "workClass", "")),
String(prop(item, "JobType", "jobType", "")), "refresh: " + String(prop(item, "JobType", "jobType", "")),
Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms", "duration: " + Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms",
formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0)) "finished: " + formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0))
])); ]));
var itemError = String(prop(item, "Error", "error", "") || ""); var itemError = String(prop(item, "Error", "error", "") || "");
if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError)); if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError));
@@ -641,23 +753,26 @@
var counts = prop(providerHttp, "Counts", "counts", []) || []; var counts = prop(providerHttp, "Counts", "counts", []) || [];
var providerRecent = prop(providerHttp, "Recent", "recent", []) || []; 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) { if (counts.length === 0 && providerRecent.length === 0) {
httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup.")); httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup."));
return; return;
} }
if (counts.length > 0) httpRoot.appendChild(diagnosticHeading("Outcome totals since plugin startup"));
counts.forEach(function (count) { counts.forEach(function (count) {
httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [ httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [
"count", "final outcome",
String(prop(count, "Outcome", "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) { providerRecent.slice(0, 10).forEach(function (entry) {
httpRoot.appendChild(makeDiagnosticRow(String(prop(entry, "Url", "url", "")), [ httpRoot.appendChild(makeDiagnosticRow(String(prop(entry, "Url", "url", "")), [
String(prop(entry, "Provider", "provider", "")), String(prop(entry, "Provider", "provider", "")),
String(prop(entry, "Outcome", "outcome", "")), String(prop(entry, "Outcome", "outcome", "")),
"attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0), "completed on attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0),
formatUnixTime(prop(entry, "At", "at", 0)) "at " + formatUnixTime(prop(entry, "At", "at", 0))
])); ]));
}); });
} }
@@ -764,6 +764,27 @@ select.emby-select option:checked {
font-weight: 600; 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 { .ml-diagnostic-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -26,6 +26,8 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
services.AddSingleton<RefreshCoordinator>(); services.AddSingleton<RefreshCoordinator>();
services.AddSingleton<RefreshService>(); services.AddSingleton<RefreshService>();
services.AddSingleton<ItemsProxyCache>(); services.AddSingleton<ItemsProxyCache>();
services.AddSingleton<ItemsProxyRequestCoalescer>();
services.AddSingleton<ItemsProxyPrecacheService>();
services.AddSingleton<ItemsProxyTransformer>(); services.AddSingleton<ItemsProxyTransformer>();
services.AddSingleton<AssetStorageService>(); services.AddSingleton<AssetStorageService>();
services.AddSingleton<MultilangBackupService>(); services.AddSingleton<MultilangBackupService>();
@@ -33,6 +33,8 @@ public sealed class ItemsProxyCache
public required bool CacheStored { get; init; } public required bool CacheStored { get; init; }
public required bool InFlightCoalesced { get; init; }
public required int StatusCode { get; init; } public required int StatusCode { get; init; }
public required int ItemCount { get; init; } public required int ItemCount { get; init; }
@@ -229,7 +231,8 @@ public sealed class ItemsProxyCache
long totalMs, long totalMs,
long upstreamMs, long upstreamMs,
long transformMs, long transformMs,
long sizeBytes) long sizeBytes,
bool inFlightCoalesced = false)
{ {
lock (_lock) lock (_lock)
{ {
@@ -239,6 +242,7 @@ public sealed class ItemsProxyCache
CreatedUtc = DateTimeOffset.UtcNow, CreatedUtc = DateTimeOffset.UtcNow,
CacheHit = cacheHit, CacheHit = cacheHit,
CacheStored = cacheStored, CacheStored = cacheStored,
InFlightCoalesced = inFlightCoalesced,
StatusCode = statusCode, StatusCode = statusCode,
ItemCount = itemCount, ItemCount = itemCount,
TotalMs = totalMs, TotalMs = totalMs,
@@ -265,6 +269,7 @@ public sealed class ItemsProxyCache
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds, AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.CacheHit, e.CacheHit,
e.CacheStored, e.CacheStored,
e.InFlightCoalesced,
e.StatusCode, e.StatusCode,
e.ItemCount, e.ItemCount,
e.TotalMs, e.TotalMs,
@@ -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<ItemsProxyPrecacheService> _logger;
private readonly object _lock = new();
private readonly Dictionary<string, DateTimeOffset> _lastSeen = new(StringComparer.Ordinal);
private readonly HashSet<string> _pendingUsers = new(StringComparer.Ordinal);
public ItemsProxyPrecacheService(IHttpClientFactory httpClientFactory, ILogger<ItemsProxyPrecacheService> 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>() ?? string.Empty,
CollectionType = view?["CollectionType"]?.GetValue<string>() ?? 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<JsonObject> 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);
}
}
@@ -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<ItemsProxyUpstreamResponse> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
private readonly object _lock = new();
private readonly Dictionary<string, InFlightRequest> _requests = new(StringComparer.Ordinal);
public async Task<CoalescedItemsProxyResponse> GetOrFetchAsync(
string key,
Func<CancellationToken, Task<ItemsProxyUpstreamResponse>> 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<CancellationToken, Task<ItemsProxyUpstreamResponse>> 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);
}
}
}
}
@@ -68,6 +68,30 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey); 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] [Fact]
public void BuildLeavesSortAndPagingUpstreamWhenMultilangDisabled() public void BuildLeavesSortAndPagingUpstreamWhenMultilangDisabled()
{ {
@@ -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<ItemsProxyUpstreamResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
var fetches = 0;
Task<ItemsProxyUpstreamResponse> 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<ItemsProxyUpstreamResponse>(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<OperationCanceledException>(() => cancelledWaiter);
response.SetResult(new ItemsProxyUpstreamResponse(200, "{}", "application/json"));
var result = await activeWaiter;
Assert.True(result.JoinedExistingRequest);
Assert.Equal(200, result.Response.StatusCode);
}
}