using System.Text.Json.Nodes; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Multilang.Services; public sealed class ItemsProxyPrecacheService { private static readonly TimeSpan IdleThreshold = TimeSpan.FromMinutes(30); private readonly IServerApplicationHost _applicationHost; private readonly IConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly object _lock = new(); private readonly Dictionary _lastSeen = new(StringComparer.Ordinal); private readonly HashSet _pendingUsers = new(StringComparer.Ordinal); public ItemsProxyPrecacheService( IServerApplicationHost applicationHost, IConfigurationManager configurationManager, IHttpClientFactory httpClientFactory, ILogger logger) { _applicationHost = applicationHost; _configurationManager = configurationManager; _httpClientFactory = httpClientFactory; _logger = logger; } public void ObserveUserActivity( 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(userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries); } private async Task PrecacheAsync( string userId, string token, string clientLocale, bool precacheMovieLibraries, bool precacheTvShowLibraries) { try { var baseUri = LocalApiUri; var views = await GetJsonAsync(new Uri(baseUri, $"Users/{userId}/Views"), token).ConfigureAwait(false); var items = views["Items"]?.AsArray() ?? []; var targets = items .Select(view => new { Id = view?["Id"]?.GetValue() ?? string.Empty, CollectionType = view?["CollectionType"]?.GetValue() ?? string.Empty }) .Where(view => !string.IsNullOrWhiteSpace(view.Id)) .Where(view => (precacheMovieLibraries && view.CollectionType.Equals("movies", StringComparison.OrdinalIgnoreCase)) || (precacheTvShowLibraries && view.CollectionType.Equals("tvshows", StringComparison.OrdinalIgnoreCase))) .ToArray(); foreach (var target in targets) { var itemType = target.CollectionType.Equals("movies", StringComparison.OrdinalIgnoreCase) ? "Movie" : "Series"; var itemsUrl = $"/Users/{userId}/Items?ParentId={Uri.EscapeDataString(target.Id)}&IncludeItemTypes={itemType}&Recursive=true&SortBy=SortName&SortOrder=Ascending&StartIndex=0&Limit=100"; var endpoint = "Multilang/ItemsProxy?url=" + Uri.EscapeDataString(itemsUrl) + "&precache=true"; if (!string.IsNullOrWhiteSpace(clientLocale)) endpoint += "&mlLocale=" + Uri.EscapeDataString(clientLocale); using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, endpoint)); AddTokenHeaders(request, token); using var response = await _httpClientFactory.CreateClient("Multilang.Jellyfin").SendAsync(request, CancellationToken.None).ConfigureAwait(false); if (!response.IsSuccessStatusCode) _logger.LogWarning("Multilang pre-cache failed user={UserId} library={LibraryId} status={StatusCode}", userId, target.Id, (int)response.StatusCode); } } catch (Exception ex) { _logger.LogWarning(ex, "Multilang pre-cache failed for user={UserId}", userId); } finally { lock (_lock) _pendingUsers.Remove(userId); } } private async Task GetJsonAsync(Uri uri, string token) { using var request = new HttpRequestMessage(HttpMethod.Get, uri); AddTokenHeaders(request, token); using var response = await _httpClientFactory.CreateClient("Multilang.Jellyfin").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); } public Uri LocalApiUri => new(_applicationHost.GetLocalApiUrl("127.0.0.1", "http", _configurationManager.GetNetworkConfiguration().InternalHttpPort).TrimEnd('/') + "/"); }