Improve proxy cache behavior and diagnostics
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user