Files
jRay/Jellyfin.Plugin.JRay/Services/TruthDataService.cs
T
dtourolleandClaude Opus 5 5152f6f129
🏗️ Build Plugin / build (push) Successful in 31s
Latest Release / latest-release (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 27s
JR-010: record how truth data was obtained
Three routes now deliver truth data -- a sidecar, a worker push, and a fetched
manifest -- and once stored they were indistinguishable. The truth file records
nothing about how it arrived, so a locally computed sidecar and a loose-tier
manifest from a third-party server looked identical to every reader, despite
making claims of very different strength about the same item.

TruthProvenance records source, server, match tier, applied offset and caveat.
GET /Items/{itemId}/Provenance serves it, and JR-036's loose-tier caveat now
has somewhere to come from.

Two decisions carried in the code rather than assumed:

Provenance is stored BESIDE the truth file, never inside it. Injecting fields
would mean the bytes served back are not the bytes the producer wrote, which is
the property JR-004 turns on. UT-026 pins it by asserting the stored truth JSON
contains no provenance keys.

The applied offset is recorded because it is otherwise unrecoverable. Once
JR-030 shifts every window the timings look native, and nothing else would say
they had been shifted -- which matters when diagnosing an overlay that is
consistently a few seconds out.

A sidecar's provenance is derived rather than stored: it is local, and its
timestamp is the file's own. Precedence resolves through the same rule as
GetTruthAsync, because resolving it twice by different rules is how the two
would drift.

Fourth mutation check: stopping Delete from removing provenance fails UT-027
alone -- a stale record would otherwise outlive its claim and describe data the
next fetch had already replaced.

TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:36:08 +02:00

146 lines
5.1 KiB
C#

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;
/// <summary>
/// Loads scene-actor-extraction truth files from disk, alongside each media
/// item's source file, and caches the parsed result for a configurable
/// duration.
/// </summary>
/// <remarks>
/// Managed truth — pushed by a worker, or fetched from a manifest server —
/// takes precedence over a sidecar file. Storing fetched manifests through the
/// managed store is what keeps this a two-way rule rather than a three-way one,
/// so the read path never learns that the exchange exists.
/// </remarks>
// TRACES: JR-008, JR-010, JR-011 | PR-001
public sealed class TruthDataService : ITruthDataService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILibraryManager _libraryManager;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ILogger<TruthDataService> _logger;
private readonly ConcurrentDictionary<Guid, CacheEntry> _cache = new();
/// <summary>
/// Initializes a new instance of the <see cref="TruthDataService"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="managedTruthStore">The managed truth store.</param>
/// <param name="logger">The logger.</param>
public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger<TruthDataService> logger)
{
_libraryManager = libraryManager;
_managedTruthStore = managedTruthStore;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> 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<TruthFile>(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;
}
}
/// <inheritdoc />
public TruthProvenance? GetProvenance(Guid itemId)
{
// Managed truth wins, exactly as in GetTruthAsync -- resolving
// precedence twice by different rules is how the two would drift.
var managed = _managedTruthStore.LoadProvenance(itemId);
if (managed is not null)
{
return managed;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null || string.IsNullOrEmpty(item.Path))
{
return null;
}
var sidecarPath = GetSidecarPath(item.Path);
if (!File.Exists(sidecarPath))
{
return null;
}
// A sidecar records nothing about itself, so its provenance is derived:
// it is local, and its timestamp is the file's own.
return TruthProvenance.Local(TruthSource.Sidecar, File.GetLastWriteTimeUtc(sidecarPath));
}
/// <inheritdoc />
public void Invalidate(Guid itemId)
{
_cache.TryRemove(itemId, out _);
}
/// <inheritdoc />
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);
}