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>
This commit is contained in:
@@ -25,9 +25,21 @@ public interface IManagedTruthStore
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||
/// <param name="truth">The truth file contents to persist.</param>
|
||||
/// <param name="provenance">How this truth data was obtained.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the file has been written.</returns>
|
||||
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
|
||||
/// <remarks>
|
||||
/// Provenance is written beside the truth file, never into it: the bytes
|
||||
/// served back must be the bytes the producer wrote (JR-004).
|
||||
/// </remarks>
|
||||
Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the provenance recorded alongside an item's managed truth data.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||
/// <returns>The provenance, or null if this item has no managed truth data.</returns>
|
||||
TruthProvenance? LoadProvenance(Guid itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the managed truth file for the given item, if one exists.
|
||||
|
||||
@@ -18,6 +18,17 @@ public interface ITruthDataService
|
||||
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
|
||||
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets how the item's truth data was obtained, or null if it has none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolves the same precedence as <see cref="GetTruthAsync"/>: managed
|
||||
/// truth (pushed or fetched) wins, and a sidecar is reported as such.
|
||||
/// </remarks>
|
||||
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||
/// <returns>The provenance of the truth data that would be served.</returns>
|
||||
TruthProvenance? GetProvenance(Guid itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes any cached truth file for the given item, so the next
|
||||
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken)
|
||||
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.");
|
||||
@@ -73,11 +73,52 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<TruthProvenance>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Delete(Guid itemId)
|
||||
{
|
||||
var provenancePath = GetProvenancePath(itemId);
|
||||
if (File.Exists(provenancePath))
|
||||
{
|
||||
File.Delete(provenancePath);
|
||||
}
|
||||
|
||||
var path = GetPath(itemId);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
@@ -98,4 +139,9 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,34 @@ public sealed class TruthDataService : ITruthDataService
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user