using System; 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.Common.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.JRay.Services; /// /// Stores truth files pushed directly to JRay (e.g. by a remote extraction /// worker) under the plugin's configuration directory, keyed by item id. /// /// /// Deliberately outside the media library filesystem, so a worker that cannot /// write beside the media file is not a second-class producer. /// // TRACES: JR-009, JR-010 | PR-004 public sealed class ManagedTruthStore : IManagedTruthStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly IApplicationPaths _applicationPaths; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The Jellyfin application paths. /// The logger. public ManagedTruthStore(IApplicationPaths applicationPaths, ILogger logger) { _applicationPaths = applicationPaths; _logger = logger; } /// public async Task LoadAsync(Guid itemId, CancellationToken cancellationToken) { var path = GetPath(itemId); if (!File.Exists(path)) { return null; } try { using var stream = File.OpenRead(path); var truth = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) .ConfigureAwait(false); // Managed truth is checked on the way out as well as on the way in. // Data written by an earlier plugin version is already on disk, and // it did not pass today's PUT. if (!TruthSchema.IsSupported(truth)) { _logger.LogWarning( "JRay: ignoring managed truth {Path} — schema_version {Found}, expected {Expected}. Re-push or re-extract this item.", path, truth?.SchemaVersion ?? 0, TruthSchema.SupportedVersion); return null; } return truth; } catch (Exception ex) when (ex is IOException or JsonException) { _logger.LogWarning( ex, "JRay: failed to read managed truth file {Path}. If this is v1 data, re-push it — v1 is no longer read.", path); return null; } } /// public async Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken) { var path = GetPath(itemId); var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory."); Directory.CreateDirectory(directory); var tempPath = path + ".tmp"; using (var stream = File.Create(tempPath)) { await JsonSerializer.SerializeAsync(stream, truth, JsonOptions, cancellationToken).ConfigureAwait(false); } File.Move(tempPath, path, overwrite: true); // Written after the truth file, so a crash between the two leaves truth // with no provenance (readable, source unknown) rather than provenance // describing a file that is not there. var provenancePath = GetProvenancePath(itemId); var provenanceTemp = provenancePath + ".tmp"; using (var stream = File.Create(provenanceTemp)) { await JsonSerializer.SerializeAsync(stream, provenance, JsonOptions, cancellationToken).ConfigureAwait(false); } File.Move(provenanceTemp, provenancePath, overwrite: true); } /// public TruthProvenance? LoadProvenance(Guid itemId) { var path = GetProvenancePath(itemId); if (!File.Exists(path)) { return null; } try { using var stream = File.OpenRead(path); return JsonSerializer.Deserialize(stream, JsonOptions); } catch (Exception ex) when (ex is IOException or JsonException) { // Provenance is metadata about the claim, not the claim. Losing it // must never make readable truth data unreadable. _logger.LogWarning(ex, "JRay: failed to read truth provenance at {Path}", path); return null; } } /// public bool Delete(Guid itemId) { var provenancePath = GetProvenancePath(itemId); if (File.Exists(provenancePath)) { File.Delete(provenancePath); } var path = GetPath(itemId); if (!File.Exists(path)) { return false; } File.Delete(path); return true; } /// public bool Exists(Guid itemId) { return File.Exists(GetPath(itemId)); } private string GetPath(Guid itemId) { return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json"); } private string GetProvenancePath(Guid itemId) { return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".provenance.json"); } }