using System; using System.Collections.Concurrent; using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.JRay.Models; using Jellyfin.Plugin.JRay.Services.Interfaces; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.JRay.Services; /// /// Loads scene-actor-extraction truth files from disk, alongside each media /// item's source file, and caches the parsed result for a configurable /// duration. /// public sealed class TruthDataService : ITruthDataService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly ILibraryManager _libraryManager; private readonly IManagedTruthStore _managedTruthStore; private readonly ILogger _logger; private readonly ConcurrentDictionary _cache = new(); /// /// Initializes a new instance of the class. /// /// The Jellyfin library manager. /// The managed truth store. /// The logger. public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger logger) { _libraryManager = libraryManager; _managedTruthStore = managedTruthStore; _logger = logger; } /// public async Task GetTruthAsync(Guid itemId, CancellationToken cancellationToken) { var cacheDuration = TimeSpan.FromMinutes(Math.Max(0, Plugin.Instance?.Configuration.CacheDurationMinutes ?? 0)); if (_cache.TryGetValue(itemId, out var cached) && DateTime.UtcNow - cached.LoadedAt < cacheDuration) { return cached.Truth; } var managed = await _managedTruthStore.LoadAsync(itemId, cancellationToken).ConfigureAwait(false); if (managed is not null) { _cache[itemId] = new CacheEntry(managed, DateTime.UtcNow); return managed; } var item = _libraryManager.GetItemById(itemId); if (item is null || string.IsNullOrEmpty(item.Path)) { _logger.LogDebug("JRay: item {ItemId} not found or has no path", itemId); return null; } var truthPath = GetSidecarPath(item.Path); if (!File.Exists(truthPath)) { _logger.LogDebug("JRay: no truth file at {TruthPath}", truthPath); _cache[itemId] = new CacheEntry(null, DateTime.UtcNow); return null; } try { using var stream = File.OpenRead(truthPath); var truth = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) .ConfigureAwait(false); _cache[itemId] = new CacheEntry(truth, DateTime.UtcNow); return truth; } catch (Exception ex) when (ex is IOException or JsonException) { _logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath); return null; } } /// public void Invalidate(Guid itemId) { _cache.TryRemove(itemId, out _); } /// public bool HasTruth(Guid itemId, string itemPath) { return _managedTruthStore.Exists(itemId) || File.Exists(GetSidecarPath(itemPath)); } private static string GetSidecarPath(string itemPath) { var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json"; return Path.Combine( Path.GetDirectoryName(itemPath) ?? string.Empty, Path.GetFileNameWithoutExtension(itemPath) + suffix); } private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt); }