Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b4633324 | ||
|
|
8c5fb5950d | ||
|
|
1e247c4c7d | ||
|
|
e8ce779ad3 | ||
|
|
8b430d53c0 | ||
|
|
c863fe85f5 | ||
|
|
17be9bcc4e | ||
|
|
19aecee646 | ||
|
|
596566813c | ||
|
|
5152f6f129 | ||
|
|
c04d5a3dcc | ||
|
|
305b898b15 | ||
|
|
12f076cf85 | ||
|
|
3d210b5bd3 | ||
|
|
64f549ab35 | ||
|
|
2926740d03 | ||
|
|
0fafa84158 | ||
|
|
cc973fd7c5 | ||
|
|
e16f903469 | ||
|
|
f4e8fb5dea | ||
|
|
2d043aeee3 | ||
|
|
5159692364 | ||
|
|
d786d56368 | ||
|
|
d9a38bb7fb | ||
|
|
3b24fe1b3c | ||
|
|
32f976e79f | ||
|
|
8e1679faed |
@@ -42,6 +42,16 @@ jobs:
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-self-contained /m:1
|
||||
|
||||
# The workflow is named "Test Plugin" and until now only compiled one. A
|
||||
# test that is built but never run is not evidence, and JR-043 is the case
|
||||
# that makes it matter: the point of a golden vector shared with the
|
||||
# extraction repo is that CI fails when the two implementations drift.
|
||||
# T1 needs no ASP.NET runtime and no FFmpeg — the audio golden check
|
||||
# regenerates its own fixture PCM.
|
||||
- name: Run tests
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-build
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -rf test-${{ github.run_id }}
|
||||
|
||||
@@ -3,3 +3,12 @@ obj/
|
||||
.vs/
|
||||
.idea/
|
||||
artifacts
|
||||
|
||||
# Python artefacts from the traceability tooling
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Generated traceability output — regenerate with the gate, never hand-edit.
|
||||
# `docs/traceability.md` is committed, as in the other two components; the JSON
|
||||
# report is not, since nothing reads it back.
|
||||
traces-report.json
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "scripts/vendor/jray-project"]
|
||||
path = scripts/vendor/jray-project
|
||||
url = git@gitea.tourolle.paris:dtourolle/jray-project.git
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-044 (media shorter than 120 s emits no signature and takes no sync offset)
|
||||
/// and JR-045 (the `v1:` prefix is emitted *and honoured*).
|
||||
///
|
||||
/// Both requirements have a producer half and a consumer half. The producer
|
||||
/// halves live with <see cref="AudioSignature"/> and were closed by
|
||||
/// UT-038 … UT-044; these are the consumer halves, which needed a reader —
|
||||
/// <see cref="AudioSignatureMatcher"/> — before they could be closed at all.
|
||||
///
|
||||
/// The distinction that matters throughout: **refusing is a correct answer, and
|
||||
/// a silently wrong one is not.** An unreadable or out-of-version signature must
|
||||
/// drop the item to the runtime tier, never score as if it were understood.
|
||||
///
|
||||
/// TRACES: UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052 | JR-044, JR-045
|
||||
/// </summary>
|
||||
public class AudioSignatureMatcherTests
|
||||
{
|
||||
private static readonly string FixtureDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
|
||||
|
||||
private static readonly Lazy<string> GoldenSignature = new(() =>
|
||||
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))
|
||||
.RootElement.GetProperty("signature").GetString()!);
|
||||
|
||||
private static readonly Lazy<byte[]> GoldenFrames = new(() =>
|
||||
AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value)!);
|
||||
|
||||
// A feature-length runtime, so the window-anchor term is exercised at a
|
||||
// realistic scale rather than at the 120 s boundary.
|
||||
private const double FeatureRuntime = 7200.0;
|
||||
|
||||
// UT-045 — JR-044, the producer boundary.
|
||||
[Fact]
|
||||
public async Task ShortMedia_YieldsNoSignature_AndExactly120sDoes()
|
||||
{
|
||||
// Decisive because it is the *same file* either side of the boundary:
|
||||
// only the runtime differs, so a null cannot be blamed on the decode.
|
||||
// The fixture is exactly 120.000 s, which is the boundary itself.
|
||||
var fixture = Path.Combine(FixtureDir, "jray_audio_v1_tone.flac");
|
||||
|
||||
// Below the window: refused before the encoder is ever consulted, which
|
||||
// is what the deliberately invalid path proves.
|
||||
Assert.Null(await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
"/nonexistent/ffmpeg",
|
||||
fixture,
|
||||
AudioSignature.WindowSec - 0.001,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true));
|
||||
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Null(await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
ffmpeg,
|
||||
fixture,
|
||||
AudioSignature.WindowSec - 0.001,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true));
|
||||
|
||||
// Exactly at the boundary the window fits, so a signature is emitted.
|
||||
// Both producers must agree here or they diverge on precisely the short
|
||||
// items most likely to be misidentified.
|
||||
Assert.Equal(
|
||||
GoldenSignature.Value,
|
||||
await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
ffmpeg,
|
||||
fixture,
|
||||
AudioSignature.WindowSec,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true));
|
||||
}
|
||||
|
||||
// UT-046 — JR-044, the consumer half.
|
||||
[Fact]
|
||||
public void ShortMedia_TakesNoOffset_EvenWithTwoValidSignatures()
|
||||
{
|
||||
var signature = GoldenSignature.Value;
|
||||
|
||||
// Two identical, perfectly valid signatures — the strongest possible
|
||||
// match — still yield nothing when either side is under the window. The
|
||||
// rule is checked on the runtime, not inferred from a missing string,
|
||||
// because the runtime is what both producers test.
|
||||
Assert.Null(AudioSignatureMatcher.Compare(
|
||||
signature, signature, AudioSignature.WindowSec - 0.001, FeatureRuntime));
|
||||
Assert.Null(AudioSignatureMatcher.Compare(
|
||||
signature, signature, FeatureRuntime, AudioSignature.WindowSec - 0.001));
|
||||
|
||||
// At exactly the boundary it matches, so the refusal above is the
|
||||
// threshold and not a blanket refusal.
|
||||
Assert.NotNull(AudioSignatureMatcher.Compare(
|
||||
signature, signature, AudioSignature.WindowSec, AudioSignature.WindowSec));
|
||||
}
|
||||
|
||||
// UT-047 — JR-045, the requirement's whole point.
|
||||
[Fact]
|
||||
public void UnknownVersionPrefix_IsRefused_NotParsed()
|
||||
{
|
||||
// A v2 signature from a future producer, whose payload is byte-identical
|
||||
// to a valid v1 one. Parsing it as v1 would yield a confident, plausible,
|
||||
// wrong score; refusing drops the item to the runtime tier, which is
|
||||
// correct. This is the entire reason the prefix is separate from
|
||||
// schema_version.
|
||||
var payload = GoldenSignature.Value[AudioSignature.VersionPrefix.Length..];
|
||||
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames("v2:" + payload));
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames("v10:" + payload));
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames(payload)); // no prefix at all
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames("V1:" + payload)); // case is not cosmetic
|
||||
|
||||
// And it must be refused by the matcher too, not merely by the parser —
|
||||
// a v2 signature produces no match and therefore no offset.
|
||||
Assert.Null(AudioSignatureMatcher.Compare(
|
||||
GoldenSignature.Value, "v2:" + payload, FeatureRuntime, FeatureRuntime));
|
||||
Assert.Null(AudioSignatureMatcher.Compare(
|
||||
"v2:" + payload, GoldenSignature.Value, FeatureRuntime, FeatureRuntime));
|
||||
}
|
||||
|
||||
// UT-048 — JR-045, the accepting side.
|
||||
[Fact]
|
||||
public void V1Signature_ParsesToExactlyTheProducedFrames()
|
||||
{
|
||||
// The reader is the inverse of the producer, checked against the golden
|
||||
// vector rather than against the producer's own output, so the two are
|
||||
// pinned to the fixture and not merely to each other.
|
||||
var frames = AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value);
|
||||
|
||||
Assert.NotNull(frames);
|
||||
Assert.Equal(AudioSignature.ExpectedFrames, frames!.Length);
|
||||
Assert.Equal(GoldenSignature.Value, AudioSignature.VersionPrefix + Convert.ToBase64String(frames));
|
||||
}
|
||||
|
||||
// UT-049 — JR-045, structural refusal.
|
||||
[Fact]
|
||||
public void MalformedSignatures_AreRefused_WithoutThrowing()
|
||||
{
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames(null));
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames(string.Empty));
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames("v1:")); // empty payload
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames("v1:not!base64!"));
|
||||
|
||||
// Bit 7 is reserved by the packing — a byte with it set is not a frame.
|
||||
// The server refuses this on upload; the client must not accept what the
|
||||
// server would have rejected.
|
||||
Assert.Null(AudioSignatureMatcher.TryParseFrames(
|
||||
AudioSignature.VersionPrefix + Convert.ToBase64String(new byte[] { 0x04, 0x80, 0x08 })));
|
||||
}
|
||||
|
||||
// UT-050 — the aligned case.
|
||||
[Fact]
|
||||
public void IdenticalSignatures_ScorePerfectly_AtZeroOffset()
|
||||
{
|
||||
var match = AudioSignatureMatcher.Compare(
|
||||
GoldenSignature.Value, GoldenSignature.Value, FeatureRuntime, FeatureRuntime);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal(1.0, match!.Value.Score);
|
||||
Assert.Equal(0, match.Value.OffsetFrames);
|
||||
Assert.Equal(0.0, match.Value.OffsetSec);
|
||||
Assert.Equal(MatchTier.Audio, match.Value.Tier);
|
||||
}
|
||||
|
||||
// UT-051 — the case the feature exists for.
|
||||
[Fact]
|
||||
public void AShiftedRelease_RecoversTheOffset_RatherThanFailingToMatch()
|
||||
{
|
||||
// A release trimmed differently from the one the manifest was built on:
|
||||
// the same cut, sampled at a different point. Before offset recovery this
|
||||
// failed the runtime tier outright; the recovered shift is what makes one
|
||||
// stored manifest serve every trim.
|
||||
const int Shift = 100;
|
||||
const int Span = 1000;
|
||||
const int RemoteStart = 144;
|
||||
|
||||
var source = GoldenFrames.Value;
|
||||
var remote = Signature(source, RemoteStart, Span);
|
||||
var local = Signature(source, RemoteStart - Shift, Span);
|
||||
|
||||
var match = AudioSignatureMatcher.Compare(local, remote, FeatureRuntime, FeatureRuntime);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal(1.0, match!.Value.Score);
|
||||
Assert.Equal(Shift, match.Value.OffsetFrames);
|
||||
Assert.Equal(MatchTier.Audio, match.Value.Tier);
|
||||
|
||||
// Equal runtimes, so the window-anchor term vanishes and the offset is
|
||||
// purely the recovered slide.
|
||||
Assert.Equal(Shift * AudioSignatureMatcher.FrameSeconds, match.Value.OffsetSec, 9);
|
||||
}
|
||||
|
||||
// UT-052 — the rejecting case, and the anchor term.
|
||||
[Fact]
|
||||
public void UnrelatedContent_DoesNotMatch_AndRuntimeSkewShiftsTheOffset()
|
||||
{
|
||||
// Two independent band sequences agree about 1 frame in 32, far below the
|
||||
// loose floor. A matcher that returned its argmax regardless would hand
|
||||
// back a confident alignment for unrelated films.
|
||||
Assert.Null(AudioSignatureMatcher.Compare(
|
||||
GoldenSignature.Value, PseudoRandomSignature(1288, seed: 12345), FeatureRuntime, FeatureRuntime));
|
||||
|
||||
// Both windows are centred on their own file's midpoint, so when the
|
||||
// runtimes differ the windows start at different absolute times and that
|
||||
// difference is part of the offset. Without this term the offset would be
|
||||
// wrong by half the runtime difference on every shifted release.
|
||||
var skew = 40.0;
|
||||
var match = AudioSignatureMatcher.Compare(
|
||||
GoldenSignature.Value,
|
||||
GoldenSignature.Value,
|
||||
FeatureRuntime + skew,
|
||||
FeatureRuntime);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal(0, match!.Value.OffsetFrames);
|
||||
Assert.Equal(skew / 2.0, match.Value.OffsetSec, 9);
|
||||
}
|
||||
|
||||
private static string Signature(byte[] source, int start, int count)
|
||||
=> AudioSignature.VersionPrefix + Convert.ToBase64String(source, start, count);
|
||||
|
||||
private static string PseudoRandomSignature(int frames, int seed)
|
||||
{
|
||||
var bytes = new byte[frames];
|
||||
var state = (uint)seed;
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
// Deterministic LCG — a fixed sequence, so a failure here is
|
||||
// reproducible rather than flaky.
|
||||
state = (state * 1664525u) + 1013904223u;
|
||||
bytes[i] = (byte)((((state >> 16) % AudioSignature.NumBands) << 2) | ((state >> 8) & 0x03));
|
||||
}
|
||||
|
||||
return AudioSignature.VersionPrefix + Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("JRAY_TEST_FFMPEG");
|
||||
if (!string.IsNullOrEmpty(configured))
|
||||
{
|
||||
return File.Exists(configured) ? configured : null;
|
||||
}
|
||||
|
||||
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-042 (the signature is computed exactly per server specification §3) and
|
||||
/// JR-043 (the golden-vector fixture shared with the extraction repo).
|
||||
///
|
||||
/// The headline claim is cross-repo: the C++ pipeline and this plugin are two
|
||||
/// independent implementations of one fingerprint, and two fingerprints that
|
||||
/// differ in any parameter simply do not match. That claim is only worth
|
||||
/// anything if it is checked, so `fixtures/audio/` holds the same three files
|
||||
/// the extraction repo holds — byte-identical — and these tests assert against
|
||||
/// the values recorded in them, never against each other.
|
||||
///
|
||||
/// The binding check needs no FFmpeg. `make_fixture.py` generates the fixture
|
||||
/// media from plain arithmetic, so UT-038 regenerates that PCM here and proves
|
||||
/// it is byte-identical to what the pipeline decoded, using the checksums the
|
||||
/// fixture records. Everything after that is pure DSP, which is what lets this
|
||||
/// run on any CI host — the extraction repo's counterpart (UT-101) drives the
|
||||
/// same vector through libavcodec, and both must land on the same string.
|
||||
///
|
||||
/// TRACES: UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044 | JR-042, JR-043
|
||||
/// </summary>
|
||||
public class AudioSignatureTests
|
||||
{
|
||||
private static readonly string FixtureDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
|
||||
|
||||
private static readonly Lazy<JsonDocument> GoldenDoc = new(() =>
|
||||
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json"))));
|
||||
|
||||
private static readonly Lazy<short[]> FixturePcm = new(GenerateFixturePcm);
|
||||
|
||||
private static readonly Lazy<float[]> FixtureSamples = new(() =>
|
||||
{
|
||||
var pcm = FixturePcm.Value;
|
||||
var samples = new float[pcm.Length];
|
||||
for (var i = 0; i < pcm.Length; i++)
|
||||
{
|
||||
// FFmpeg's native s16 -> flt conversion. 1/32768 is a power of two,
|
||||
// so this is exact rather than merely close.
|
||||
samples[i] = pcm[i] / 32768f;
|
||||
}
|
||||
|
||||
return samples;
|
||||
});
|
||||
|
||||
private static JsonElement Golden => GoldenDoc.Value.RootElement;
|
||||
|
||||
private static string GoldenSignature => Golden.GetProperty("signature").GetString()!;
|
||||
|
||||
// UT-038
|
||||
[Fact]
|
||||
public void RegeneratedFixturePcm_MatchesTheRecordedDecodedWindow()
|
||||
{
|
||||
// Checked before the signature, and separately from it, so a mismatch
|
||||
// is diagnosable: if this passes and UT-039 fails, the DSP diverged; if
|
||||
// this fails, the input did, and the signature comparison would only
|
||||
// have told you "different" without saying where.
|
||||
var pcm = FixturePcm.Value;
|
||||
var decoded = Golden.GetProperty("decoded_window");
|
||||
|
||||
Assert.Equal(decoded.GetProperty("samples").GetInt32(), pcm.Length);
|
||||
|
||||
var s16 = new byte[pcm.Length * sizeof(short)];
|
||||
for (var i = 0; i < pcm.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteInt16LittleEndian(s16.AsSpan(i * sizeof(short)), pcm[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(Hex64(decoded.GetProperty("s16le_fnv1a64").GetString()!), Fnv1a64(s16));
|
||||
|
||||
var f32 = new byte[FixtureSamples.Value.Length * sizeof(float)];
|
||||
for (var i = 0; i < FixtureSamples.Value.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteSingleLittleEndian(f32.AsSpan(i * sizeof(float)), FixtureSamples.Value[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(Hex64(decoded.GetProperty("f32le_fnv1a64").GetString()!), Fnv1a64(f32));
|
||||
}
|
||||
|
||||
// UT-039
|
||||
[Fact]
|
||||
public void Signature_OfTheGoldenFixture_MatchesTheRecordedValueExactly()
|
||||
{
|
||||
// The cross-repo check. Not "close", not "matches to within a tier" —
|
||||
// the same string the C++ producer emits for the same audio.
|
||||
Assert.Equal(GoldenSignature, AudioSignature.FromMonoSamples(FixtureSamples.Value));
|
||||
}
|
||||
|
||||
// UT-040
|
||||
[Fact]
|
||||
public void BandTable_MatchesTheRecordedOne_AndTilesTheRangeExactly()
|
||||
{
|
||||
// The band-to-FFT-bin table is the part of the construction most likely
|
||||
// to drift between two implementations — an off-by-one in a ceiling, a
|
||||
// half-open range read as closed — so it is pinned independently of the
|
||||
// signature it produces.
|
||||
var table = AudioSignature.BandFftBins();
|
||||
var want = Golden.GetProperty("band_fft_bins");
|
||||
|
||||
Assert.Equal(want.GetArrayLength(), table.Count);
|
||||
for (var b = 0; b < table.Count; b++)
|
||||
{
|
||||
Assert.Equal(want[b][0].GetInt32(), table[b].Low);
|
||||
Assert.Equal(want[b][1].GetInt32(), table[b].High);
|
||||
Assert.True(table[b].High > table[b].Low, $"band {b} is empty");
|
||||
if (b > 0)
|
||||
{
|
||||
// Contiguous, so the frame energy really is the sum of the band
|
||||
// sums — no gap, no bin counted twice.
|
||||
Assert.Equal(table[b - 1].High, table[b].Low);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UT-041
|
||||
[Fact]
|
||||
public void Signature_IsWellFormed_PrefixFrameCountAndStructuralBytes()
|
||||
{
|
||||
var signature = AudioSignature.FromMonoSamples(FixtureSamples.Value);
|
||||
Assert.NotNull(signature);
|
||||
|
||||
// JR-045 — the signature carries its own version, separate from
|
||||
// schema_version, so a future DSP change is detectable rather than
|
||||
// silently producing signatures that no longer match.
|
||||
Assert.StartsWith(AudioSignature.VersionPrefix, signature, StringComparison.Ordinal);
|
||||
|
||||
var bytes = Convert.FromBase64String(signature[AudioSignature.VersionPrefix.Length..]);
|
||||
Assert.Equal(Golden.GetProperty("frame_count").GetInt32(), bytes.Length);
|
||||
Assert.Equal(AudioSignature.ExpectedFrames, bytes.Length);
|
||||
|
||||
// The server validates this structure on upload: each byte is a 5-bit
|
||||
// band index plus a 2-bit energy class, so bit 7 is always clear and an
|
||||
// arbitrary byte is not a valid signature. That is what keeps the field
|
||||
// from being usable as a payload channel.
|
||||
var bandsSeen = new bool[AudioSignature.NumBands];
|
||||
var classesSeen = new bool[4];
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
Assert.Equal(0, b & 0x80);
|
||||
bandsSeen[(b >> 2) & 0x1F] = true;
|
||||
classesSeen[b & 0x03] = true;
|
||||
}
|
||||
|
||||
// The fixture is built to exercise the whole output alphabet. If it ever
|
||||
// stops doing so, the golden vector has become a weaker check than it
|
||||
// looks — so that property is asserted rather than assumed.
|
||||
Assert.All(bandsSeen, Assert.True);
|
||||
Assert.All(classesSeen, Assert.True);
|
||||
}
|
||||
|
||||
// UT-042
|
||||
[Fact]
|
||||
public void PackFrames_UsesWholeFramesOnly()
|
||||
{
|
||||
Assert.Empty(AudioSignature.PackFrames(new float[AudioSignature.FrameSize - 1]));
|
||||
Assert.Single(AudioSignature.PackFrames(new float[AudioSignature.FrameSize]));
|
||||
Assert.Single(AudioSignature.PackFrames(
|
||||
new float[AudioSignature.FrameSize + AudioSignature.HopSize - 1]));
|
||||
Assert.Equal(2, AudioSignature.PackFrames(
|
||||
new float[AudioSignature.FrameSize + AudioSignature.HopSize].AsSpan()).Length);
|
||||
|
||||
// A partial frame is not a signature: below one frame there is nothing
|
||||
// to emit, and emitting a padded frame would be a different fingerprint
|
||||
// from the pipeline's.
|
||||
Assert.Null(AudioSignature.FromMonoSamples(new float[AudioSignature.FrameSize - 1]));
|
||||
|
||||
// The full window is 1288 frames — asserted as a constant rather than by
|
||||
// running the DSP over 1.3M zeros, which is the same claim for free.
|
||||
Assert.Equal(1323000, AudioSignature.WindowSamples);
|
||||
Assert.Equal(
|
||||
1 + ((AudioSignature.WindowSamples - AudioSignature.FrameSize) / AudioSignature.HopSize),
|
||||
AudioSignature.ExpectedFrames);
|
||||
}
|
||||
|
||||
// UT-043
|
||||
[Fact]
|
||||
public async Task Decode_ThroughFfmpeg_ReproducesTheGoldenSignature()
|
||||
{
|
||||
// The one test that exercises the real decode — the command line, the
|
||||
// stream selection, the downmix and resample — rather than the DSP
|
||||
// alone. It needs an FFmpeg binary, which the plugin gets from Jellyfin
|
||||
// at run time and which a bare CI container may not have; UT-038 and
|
||||
// UT-039 are what make the cross-repo claim binding without one.
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var signature = await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
ffmpeg,
|
||||
Path.Combine(FixtureDir, "jray_audio_v1_tone.flac"),
|
||||
AudioSignature.WindowSec,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
Assert.Equal(GoldenSignature, signature);
|
||||
}
|
||||
|
||||
// UT-044
|
||||
[Fact]
|
||||
public async Task Decode_TakesTheWindowFromTheCentre_NotTheHead()
|
||||
{
|
||||
// Sampling from the centre is the whole reason the construction avoids
|
||||
// the head and tail — logos and cold opens at one end, credits at the
|
||||
// other — so it needs its own check. Nothing else here pins the seek: a
|
||||
// head-anchored window passes every other test in this file.
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pad = 90 * AudioSignature.SampleRate;
|
||||
var padded = new short[(pad * 2) + FixturePcm.Value.Length];
|
||||
FixturePcm.Value.CopyTo(padded, pad);
|
||||
|
||||
var wav = Path.Combine(Path.GetTempPath(), $"jray_audio_centre_{Environment.ProcessId}.wav");
|
||||
try
|
||||
{
|
||||
WriteWav(wav, padded);
|
||||
var signature = await AudioSignatureService.ComputeWithEncoderAsync(
|
||||
ffmpeg,
|
||||
wav,
|
||||
padded.Length / (double)AudioSignature.SampleRate,
|
||||
NullLogger.Instance,
|
||||
CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
Assert.Equal(GoldenSignature, signature);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(wav);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates the fixture media's PCM, as <c>make_fixture.py</c> defines
|
||||
/// it: 120 s of tones stepping through all 32 log-bands, amplitudes walking
|
||||
/// a golden-ratio sequence so all four energy classes appear, over a quiet
|
||||
/// constant 777 Hz bed so no frame is degenerate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A port, not a re-derivation — the point is that a repo can rebuild the
|
||||
/// input from scratch and check the result against the recorded checksums,
|
||||
/// which is what UT-038 does. The closest sample to a quantisation boundary
|
||||
/// sits 3.7e-7 away from one, so the result does not depend on which libm
|
||||
/// rounds the sine.
|
||||
/// </remarks>
|
||||
private static short[] GenerateFixturePcm()
|
||||
{
|
||||
const int Segment = 32768;
|
||||
const int BandStride = 7;
|
||||
const double AmpLogMin = -1.55;
|
||||
const double AmpLogSpan = 1.53;
|
||||
const double PhiFrac = 0.6180339887498949;
|
||||
const double BackgroundHz = 777.0;
|
||||
const double BackgroundAmp = 0.004;
|
||||
|
||||
var n = (int)Math.Round(AudioSignature.SampleRate * AudioSignature.WindowSec);
|
||||
var samples = new short[n];
|
||||
var twoPi = 2.0 * Math.PI;
|
||||
var phase = 0.0;
|
||||
|
||||
for (var start = 0; start < n; start += Segment)
|
||||
{
|
||||
var s = start / Segment;
|
||||
var end = Math.Min(n, start + Segment);
|
||||
var band = (s * BandStride) % AudioSignature.NumBands;
|
||||
var freq = AudioSignature.BandLoHz * Math.Pow(
|
||||
AudioSignature.BandHiHz / AudioSignature.BandLoHz,
|
||||
(band + 0.5) / AudioSignature.NumBands);
|
||||
var amp = Math.Pow(10.0, AmpLogMin + (AmpLogSpan * ((s * PhiFrac) % 1.0)));
|
||||
var step = twoPi * freq / AudioSignature.SampleRate;
|
||||
|
||||
for (var k = 0; k < end - start; k++)
|
||||
{
|
||||
var i = start + k;
|
||||
var x = amp * Math.Sin(phase + (step * k));
|
||||
x += BackgroundAmp * Math.Sin(twoPi * BackgroundHz * i / AudioSignature.SampleRate);
|
||||
x = Math.Clamp(x, -1.0, 1.0);
|
||||
samples[i] = (short)Math.Floor((x * 32767.0) + 0.5);
|
||||
}
|
||||
|
||||
phase = (phase + (step * (end - start))) % twoPi;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static void WriteWav(string path, short[] samples)
|
||||
{
|
||||
var dataBytes = samples.Length * sizeof(short);
|
||||
using var stream = File.Create(path);
|
||||
using var writer = new BinaryWriter(stream);
|
||||
writer.Write("RIFF"u8);
|
||||
writer.Write(36 + dataBytes);
|
||||
writer.Write("WAVE"u8);
|
||||
writer.Write("fmt "u8);
|
||||
writer.Write(16); // PCM header size
|
||||
writer.Write((short)1); // PCM
|
||||
writer.Write((short)1); // mono
|
||||
writer.Write(AudioSignature.SampleRate);
|
||||
writer.Write(AudioSignature.SampleRate * sizeof(short));
|
||||
writer.Write((short)sizeof(short)); // block align
|
||||
writer.Write((short)16); // bits per sample
|
||||
writer.Write("data"u8);
|
||||
writer.Write(dataBytes);
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
writer.Write(sample);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("JRAY_TEST_FFMPEG");
|
||||
if (!string.IsNullOrEmpty(configured))
|
||||
{
|
||||
return File.Exists(configured) ? configured : null;
|
||||
}
|
||||
|
||||
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ulong Fnv1a64(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var hash = 0xcbf29ce484222325UL;
|
||||
foreach (var b in data)
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= 0x100000001b3UL;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static ulong Hex64(string value)
|
||||
{
|
||||
var digits = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value;
|
||||
return ulong.Parse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-023 — absent the File Transformation plugin, disable *only* the overlay
|
||||
/// and say so. There is no on-disk fallback (JR-021), so the failure path is
|
||||
/// the one an admin will actually meet, and it has to be legible: silently
|
||||
/// serving no overlay is indistinguishable from a broken install.
|
||||
///
|
||||
/// The File Transformation assembly is not loaded in the test host, so
|
||||
/// TryRegister exercises its not-found branch for real rather than through a
|
||||
/// seam invented for the test.
|
||||
///
|
||||
/// TRACES: UT-012, UT-013, UT-014, UT-015 | JR-023
|
||||
/// </summary>
|
||||
public class FileTransformationRegistrationTests
|
||||
{
|
||||
// UT-012
|
||||
[Fact]
|
||||
public void TryRegister_WithPluginAbsent_ReturnsFalse()
|
||||
{
|
||||
Assert.False(FileTransformationRegistration.TryRegister(new CapturingLogger()));
|
||||
}
|
||||
|
||||
// UT-013
|
||||
[Fact]
|
||||
public void TryRegister_WithPluginAbsent_WarnsNamingThePluginAndHowToInstallIt()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
FileTransformationRegistration.TryRegister(logger);
|
||||
|
||||
var entry = Assert.Single(logger.Entries);
|
||||
|
||||
// Warning, not Information: the overlay is a headline feature and it is
|
||||
// off. Logging this at Information buries it among startup chatter.
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
|
||||
// The message has to carry the install URL, and must not promise the
|
||||
// on-disk fallback that JR-021 removed.
|
||||
Assert.Contains(FileTransformationRegistration.ManifestUrl, entry.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("falling back", entry.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// UT-014
|
||||
[Fact]
|
||||
public void TransformIndexHtml_WithOverlayDisabled_ReturnsContentsUnchanged()
|
||||
{
|
||||
// No Plugin instance exists in the test host, so OverlayEnabled is
|
||||
// false — the same branch taken when an admin switches the overlay off.
|
||||
var html = "<html><body><div>x</div>\n</body></html>";
|
||||
|
||||
var result = FileTransformationRegistration.TransformIndexHtml(
|
||||
new TransformationPayload { Contents = html });
|
||||
|
||||
Assert.Equal(html, result);
|
||||
}
|
||||
|
||||
// UT-015
|
||||
[Fact]
|
||||
public void TransformIndexHtml_WithNullContents_ReturnsEmptyRatherThanThrowing()
|
||||
{
|
||||
// This callback is invoked by another plugin's code on every page
|
||||
// served. Throwing here would break the web client itself, not just
|
||||
// JRay's overlay.
|
||||
var result = FileTransformationRegistration.TransformIndexHtml(new TransformationPayload());
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!--
|
||||
The plugin project treats warnings as errors and runs StyleCop. Tests do
|
||||
not inherit that: their naming conventions differ deliberately (Method_
|
||||
Condition_Expectation reads as documentation, and trips SA1300-family
|
||||
rules), and a style failure in a test is not a defect in the thing under
|
||||
test.
|
||||
-->
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<!--
|
||||
The plugin targets net9.0 to match Jellyfin's ABI, but a machine that can
|
||||
build it need not have the 9.0 *runtime* installed. Roll the test host
|
||||
forward to whatever major is present so the suite runs on a developer box
|
||||
and on CI without pinning either to a runtime that is not the plugin's.
|
||||
-->
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<!--
|
||||
The plugin framework-references Microsoft.AspNetCore.App through
|
||||
Jellyfin.Controller, and that reference flows into anything referencing
|
||||
the plugin. The T1 tier tests pure logic that touches no web type, so
|
||||
inheriting the web framework would make the suite unrunnable on any box
|
||||
without the ASP.NET Core runtime for no benefit. .NET resolves assemblies
|
||||
lazily, so types that never touch ASP.NET load fine without it.
|
||||
|
||||
T2 (controllers, authorisation) genuinely needs that runtime. When those
|
||||
tests arrive they belong in a second project that keeps this reference.
|
||||
-->
|
||||
<DisableTransitiveFrameworkReferences>true</DisableTransitiveFrameworkReferences>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
The plugin sets ExcludeAssets=runtime on these: at run time the Jellyfin
|
||||
server supplies them, so shipping copies in the plugin would risk loading
|
||||
a second, different MediaBrowser.Common. The test host is not the server,
|
||||
so it has to bring its own — hence the same packages without that
|
||||
exclusion, and only here.
|
||||
-->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
The audio-signature golden vector, shared verbatim with the extraction
|
||||
repo (JR-043): the same three files, byte for byte, in both repos. Two
|
||||
independent implementations of one fingerprint are only useful if they
|
||||
agree exactly, and this is what makes that a checked claim.
|
||||
-->
|
||||
<Content Include="fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-010 — three sources now deliver truth data (sidecar, pushed, fetched) and
|
||||
/// they are not interchangeable. A locally computed sidecar and a
|
||||
/// <c>loose</c>-tier manifest from a third-party server make claims of very
|
||||
/// different strength about the same item, and the truth file itself records
|
||||
/// nothing about how it arrived.
|
||||
///
|
||||
/// TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010
|
||||
/// </summary>
|
||||
public class ManagedTruthStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly ManagedTruthStore _store;
|
||||
|
||||
public ManagedTruthStoreTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "jray-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_root);
|
||||
_store = new ManagedTruthStore(new FakePaths(_root), NullLogger<ManagedTruthStore>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static TruthFile Truth()
|
||||
{
|
||||
var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion, Movie = "/m.mkv" };
|
||||
var actor = new TruthActor { Name = "A", TmdbId = "884" };
|
||||
actor.Scenes.Add(new TruthScene { Start = 1.0, End = 2.0 });
|
||||
truth.Actors.Add(actor);
|
||||
return truth;
|
||||
}
|
||||
|
||||
// UT-024
|
||||
[Fact]
|
||||
public async Task SaveAsync_ThenLoadProvenance_RoundTripsAFetchedClaim()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var recorded = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await _store.SaveAsync(
|
||||
id,
|
||||
Truth(),
|
||||
new TruthProvenance
|
||||
{
|
||||
Source = TruthSource.Fetched,
|
||||
ServerUrl = "https://jray.example",
|
||||
MatchTier = MatchTier.Loose,
|
||||
OffsetSec = -12.5,
|
||||
Caveat = "loose match",
|
||||
RecordedAt = recorded,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
var loaded = _store.LoadProvenance(id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(TruthSource.Fetched, loaded!.Source);
|
||||
Assert.Equal("https://jray.example", loaded.ServerUrl);
|
||||
Assert.Equal(MatchTier.Loose, loaded.MatchTier);
|
||||
|
||||
// The offset is unrecoverable once applied: the stored windows look
|
||||
// native, and nothing else would say they had been shifted.
|
||||
Assert.Equal(-12.5, loaded.OffsetSec);
|
||||
Assert.Equal("loose match", loaded.Caveat);
|
||||
Assert.Equal(recorded, loaded.RecordedAt);
|
||||
}
|
||||
|
||||
// UT-025
|
||||
[Fact]
|
||||
public async Task SaveAsync_LocalPush_RecordsNoServerOrTier()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
await _store.SaveAsync(
|
||||
id,
|
||||
Truth(),
|
||||
TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow),
|
||||
CancellationToken.None);
|
||||
|
||||
var loaded = _store.LoadProvenance(id)!;
|
||||
|
||||
Assert.Equal(TruthSource.Pushed, loaded.Source);
|
||||
Assert.Equal(string.Empty, loaded.ServerUrl);
|
||||
|
||||
// A push is about *this* file, so there is no cut to match. A tier here
|
||||
// would be a fabricated claim.
|
||||
Assert.Null(loaded.MatchTier);
|
||||
}
|
||||
|
||||
// UT-026
|
||||
[Fact]
|
||||
public async Task SaveAsync_DoesNotWriteProvenanceIntoTheTruthFile()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None);
|
||||
|
||||
var truthJson = await File.ReadAllTextAsync(
|
||||
Path.Combine(_root, "plugins", "configurations", "JRay", "truth", id.ToString("D") + ".json"));
|
||||
|
||||
// JR-004: the bytes served back are the bytes the producer wrote.
|
||||
Assert.DoesNotContain("source", truthJson, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("match_tier", truthJson, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// UT-027
|
||||
[Fact]
|
||||
public async Task Delete_RemovesProvenanceToo()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None);
|
||||
|
||||
Assert.True(_store.Delete(id));
|
||||
|
||||
// A stale provenance record outliving its truth file would describe data
|
||||
// the next fetch has already replaced.
|
||||
Assert.Null(_store.LoadProvenance(id));
|
||||
Assert.False(_store.Exists(id));
|
||||
}
|
||||
|
||||
// UT-028
|
||||
[Fact]
|
||||
public void LoadProvenance_ForUnknownItem_ReturnsNull()
|
||||
{
|
||||
Assert.Null(_store.LoadProvenance(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
private sealed class FakePaths : IApplicationPaths
|
||||
{
|
||||
public FakePaths(string root)
|
||||
{
|
||||
ProgramDataPath = root;
|
||||
PluginsPath = Path.Combine(root, "plugins");
|
||||
PluginConfigurationsPath = Path.Combine(root, "plugins", "configurations");
|
||||
}
|
||||
|
||||
public string ProgramDataPath { get; }
|
||||
|
||||
public string WebPath => Path.Combine(ProgramDataPath, "web");
|
||||
|
||||
public string ProgramSystemPath => ProgramDataPath;
|
||||
|
||||
public string DataPath => ProgramDataPath;
|
||||
|
||||
public string ImageCachePath => ProgramDataPath;
|
||||
|
||||
public string PluginsPath { get; }
|
||||
|
||||
public string PluginConfigurationsPath { get; }
|
||||
|
||||
public string LogDirectoryPath => ProgramDataPath;
|
||||
|
||||
public string ConfigurationDirectoryPath => ProgramDataPath;
|
||||
|
||||
public string SystemConfigurationFilePath => Path.Combine(ProgramDataPath, "system.xml");
|
||||
|
||||
public string CachePath { get; set; } = string.Empty;
|
||||
|
||||
public string TempDirectory => Path.Combine(ProgramDataPath, "temp");
|
||||
|
||||
public string TrickplayPath => Path.Combine(ProgramDataPath, "trickplay");
|
||||
|
||||
public string VirtualDataPath => ProgramDataPath;
|
||||
|
||||
public string BackupPath => Path.Combine(ProgramDataPath, "backup");
|
||||
|
||||
public void MakeSanityCheckOrThrow()
|
||||
{
|
||||
}
|
||||
|
||||
public void CreateAndCheckMarker(string path, string markerName, bool recursive = false)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-047 — a fetched manifest is aligned against the local file before its
|
||||
/// windows are stored, and how that was decided is recorded.
|
||||
///
|
||||
/// The claim under test is that **the local alignment supersedes the server's
|
||||
/// offset**. The server has never seen this file; its offset can only be a
|
||||
/// runtime-difference inference, while a local alignment compares the manifest's
|
||||
/// own signature against the media the windows will be drawn over.
|
||||
///
|
||||
/// The counterweight is that a signature must never break a fetch. Every way the
|
||||
/// local path can fail — switched off, no manifest signature, short media, a
|
||||
/// failed decode, or two signatures that do not match — has to fall back to the
|
||||
/// server's offset rather than refusing. Both halves are asserted here.
|
||||
///
|
||||
/// `Resolve` is the decision, split out from the decode so it can be driven
|
||||
/// without FFmpeg or a media file.
|
||||
///
|
||||
/// TRACES: UT-053, UT-054, UT-055, UT-056, UT-057 | JR-047
|
||||
/// </summary>
|
||||
public class ManifestAlignerTests
|
||||
{
|
||||
private const double FeatureRuntime = 7200.0;
|
||||
private const double ServerOffset = 3.5;
|
||||
|
||||
private static readonly string FixtureDir =
|
||||
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
|
||||
|
||||
private static readonly Lazy<string> GoldenSignature = new(() =>
|
||||
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))
|
||||
.RootElement.GetProperty("signature").GetString()!);
|
||||
|
||||
private static readonly Lazy<byte[]> GoldenFrames = new(() =>
|
||||
AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value)!);
|
||||
|
||||
// UT-053
|
||||
[Fact]
|
||||
public void ALocalAlignment_SupersedesTheServersOffset()
|
||||
{
|
||||
// Same cut, sampled 100 frames apart — a differently trimmed release.
|
||||
// The server offered 3.5 s from a runtime comparison; the local audio
|
||||
// says otherwise, and the local answer is the one that gets applied.
|
||||
const int Shift = 100;
|
||||
var source = GoldenFrames.Value;
|
||||
|
||||
var alignment = ManifestAligner.Resolve(
|
||||
Signature(source, 44, 1000),
|
||||
Signature(source, 144, 1000),
|
||||
FeatureRuntime,
|
||||
FeatureRuntime,
|
||||
MatchTier.Runtime,
|
||||
ServerOffset);
|
||||
|
||||
Assert.Equal(AlignmentSource.Local, alignment.Source);
|
||||
Assert.Equal(MatchTier.Audio, alignment.Tier);
|
||||
Assert.Equal(Shift, alignment.OffsetFrames);
|
||||
Assert.Equal(1.0, alignment.Score!.Value);
|
||||
Assert.Equal(Shift * AudioSignatureMatcher.FrameSeconds, alignment.OffsetSec, 9);
|
||||
|
||||
// The server's claim is kept rather than overwritten: the applied offset
|
||||
// is otherwise unrecoverable once the windows are shifted, and the two
|
||||
// disagreeing is exactly what someone debugging would need to see.
|
||||
Assert.Equal(ServerOffset, alignment.ServerOffsetSec);
|
||||
Assert.Equal(MatchTier.Runtime, alignment.ServerTier);
|
||||
}
|
||||
|
||||
// UT-054
|
||||
[Fact]
|
||||
public void TheLocalSignature_IsRecorded_SoALaterFetchNeedNotDecodeAgain()
|
||||
{
|
||||
// The decode is the expensive half and the reason signatures are opt-in.
|
||||
// Keeping the local one beside the truth file is what lets a second
|
||||
// manifest be aligned for free.
|
||||
var alignment = ManifestAligner.Resolve(
|
||||
GoldenSignature.Value,
|
||||
GoldenSignature.Value,
|
||||
FeatureRuntime,
|
||||
FeatureRuntime,
|
||||
MatchTier.Runtime,
|
||||
ServerOffset);
|
||||
|
||||
Assert.Equal(GoldenSignature.Value, alignment.LocalSignature);
|
||||
Assert.Equal(AlignmentSource.Local, alignment.Source);
|
||||
Assert.Equal(0.0, alignment.OffsetSec);
|
||||
}
|
||||
|
||||
// UT-055
|
||||
[Fact]
|
||||
public void EveryUnavailableLocalPath_FallsBackToTheServer_RatherThanRefusing()
|
||||
{
|
||||
// A signature is an enhancement to cut matching. A missing one costs a
|
||||
// tier; it must never be able to break a fetch, so each of these stores
|
||||
// the manifest on the server's terms.
|
||||
var cases = new (string? Local, string? Manifest, string Why)[]
|
||||
{
|
||||
(null, GoldenSignature.Value, "signatures off, or the decode failed"),
|
||||
(GoldenSignature.Value, null, "the manifest carried no signature"),
|
||||
(null, null, "neither side has one"),
|
||||
};
|
||||
|
||||
foreach (var (local, manifest, why) in cases)
|
||||
{
|
||||
var alignment = ManifestAligner.Resolve(
|
||||
local, manifest, FeatureRuntime, FeatureRuntime, MatchTier.Runtime, ServerOffset);
|
||||
|
||||
Assert.Equal(AlignmentSource.Server, alignment.Source);
|
||||
Assert.Equal(ServerOffset, alignment.OffsetSec);
|
||||
Assert.Equal(MatchTier.Runtime, alignment.Tier);
|
||||
Assert.Null(alignment.Score);
|
||||
Assert.Null(alignment.OffsetFrames);
|
||||
}
|
||||
|
||||
// Short media is the same fallback, reached through JR-044 rather than
|
||||
// through a missing string: both sides have a perfectly valid signature
|
||||
// and it is still the runtime that decides.
|
||||
var shortMedia = ManifestAligner.Resolve(
|
||||
GoldenSignature.Value,
|
||||
GoldenSignature.Value,
|
||||
119.0,
|
||||
FeatureRuntime,
|
||||
MatchTier.Runtime,
|
||||
ServerOffset);
|
||||
|
||||
Assert.Equal(AlignmentSource.Server, shortMedia.Source);
|
||||
Assert.Equal(ServerOffset, shortMedia.OffsetSec);
|
||||
|
||||
// A `v2:` signature from a future producer is *un-comparable*, not a
|
||||
// mismatch (JR-045). Reporting it as one would tell the user their audio
|
||||
// disagrees with the manifest when all that happened is the producer
|
||||
// moved ahead of this build.
|
||||
var futureProducer = ManifestAligner.Resolve(
|
||||
GoldenSignature.Value,
|
||||
"v2:" + GoldenSignature.Value[AudioSignature.VersionPrefix.Length..],
|
||||
FeatureRuntime,
|
||||
FeatureRuntime,
|
||||
MatchTier.Runtime,
|
||||
ServerOffset);
|
||||
|
||||
Assert.Equal(AlignmentSource.Server, futureProducer.Source);
|
||||
Assert.Equal(ServerOffset, futureProducer.OffsetSec);
|
||||
}
|
||||
|
||||
// UT-056
|
||||
[Fact]
|
||||
public void TwoSignaturesThatDoNotMatch_AreRecorded_ButStillDoNotBreakTheFetch()
|
||||
{
|
||||
// The strongest available hint that a manifest describes different
|
||||
// content. It is not treated as a failure — the audio may legitimately
|
||||
// differ, a different language track being the obvious case — but it is
|
||||
// not discarded either.
|
||||
var alignment = ManifestAligner.Resolve(
|
||||
GoldenSignature.Value,
|
||||
PseudoRandomSignature(1288, seed: 4242),
|
||||
FeatureRuntime,
|
||||
FeatureRuntime,
|
||||
MatchTier.Runtime,
|
||||
ServerOffset);
|
||||
|
||||
Assert.Equal(AlignmentSource.LocalMismatch, alignment.Source);
|
||||
|
||||
// The manifest is still stored, on the server's terms.
|
||||
Assert.Equal(ServerOffset, alignment.OffsetSec);
|
||||
Assert.Equal(MatchTier.Runtime, alignment.Tier);
|
||||
}
|
||||
|
||||
// UT-057
|
||||
[Fact]
|
||||
public void AMismatchOutranksTheTier_InTheCaveatShownToTheUser()
|
||||
{
|
||||
// A `runtime`-tier match normally needs no caveat at all, so without
|
||||
// this the strongest warning available would be the one never shown.
|
||||
var mismatch = new TruthAlignment { Source = AlignmentSource.LocalMismatch };
|
||||
|
||||
var caveat = ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0.0, mismatch);
|
||||
Assert.NotNull(caveat);
|
||||
Assert.Contains("does not match", caveat, StringComparison.Ordinal);
|
||||
|
||||
// It outranks `loose` too, which would otherwise have claimed the slot
|
||||
// with the weaker statement — that the runtimes differ, not the audio.
|
||||
var looseCaveat = ManifestConverter.DescribeCaveat(MatchTier.Loose, 0.0, mismatch);
|
||||
Assert.Contains("does not match", looseCaveat!, StringComparison.Ordinal);
|
||||
|
||||
// A clean local alignment keeps the existing behaviour: a shift is
|
||||
// explained, an aligned match needs nothing.
|
||||
var local = new TruthAlignment { Source = AlignmentSource.Local };
|
||||
Assert.Contains("shifted by", ManifestConverter.DescribeCaveat(MatchTier.Audio, 9.29, local)!, StringComparison.Ordinal);
|
||||
Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Audio, 0.0, local));
|
||||
}
|
||||
|
||||
private static string Signature(byte[] source, int start, int count)
|
||||
=> AudioSignature.VersionPrefix + Convert.ToBase64String(source, start, count);
|
||||
|
||||
private static string PseudoRandomSignature(int frames, int seed)
|
||||
{
|
||||
var bytes = new byte[frames];
|
||||
var state = (uint)seed;
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
state = (state * 1664525u) + 1013904223u;
|
||||
bytes[i] = (byte)((((state >> 16) % AudioSignature.NumBands) << 2) | ((state >> 8) & 0x03));
|
||||
}
|
||||
|
||||
return AudioSignature.VersionPrefix + Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the manifest exchange: tier policy, transport rules, validation on
|
||||
/// receipt, and offset application.
|
||||
/// </summary>
|
||||
public class ManifestExchangeTests
|
||||
{
|
||||
private static ManifestServer Server(string url) =>
|
||||
new() { Url = url, Name = "test", Enabled = true };
|
||||
|
||||
private static TitleQuery Query() =>
|
||||
new() { TmdbId = "504172", RuntimeSec = 6420.5 };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The withdrawn file-hash tier
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MatchTierHasNoExactMember()
|
||||
{
|
||||
// The `exact` tier keyed on an OpenSubtitles file hash and was withdrawn
|
||||
// on legal grounds: a file hash identifies the exact release a user
|
||||
// holds, not the cut the timings describe, so sending one turns a
|
||||
// catalogue lookup into a release-identification service.
|
||||
//
|
||||
// Asserted on the enum rather than trusted, because "we removed it" is
|
||||
// exactly the kind of decision a later reader re-adds as an oversight.
|
||||
var names = Enum.GetNames<MatchTier>();
|
||||
Assert.DoesNotContain("Exact", names);
|
||||
Assert.Equal(new[] { "Loose", "Runtime", "Audio" }, names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioIsTheTopTier()
|
||||
{
|
||||
// Content-derived, so it identifies the cut rather than the copy — which
|
||||
// is what makes it an acceptable replacement for the file hash.
|
||||
Assert.True(MatchTier.Audio > MatchTier.Runtime);
|
||||
Assert.True(MatchTier.Runtime > MatchTier.Loose);
|
||||
Assert.Equal(MatchTier.Audio, Enum.GetValues<MatchTier>().Max());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoVideoHashIsEverSent()
|
||||
{
|
||||
// Structural, not merely policy: `TitleQuery` has no VideoHash property,
|
||||
// so there is nothing a future caller could populate.
|
||||
Assert.Null(typeof(TitleQuery).GetProperty("VideoHash"));
|
||||
|
||||
var query = new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 };
|
||||
Assert.DoesNotContain("video_hash", query.ToQueryString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AServerReportingTheWithdrawnTierIsDeclined()
|
||||
{
|
||||
// A server may still hold hashes from other clients. If one somehow
|
||||
// reports `exact`, it is unrecognised rather than silently accepted
|
||||
// under a tier this plugin has no policy for.
|
||||
Assert.Null(ManifestExchangeClient.ParseTier("exact"));
|
||||
Assert.Equal(MatchTier.Audio, ManifestExchangeClient.ParseTier("audio"));
|
||||
Assert.Equal(MatchTier.Runtime, ManifestExchangeClient.ParseTier("runtime"));
|
||||
Assert.Equal(MatchTier.Loose, ManifestExchangeClient.ParseTier("loose"));
|
||||
Assert.Null(ManifestExchangeClient.ParseTier("nonsense"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Transport
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://jray.tourolle.paris", true)]
|
||||
[InlineData("https://third.party.example", true)]
|
||||
[InlineData("http://127.0.0.1:8080", true)]
|
||||
[InlineData("http://localhost:8080", true)]
|
||||
[InlineData("http://jray.tourolle.paris", false)]
|
||||
[InlineData("http://192.168.1.10:8080", false)]
|
||||
[InlineData("ftp://example.com", false)]
|
||||
public void HttpsIsRequiredAwayFromLoopback(string url, bool acceptable)
|
||||
{
|
||||
// A plaintext server would let any network intermediary rewrite actor
|
||||
// overlays, and the overlay is shown to the user as fact. Loopback is
|
||||
// exempt because there is no network path to intercept.
|
||||
Assert.Equal(acceptable, ManifestExchangeClient.IsTransportAcceptable(new Uri(url)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnusableServerUrlYieldsNoRequest()
|
||||
{
|
||||
Assert.Null(ManifestExchangeClient.BuildUrl(Server("http://example.com"), "manifests/movie", Query()));
|
||||
Assert.Null(ManifestExchangeClient.BuildUrl(Server("not a url"), "manifests/movie", Query()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryParametersAreEscaped()
|
||||
{
|
||||
var url = ManifestExchangeClient.BuildUrl(
|
||||
Server("https://s.example/"),
|
||||
"manifests/movie",
|
||||
new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 });
|
||||
|
||||
Assert.NotNull(url);
|
||||
Assert.StartsWith("https://s.example/api/v1/manifests/movie?", url!.AbsoluteUri, StringComparison.Ordinal);
|
||||
Assert.Contains("tmdb_id=504172", url.AbsoluteUri, StringComparison.Ordinal);
|
||||
// Invariant formatting, so a comma-decimal locale cannot corrupt the runtime.
|
||||
Assert.Contains("runtime_sec=6420.5", url.AbsoluteUri, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EpisodeCoordinatesAreSent()
|
||||
{
|
||||
var url = ManifestExchangeClient.BuildUrl(
|
||||
Server("https://s.example"),
|
||||
"manifests/episode",
|
||||
new TitleQuery { SeriesTmdbId = "1396", Season = 2, Episode = 5, RuntimeSec = 2820 });
|
||||
|
||||
Assert.NotNull(url);
|
||||
Assert.Contains("series_tmdb_id=1396", url!.AbsoluteUri, StringComparison.Ordinal);
|
||||
Assert.Contains("season=2", url.AbsoluteUri, StringComparison.Ordinal);
|
||||
Assert.Contains("episode=5", url.AbsoluteUri, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation on receipt — every server is untrusted
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static Jmanifest ValidManifest()
|
||||
{
|
||||
var m = new Jmanifest { JmanifestVersion = 2 };
|
||||
m.Identity = new JmanifestIdentity { Type = "movie", TmdbId = "504172" };
|
||||
m.Cut = new JmanifestCut { RuntimeSec = 6420.5 };
|
||||
var actor = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
|
||||
actor.Scenes.Add(new JmanifestScene { Start = 191.6, End = 209.2, Belief = 0.98, Route = "live" });
|
||||
m.Actors.Add(actor);
|
||||
return m;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AWellFormedManifestValidates()
|
||||
{
|
||||
Assert.True(ManifestValidator.TryValidate(ValidManifest(), 6420.5, out var error), error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnknownEnvelopeVersionIsRefused()
|
||||
{
|
||||
// Never guessed at: a server one version ahead may have changed the
|
||||
// meaning of a field this plugin thinks it understands.
|
||||
foreach (var version in new[] { 0, 1, 3, 99 })
|
||||
{
|
||||
var m = ValidManifest();
|
||||
m.JmanifestVersion = version;
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
|
||||
Assert.Contains("jmanifest_version", error, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsBeyondTheLocalRuntimeAreRejected()
|
||||
{
|
||||
// Bounds are checked against the *local* file, because that is what the
|
||||
// overlay indexes into. A window past the end is evidence the manifest
|
||||
// describes another cut.
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Scenes.Clear();
|
||||
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 9000 });
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
|
||||
Assert.Contains("runtime", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertedNegativeAndNonFiniteWindowsAreRejected()
|
||||
{
|
||||
foreach (var scene in new[]
|
||||
{
|
||||
new JmanifestScene { Start = 50, End = 10 },
|
||||
new JmanifestScene { Start = -1, End = 10 },
|
||||
new JmanifestScene { Start = double.NaN, End = 10 },
|
||||
new JmanifestScene { Start = 0, End = double.PositiveInfinity },
|
||||
})
|
||||
{
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Scenes.Clear();
|
||||
m.Actors[0].Scenes.Add(scene);
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeliefOutsideZeroToOneIsRejected()
|
||||
{
|
||||
foreach (var belief in new[] { -0.1, 1.5, double.NaN })
|
||||
{
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Scenes[0].Belief = belief;
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
|
||||
Assert.Contains("belief", error, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedIdentifiersAreRejected()
|
||||
{
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].TmdbId = "884'; DROP TABLE--";
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
|
||||
|
||||
m = ValidManifest();
|
||||
m.Actors[0].ImdbId = "tt0000114"; // a title id in a person field
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControlCharactersInANameAreRejected()
|
||||
{
|
||||
// The overlay renders names as text nodes, so markup is already inert —
|
||||
// but a bidi override still makes a name display as something other than
|
||||
// what was stored.
|
||||
foreach (var name in new[] { "SteveBuscemi", "SteveimecsuB", "SteveBuscemi" })
|
||||
{
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Name = name;
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealNamesAreAccepted()
|
||||
{
|
||||
foreach (var name in new[] { "Steve Buscemi", "Renée Zellweger", "宮崎 駿", "O'Brien" })
|
||||
{
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Name = name;
|
||||
Assert.True(ManifestValidator.TryValidate(m, 6420.5, out var error), $"{name}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateActorsAreRejected()
|
||||
{
|
||||
var m = ValidManifest();
|
||||
var dup = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
|
||||
dup.Scenes.Add(new JmanifestScene { Start = 1, End = 2 });
|
||||
m.Actors.Add(dup);
|
||||
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Offset application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TheOffsetIsAppliedToEveryWindow()
|
||||
{
|
||||
// Applied once, at store time, so the stored truth is always in the
|
||||
// local file's timebase and no reader needs offset awareness.
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Scenes.Clear();
|
||||
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 100, End = 120 });
|
||||
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 200, End = 220 });
|
||||
|
||||
var truth = ManifestConverter.ToTruthFile(m, 40, "/media/film.mkv");
|
||||
|
||||
Assert.Equal((140.0, 160.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
|
||||
Assert.Equal((240.0, 260.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANegativeOffsetCannotPushAWindowBelowZero()
|
||||
{
|
||||
// A start before the file begins is not indexable by any reader.
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Scenes.Clear();
|
||||
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 5, End = 20 });
|
||||
|
||||
var truth = ManifestConverter.ToTruthFile(m, -40, "/media/film.mkv");
|
||||
|
||||
Assert.Equal(0.0, truth.Actors[0].Scenes[0].Start);
|
||||
Assert.True(truth.Actors[0].Scenes[0].End >= truth.Actors[0].Scenes[0].Start);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsAreShiftedNeverReshaped()
|
||||
{
|
||||
// A window is a claim about scene membership (SR-002), so merging
|
||||
// adjacent windows would answer a different question than the pipeline
|
||||
// answered — "was a face visible" rather than "was the actor present".
|
||||
var m = ValidManifest();
|
||||
m.Actors[0].Scenes.Clear();
|
||||
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 20 });
|
||||
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 20, End = 30 });
|
||||
|
||||
var truth = ManifestConverter.ToTruthFile(m, 0, "/media/film.mkv");
|
||||
|
||||
Assert.Equal(2, truth.Actors[0].Scenes.Count);
|
||||
Assert.Equal((10.0, 20.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
|
||||
Assert.Equal((20.0, 30.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALooseMatchSurfacesACaveat()
|
||||
{
|
||||
// Applied as a caveat rather than silently: the runtimes differ by up to
|
||||
// 30 seconds, which is usually a different trim of the same cut but is
|
||||
// not guaranteed to be.
|
||||
Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Loose, 0));
|
||||
Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Audio, 40));
|
||||
Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-016 — prioritise/ignore rules resolve by specificity: Item beats Series
|
||||
/// beats Genre. A scope+value holds only one action, so the only conflicts
|
||||
/// possible are across scopes, and that is exactly what these cover.
|
||||
///
|
||||
/// TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016
|
||||
/// </summary>
|
||||
public class PolicyResolverTests
|
||||
{
|
||||
private static readonly Guid ItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid SeriesId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
private static MediaPolicyRule Rule(PolicyScope scope, string value, PolicyAction action)
|
||||
=> new() { Scope = scope, Value = value, Action = action };
|
||||
|
||||
// UT-007
|
||||
[Fact]
|
||||
public void Resolve_WithNoMatchingRule_ReturnsNull()
|
||||
{
|
||||
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore) };
|
||||
|
||||
Assert.Null(PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Drama" }));
|
||||
}
|
||||
|
||||
// UT-008
|
||||
[Fact]
|
||||
public void Resolve_ItemRuleBeatsSeriesRule()
|
||||
{
|
||||
var rules = new List<MediaPolicyRule>
|
||||
{
|
||||
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Ignore),
|
||||
Rule(PolicyScope.Item, ItemId.ToString("D"), PolicyAction.Prioritise),
|
||||
};
|
||||
|
||||
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
// UT-009
|
||||
[Fact]
|
||||
public void Resolve_PrioritisedSeriesInsideIgnoredGenre_SeriesWins()
|
||||
{
|
||||
// The case that motivated specificity resolution: an admin ignores a
|
||||
// whole genre but wants one series out of it anyway. If genre won, the
|
||||
// more specific instruction would be silently discarded.
|
||||
var rules = new List<MediaPolicyRule>
|
||||
{
|
||||
Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore),
|
||||
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Prioritise),
|
||||
};
|
||||
|
||||
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Anime" }));
|
||||
}
|
||||
|
||||
// UT-010
|
||||
[Fact]
|
||||
public void Resolve_GenreMatchIsCaseInsensitive()
|
||||
{
|
||||
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "anime", PolicyAction.Ignore) };
|
||||
|
||||
Assert.Equal(PolicyAction.Ignore, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "AnImE" }));
|
||||
}
|
||||
|
||||
// UT-011
|
||||
[Fact]
|
||||
public void Resolve_SeriesRuleDoesNotMatchNonEpisode()
|
||||
{
|
||||
// A movie carries Guid.Empty as its series id. A series rule whose value
|
||||
// happened to be an empty GUID must not swallow every movie in the
|
||||
// library.
|
||||
var rules = new List<MediaPolicyRule>
|
||||
{
|
||||
Rule(PolicyScope.Series, Guid.Empty.ToString("D"), PolicyAction.Ignore),
|
||||
};
|
||||
|
||||
Assert.Null(PolicyResolver.Resolve(rules, ItemId, Guid.Empty, Array.Empty<string>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-004 (windows are scene-membership claims, served verbatim), JR-005 (query
|
||||
/// semantics and inclusive bounds) and JR-006 (numerous windows).
|
||||
///
|
||||
/// These pin the semantics SR-002 sets. The failure they exist to prevent is a
|
||||
/// well-meaning "tidy-up" — merging adjacent windows, trimming a zero-length
|
||||
/// one, or collapsing overlaps — each of which silently answers a different
|
||||
/// question from the one the truth file asked.
|
||||
///
|
||||
/// TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023 | JR-004, JR-005, JR-006
|
||||
/// </summary>
|
||||
public class PresenceLookupTests
|
||||
{
|
||||
private static TruthActor Actor(params double[][] windows)
|
||||
{
|
||||
var actor = new TruthActor { Name = "Steve Buscemi", TmdbId = "884" };
|
||||
foreach (var w in windows)
|
||||
{
|
||||
actor.Scenes.Add(new TruthScene { Start = w[0], End = w[1] });
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
// UT-016
|
||||
[Theory]
|
||||
[InlineData(12.0)] // exactly the start
|
||||
[InlineData(30.0)] // inside
|
||||
[InlineData(45.0)] // exactly the end
|
||||
public void IsPresentAt_WithinInclusiveBounds_IsPresent(double t)
|
||||
{
|
||||
// Both ends inclusive: a window is [start, end], not [start, end).
|
||||
Assert.True(PresenceLookup.IsPresentAt(Actor([12.0, 45.0]), t));
|
||||
}
|
||||
|
||||
// UT-017
|
||||
[Theory]
|
||||
[InlineData(11.999)]
|
||||
[InlineData(45.001)]
|
||||
public void IsPresentAt_OutsideBounds_IsAbsent(double t)
|
||||
{
|
||||
Assert.False(PresenceLookup.IsPresentAt(Actor([12.0, 45.0]), t));
|
||||
}
|
||||
|
||||
// UT-018
|
||||
[Fact]
|
||||
public void IsPresentAt_ZeroLengthWindow_IsPresentAtThatInstant()
|
||||
{
|
||||
// A single sighting is a legitimate window. Discarding it as degenerate
|
||||
// would drop the actor from a scene they are demonstrably in.
|
||||
Assert.True(PresenceLookup.IsPresentAt(Actor([30.0, 30.0]), 30.0));
|
||||
}
|
||||
|
||||
// UT-019
|
||||
[Fact]
|
||||
public void IsPresentAt_OverlappingWindows_IsPresentInsideTheEnclosingOne()
|
||||
{
|
||||
// [0,100] encloses [50,60]. A lookup that assumed non-overlapping,
|
||||
// sorted windows and stopped at the first start > t would miss t = 80.
|
||||
Assert.True(PresenceLookup.IsPresentAt(Actor([0.0, 100.0], [50.0, 60.0]), 80.0));
|
||||
}
|
||||
|
||||
// UT-020
|
||||
[Fact]
|
||||
public void IsPresentAt_UnsortedWindows_StillFindsPresence()
|
||||
{
|
||||
// Sortedness is a producer guarantee, not something correctness may
|
||||
// depend on. A file that violates it must still be read correctly.
|
||||
var actor = Actor([100.0, 110.0], [10.0, 20.0]);
|
||||
|
||||
Assert.True(PresenceLookup.IsPresentAt(actor, 15.0));
|
||||
Assert.False(PresenceLookup.WindowsAreSorted(actor));
|
||||
}
|
||||
|
||||
// UT-021
|
||||
[Fact]
|
||||
public void ActorsPresentAt_AdjacentWindowsAreNeverMerged()
|
||||
{
|
||||
// [0,10] and [10,20] look mergeable. They must not be merged: two
|
||||
// windows mean a genuine departure and return, and the plugin does not
|
||||
// reinterpret that claim. The actor is reported once, from two windows.
|
||||
var truth = new TruthFile();
|
||||
truth.Actors.Add(Actor([0.0, 10.0], [10.0, 20.0]));
|
||||
|
||||
Assert.Single(PresenceLookup.ActorsPresentAt(truth, 10.0));
|
||||
Assert.Equal(2, truth.Actors[0].Scenes.Count);
|
||||
}
|
||||
|
||||
// UT-022
|
||||
[Fact]
|
||||
public void TruthFile_RoundTrips_WithWindowsByteIdentical()
|
||||
{
|
||||
// JR-004: served exactly as given. A round trip through the serializer
|
||||
// is where a silent normalisation would show up.
|
||||
const string Json = """
|
||||
{"schema_version":2,"movie":"/m.mkv",
|
||||
"extraction":{"sample_fps":1,"extinction_sec":12},
|
||||
"cut":{"runtime_sec":6420.5},
|
||||
"actors":[{"name":"A","imdb_id":"","tmdb_id":"884","jellyfin_id":"",
|
||||
"scenes":[{"start":0.0,"end":10.0},
|
||||
{"start":10.0,"end":20.0},
|
||||
{"start":30.0,"end":30.0}]}]}
|
||||
""";
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<TruthFile>(Json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
var windows = parsed.Actors[0].Scenes;
|
||||
|
||||
Assert.Equal(3, windows.Count);
|
||||
Assert.Equal((0.0, 10.0), (windows[0].Start, windows[0].End));
|
||||
Assert.Equal((10.0, 20.0), (windows[1].Start, windows[1].End));
|
||||
Assert.Equal((30.0, 30.0), (windows[2].Start, windows[2].End));
|
||||
}
|
||||
|
||||
// UT-023
|
||||
[Fact]
|
||||
public void ActorsPresentAt_WithManyWindows_StaysCheapAndBoundsTheResponse()
|
||||
{
|
||||
// SR-002: windows may be numerous; consumers must not assume a handful
|
||||
// of long ones. Track-extent presence with a short re-acquisition
|
||||
// timeout produces many short windows per actor.
|
||||
var truth = new TruthFile();
|
||||
for (var a = 0; a < 50; a++)
|
||||
{
|
||||
var actor = Actor();
|
||||
for (var w = 0; w < 1000; w++)
|
||||
{
|
||||
actor.Scenes.Add(new TruthScene { Start = w * 10.0, End = (w * 10.0) + 4.0 });
|
||||
}
|
||||
|
||||
truth.Actors.Add(actor);
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var present = PresenceLookup.ActorsPresentAt(truth, 5002.0).ToList();
|
||||
sw.Stop();
|
||||
|
||||
// The response is bounded by actor count, never by window count — which
|
||||
// is what keeps `jray?t=` small however finely presence is sliced.
|
||||
Assert.Equal(50, present.Count);
|
||||
|
||||
// 50 000 windows scanned. Generous bound: this asserts the read path is
|
||||
// not accidentally quadratic, not a precise budget on a shared runner.
|
||||
Assert.True(sw.ElapsedMilliseconds < 250, $"lookup took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-002 (the <c>schema_version</c> 2 shape) and JR-003 (an unknown version is
|
||||
/// refused, never guessed).
|
||||
///
|
||||
/// These are the flag-day tests. v1 is gone rather than deprecated, so what has
|
||||
/// to be pinned is not only that v2 parses but that v1 does *not* quietly
|
||||
/// half-parse into something a reader would treat as real.
|
||||
///
|
||||
/// TRACES: UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037 | JR-002, JR-003
|
||||
/// </summary>
|
||||
public class TruthSchemaTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private const string V2 = """
|
||||
{"schema_version":2,"movie":"/data/movies/Movie.mkv",
|
||||
"extraction":{"sample_fps":5,"extinction_sec":12,"gallery_size":1820,
|
||||
"gallery_scope":"global","pipeline_version":"scene-actor-extraction 0.4.1"},
|
||||
"cut":{"runtime_sec":6420.5,"audio_signature":"v1:v7fA3k"},
|
||||
"actors":[{"name":"Steve Buscemi","imdb_id":"nm0000114","tmdb_id":"884",
|
||||
"jellyfin_id":"abc123-guid",
|
||||
"scenes":[{"start":191.6,"end":209.2,"belief":0.98,"route":"live"},
|
||||
{"start":438.2,"end":465.6,"belief":0.81,"route":"deferred"}]}]}
|
||||
""";
|
||||
|
||||
// UT-029
|
||||
[Fact]
|
||||
public void AV2FileRoundTripsWithProvenanceAndCutIntact()
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<TruthFile>(V2, Options)!;
|
||||
|
||||
Assert.Equal(2, parsed.SchemaVersion);
|
||||
Assert.Equal("/data/movies/Movie.mkv", parsed.Movie);
|
||||
|
||||
// sample_fps moved into `extraction` in the bump. Reading it from the
|
||||
// top level would silently yield zero.
|
||||
Assert.Equal(5, parsed.Extraction!.SampleFps);
|
||||
Assert.Equal(12, parsed.Extraction.ExtinctionSec);
|
||||
Assert.Equal(1820, parsed.Extraction.GallerySize);
|
||||
Assert.Equal("global", parsed.Extraction.GalleryScope);
|
||||
|
||||
Assert.Equal(6420.5, parsed.Cut!.RuntimeSec);
|
||||
Assert.Equal("v1:v7fA3k", parsed.Cut.AudioSignature);
|
||||
}
|
||||
|
||||
// UT-030
|
||||
[Fact]
|
||||
public void ScenesAreObjectsThatRetainBeliefAndRoute()
|
||||
{
|
||||
// The reason `scenes` stopped being float pairs. A window that loses its
|
||||
// belief and route is indistinguishable from a v1 window, which is
|
||||
// exactly the regression this pins.
|
||||
var parsed = JsonSerializer.Deserialize<TruthFile>(V2, Options)!;
|
||||
var windows = parsed.Actors[0].Scenes;
|
||||
|
||||
Assert.Equal(2, windows.Count);
|
||||
Assert.Equal(0.98, windows[0].Belief);
|
||||
Assert.Equal("live", windows[0].Route);
|
||||
Assert.Equal(0.81, windows[1].Belief);
|
||||
Assert.Equal("deferred", windows[1].Route);
|
||||
}
|
||||
|
||||
// UT-031
|
||||
[Theory]
|
||||
[InlineData("live")]
|
||||
[InlineData("deferred")]
|
||||
[InlineData("pooled")]
|
||||
public void AllThreeRoutesSurviveARoundTrip(string route)
|
||||
{
|
||||
// All three are named by extraction AR-017. A serializer that dropped an
|
||||
// unrecognised one would make `pooled` claims look live.
|
||||
var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion };
|
||||
var actor = new TruthActor { Name = "A", TmdbId = "884" };
|
||||
actor.Scenes.Add(new TruthScene { Start = 1, End = 2, Belief = 0.5, Route = route });
|
||||
truth.Actors.Add(actor);
|
||||
|
||||
var round = JsonSerializer.Deserialize<TruthFile>(JsonSerializer.Serialize(truth, Options), Options)!;
|
||||
|
||||
Assert.Equal(route, round.Actors[0].Scenes[0].Route);
|
||||
}
|
||||
|
||||
// UT-032
|
||||
[Fact]
|
||||
public void AWindowWithoutBeliefIsNullRatherThanZero()
|
||||
{
|
||||
// Absent and "believed with probability zero" are different statements.
|
||||
// Defaulting to 0.0 would make an unannotated window look maximally
|
||||
// untrustworthy, and a consumer ranking on belief would discard it.
|
||||
const string NoBelief = """
|
||||
{"schema_version":2,"movie":"/m.mkv",
|
||||
"actors":[{"name":"A","tmdb_id":"884","scenes":[{"start":1,"end":2}]}]}
|
||||
""";
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<TruthFile>(NoBelief, Options)!;
|
||||
|
||||
Assert.Null(parsed.Actors[0].Scenes[0].Belief);
|
||||
Assert.Null(parsed.Actors[0].Scenes[0].Route);
|
||||
}
|
||||
|
||||
// UT-033 — JR-003, the flag day itself.
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(0)]
|
||||
public void AnUnsupportedVersionIsRefused(int version)
|
||||
{
|
||||
// Both directions matter. v1 is the version that exists in the wild, and
|
||||
// v3 is a future producer this build cannot know the shape of — guessing
|
||||
// at either is what JR-003 forbids.
|
||||
var truth = new TruthFile { SchemaVersion = version };
|
||||
|
||||
Assert.False(TruthSchema.IsSupported(truth));
|
||||
Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), TruthSchema.DescribeRejection(version), System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// UT-034
|
||||
[Fact]
|
||||
public void AMissingSchemaVersionIsRefusedRatherThanAssumedCurrent()
|
||||
{
|
||||
// An absent field deserialises to 0. Treating that as "probably the
|
||||
// current version" is the single most tempting mistake here, and it
|
||||
// would accept any malformed document that happened to parse.
|
||||
const string NoVersion = """
|
||||
{"movie":"/m.mkv","actors":[]}
|
||||
""";
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<TruthFile>(NoVersion, Options);
|
||||
|
||||
Assert.False(TruthSchema.IsSupported(parsed));
|
||||
}
|
||||
|
||||
// UT-035
|
||||
[Fact]
|
||||
public void ANullTruthFileIsRefusedWithoutThrowing()
|
||||
{
|
||||
// `null` reaches this from a file containing the literal `null`, which
|
||||
// parses successfully. The check must reject it rather than dereference.
|
||||
Assert.False(TruthSchema.IsSupported(null));
|
||||
}
|
||||
|
||||
// UT-037
|
||||
[Fact]
|
||||
public void TheProducersActualOutputParses()
|
||||
{
|
||||
// Byte-for-byte the shape `scene-actor-extraction` writes today
|
||||
// (`src/nodes/result_sink_node.hpp`, IR-002) — *not* the fully populated
|
||||
// example from the spec. It omits `cut` entirely and carries only three
|
||||
// of the five `extraction` fields.
|
||||
//
|
||||
// This is the test that would have caught the break: the plugin read v1
|
||||
// while the pipeline had already moved to v2, so nothing the pipeline
|
||||
// produced could be read at all. A round-trip test written against the
|
||||
// spec's example alone would have passed throughout.
|
||||
const string AsProduced = """
|
||||
{
|
||||
"schema_version": 2,
|
||||
"movie": "/data/movies/Film.mkv",
|
||||
"extraction": {
|
||||
"sample_fps": 5.0,
|
||||
"extinction_sec": 12.0,
|
||||
"gallery_scope": "global"
|
||||
},
|
||||
"actors": [
|
||||
{
|
||||
"name": "Steve Buscemi",
|
||||
"imdb_id": "nm0000114",
|
||||
"tmdb_id": "884",
|
||||
"jellyfin_id": "",
|
||||
"scenes": [
|
||||
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<TruthFile>(AsProduced, Options);
|
||||
|
||||
Assert.True(TruthSchema.IsSupported(parsed));
|
||||
Assert.Equal(5.0, parsed!.Extraction!.SampleFps);
|
||||
Assert.Equal(12.0, parsed.Extraction.ExtinctionSec);
|
||||
Assert.Equal("global", parsed.Extraction.GalleryScope);
|
||||
|
||||
// Absent blocks and fields are null, not defaults that would read as data.
|
||||
Assert.Null(parsed.Cut);
|
||||
Assert.Null(parsed.Extraction.GallerySize);
|
||||
Assert.Null(parsed.Extraction.PipelineVersion);
|
||||
|
||||
var window = Assert.Single(parsed.Actors[0].Scenes);
|
||||
Assert.Equal((191.6, 209.2), (window.Start, window.End));
|
||||
Assert.Equal(0.98, window.Belief);
|
||||
Assert.Equal("live", window.Route);
|
||||
}
|
||||
|
||||
// UT-036
|
||||
[Fact]
|
||||
public void AV1FileDoesNotHalfParseIntoUsableWindows()
|
||||
{
|
||||
// The load-bearing claim of the flag day. v1 `scenes` were float pairs,
|
||||
// so a v1 file either fails to deserialise or produces windows that are
|
||||
// not usable — what must never happen is silent success with windows at
|
||||
// 0,0, which would report actors present at the start of every film.
|
||||
const string V1 = """
|
||||
{"schema_version":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2,
|
||||
"actors":[{"name":"A","tmdb_id":"884","scenes":[[0.0,10.0],[10.0,20.0]]}]}
|
||||
""";
|
||||
|
||||
TruthFile? parsed = null;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize<TruthFile>(V1, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// The expected path: a float pair is not an object.
|
||||
return;
|
||||
}
|
||||
|
||||
// If it did parse, the version gate is what stops it being used.
|
||||
Assert.False(TruthSchema.IsSupported(parsed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JR-022 — an earlier JRay injected its overlay script into index.html on
|
||||
/// disk. Those users must not be left with a stale injection pointing at
|
||||
/// endpoints that have since changed, so removal survives even though JR-021
|
||||
/// deleted the injection that created it.
|
||||
///
|
||||
/// Removal keys on JRay's own marker. The cases that matter are the ones where
|
||||
/// it could reach too far: another plugin's injection, or a script tag that
|
||||
/// looks like JRay's but carries no marker.
|
||||
///
|
||||
/// TRACES: UT-001, UT-002, UT-003, UT-004, UT-005 | JR-022
|
||||
/// </summary>
|
||||
public class WebClientPatchServiceTests
|
||||
{
|
||||
private const string Marker = "<!-- jray-overlay -->";
|
||||
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
||||
|
||||
// UT-001
|
||||
[Fact]
|
||||
public void RemoveInjection_WithMarkedTagAndNewline_RestoresOriginalBytes()
|
||||
{
|
||||
var original = "<html><body><div>x</div>\n</body></html>";
|
||||
var patched = "<html><body><div>x</div>\n" + ScriptTag + Marker + "\n</body></html>";
|
||||
|
||||
// The trailing newline goes with the tag. If it did not, every
|
||||
// install/uninstall cycle would leave another blank line behind.
|
||||
Assert.Equal(original, WebClientPatchService.RemoveInjection(patched));
|
||||
}
|
||||
|
||||
// UT-002
|
||||
[Fact]
|
||||
public void RemoveInjection_WithMarkedTagAndNoNewline_RemovesTag()
|
||||
{
|
||||
var patched = "<html><body>" + ScriptTag + Marker + "</body></html>";
|
||||
|
||||
Assert.Equal("<html><body></body></html>", WebClientPatchService.RemoveInjection(patched));
|
||||
}
|
||||
|
||||
// UT-003
|
||||
[Fact]
|
||||
public void RemoveInjection_WithNoMarker_LeavesDocumentUnchanged()
|
||||
{
|
||||
var clean = "<html><body><div>x</div>\n</body></html>";
|
||||
|
||||
Assert.Equal(clean, WebClientPatchService.RemoveInjection(clean));
|
||||
}
|
||||
|
||||
// UT-004
|
||||
[Fact]
|
||||
public void RemoveInjection_IsIdempotent()
|
||||
{
|
||||
var patched = "<html><body>" + ScriptTag + Marker + "\n</body></html>";
|
||||
|
||||
var once = WebClientPatchService.RemoveInjection(patched);
|
||||
var twice = WebClientPatchService.RemoveInjection(once);
|
||||
|
||||
// Startup calls this unconditionally, so it runs on every boot forever
|
||||
// after the patch is gone.
|
||||
Assert.Equal(once, twice);
|
||||
}
|
||||
|
||||
// UT-005
|
||||
[Fact]
|
||||
public void RemoveInjection_LeavesAnotherPluginsInjectionIntact()
|
||||
{
|
||||
var foreign = "<script defer src=\"/Plugins/Other/Script\"></script><!-- other-overlay -->";
|
||||
var patched = "<html><body>" + foreign + ScriptTag + Marker + "\n</body></html>";
|
||||
|
||||
var cleaned = WebClientPatchService.RemoveInjection(patched);
|
||||
|
||||
// The marker is what makes removal unambiguous. Removing anything we did
|
||||
// not add is the failure this guards: it is another plugin's file too.
|
||||
Assert.Contains(foreign, cleaned, System.StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(Marker, cleaned, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// UT-006
|
||||
[Fact]
|
||||
public void RemoveInjection_WithUnmarkedLookalikeTag_LeavesItAlone()
|
||||
{
|
||||
// Same script tag, no marker: JRay did not write this, so JRay does not
|
||||
// remove it.
|
||||
var patched = "<html><body>" + ScriptTag + "\n</body></html>";
|
||||
|
||||
Assert.Equal(patched, WebClientPatchService.RemoveInjection(patched));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"_": "Golden vector for the JRay v1 audio signature (JRay-public-server SPEC.md \u00a73). Shared verbatim between scene-actor-extraction (C++) and the jRay Jellyfin plugin (C#) so the two implementations can be proven bit-identical. IR-004, IR-005, IR-007, IR-008.",
|
||||
"version": "v1",
|
||||
"signature": "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHx8eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh8fHzk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5V1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dycnJycnJycnJycnJycnJycnJycnJycnJycnJycnMPDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKytFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRWNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Njfn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5/GxoZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGTc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSU1JsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbAoLCwoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCwsLJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSVDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ15eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eX19eeXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXkXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFzExMTExMTExMTExMTExMTExMTExMTExMTExMTExT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09qampqampqampqampqampqampqampqampqampqamsHBwUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyM+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4/PlhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3ExMRERERERERERERERERERERERERERERERERERERES8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpLS0plZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0eHh44ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4OFdXV1ZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWV1dXcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKysqRERERERERERERERERERERERERERERERERERERERjY2NjY2NjY2NjYw==",
|
||||
"frame_count": 1288,
|
||||
"media": {
|
||||
"file": "jray_audio_v1_tone.flac",
|
||||
"generator": "make_fixture.py",
|
||||
"container": "FLAC (lossless \u2014 decodes to exactly the PCM make_fixture.py emits)",
|
||||
"duration_sec": 120.0,
|
||||
"sample_rate": 11025,
|
||||
"channels": 1,
|
||||
"sample_format": "s16",
|
||||
"sha256": "912ecd426cd426dccb37753e0249694227619c701cb9f533502b37da0fbe8096",
|
||||
"bytes": 585142
|
||||
},
|
||||
"decoded_window": {
|
||||
"_": "Checksums of the 120 s centre window after downmix to mono and resample to 11025 Hz, i.e. exactly the stream `ffmpeg -ss <mid-60> -t 120 -i <file> -vn -ac 1 -ar 11025 -f f32le -` produces. Check these first: a mismatch here is a decode problem, not a DSP one.",
|
||||
"samples": 1323000,
|
||||
"f32le_fnv1a64": "0x1ef7899cd4d12662",
|
||||
"s16le_fnv1a64": "0xf824fa56f125c0dc"
|
||||
},
|
||||
"params": {
|
||||
"window_sec": 120.0,
|
||||
"window_centre": "runtime/2, i.e. samples from runtime/2 - 60 s; truncated to exactly 1323000 samples",
|
||||
"min_duration_sec": 120.0,
|
||||
"min_duration_rule": "IR-007 \u2014 below this emit NO signature and apply no sync offset",
|
||||
"sample_rate": 11025,
|
||||
"channels": 1,
|
||||
"arithmetic": "IEEE-754 double throughout; float32 is not sufficient",
|
||||
"sample_scale": "s16 * (1/32768), FFmpeg's native s16->flt",
|
||||
"frame_size": 4096,
|
||||
"hop_size": 1024,
|
||||
"frame_count_rule": "1 + (n_samples - 4096) / 1024, integer division; whole frames only",
|
||||
"window_fn": "Hann, PERIODIC: w[n] = 0.5 * (1 - cos(2*pi*n/4096))",
|
||||
"transform": "radix-2 DIT complex FFT over the 4096 real samples (imag=0), no normalisation",
|
||||
"magnitude": "sqrt(re^2 + im^2), linear",
|
||||
"band_lo_hz": 300.0,
|
||||
"band_hi_hz": 3000.0,
|
||||
"num_bands": 32,
|
||||
"band_edges": "edge[b] = 300 * (3000/300)^(b/32), b = 0..32",
|
||||
"band_bins": "band b owns FFT bins [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] * 4096 / 11025); see band_fft_bins",
|
||||
"band_value": "MEAN of the linear magnitudes in the band (not sum, not max)",
|
||||
"peak_bin": "argmax over the 32 band values; ties resolve to the LOWEST index",
|
||||
"energy_metric": "E = mean magnitude over all FFT bins 112..1114, i.e. the whole 300-3000 Hz band",
|
||||
"energy_reference": "upper median of E over all frames: sorted[n/2], no averaging of the two middle values",
|
||||
"energy_ratio": "r = log10((E + 1e-12) / (E_ref + 1e-12))",
|
||||
"energy_class_edges": [
|
||||
-0.6,
|
||||
-0.2,
|
||||
0.2
|
||||
],
|
||||
"energy_class": "0 if r < -0.6, 1 if r < -0.2, 2 if r < 0.2, else 3",
|
||||
"byte_layout": "bit7 = 0 (reserved), bits6..2 = 5-bit band index, bits1..0 = 2-bit energy class; byte = (band << 2) | class",
|
||||
"base64": "standard alphabet A-Za-z0-9+/ with '=' padding",
|
||||
"prefix": "v1:"
|
||||
},
|
||||
"band_fft_bins": [
|
||||
[
|
||||
112,
|
||||
120
|
||||
],
|
||||
[
|
||||
120,
|
||||
129
|
||||
],
|
||||
[
|
||||
129,
|
||||
139
|
||||
],
|
||||
[
|
||||
139,
|
||||
149
|
||||
],
|
||||
[
|
||||
149,
|
||||
160
|
||||
],
|
||||
[
|
||||
160,
|
||||
172
|
||||
],
|
||||
[
|
||||
172,
|
||||
185
|
||||
],
|
||||
[
|
||||
185,
|
||||
199
|
||||
],
|
||||
[
|
||||
199,
|
||||
213
|
||||
],
|
||||
[
|
||||
213,
|
||||
229
|
||||
],
|
||||
[
|
||||
229,
|
||||
246
|
||||
],
|
||||
[
|
||||
246,
|
||||
265
|
||||
],
|
||||
[
|
||||
265,
|
||||
285
|
||||
],
|
||||
[
|
||||
285,
|
||||
306
|
||||
],
|
||||
[
|
||||
306,
|
||||
328
|
||||
],
|
||||
[
|
||||
328,
|
||||
353
|
||||
],
|
||||
[
|
||||
353,
|
||||
379
|
||||
],
|
||||
[
|
||||
379,
|
||||
408
|
||||
],
|
||||
[
|
||||
408,
|
||||
438
|
||||
],
|
||||
[
|
||||
438,
|
||||
471
|
||||
],
|
||||
[
|
||||
471,
|
||||
506
|
||||
],
|
||||
[
|
||||
506,
|
||||
543
|
||||
],
|
||||
[
|
||||
543,
|
||||
584
|
||||
],
|
||||
[
|
||||
584,
|
||||
627
|
||||
],
|
||||
[
|
||||
627,
|
||||
674
|
||||
],
|
||||
[
|
||||
674,
|
||||
724
|
||||
],
|
||||
[
|
||||
724,
|
||||
778
|
||||
],
|
||||
[
|
||||
778,
|
||||
836
|
||||
],
|
||||
[
|
||||
836,
|
||||
899
|
||||
],
|
||||
[
|
||||
899,
|
||||
966
|
||||
],
|
||||
[
|
||||
966,
|
||||
1038
|
||||
],
|
||||
[
|
||||
1038,
|
||||
1115
|
||||
]
|
||||
],
|
||||
"notes": [
|
||||
"The server spec fixes the window, rate, STFT geometry, band and the 5+2 bit packing. Everything under params beyond that (Hann periodicity, band aggregation, the energy-class definition, tie-breaking, base64 alphabet) is pinned HERE for v1 \u2014 the spec does not constrain it, and two implementations that guess differently produce non-matching signatures.",
|
||||
"Decision margins on this fixture: the two strongest bands are within 1.3% on the closest frame, and the closest frame to an energy-class edge is 3.6e-3 away in log10. Both are many orders of magnitude above double-precision FFT differences, so any two correct double- precision implementations agree; a float32 implementation is not guaranteed to.",
|
||||
"Coverage: all 32 bands and all 4 energy classes appear in the golden signature.",
|
||||
"Robustness observed on this fixture: identical peak-bin sequence after a stereo/44100 Hz round trip and after AAC 128 kbit/s re-encoding."
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the JRay audio-signature golden fixture.
|
||||
|
||||
python3 make_fixture.py # writes jray_audio_v1_tone.flac here
|
||||
|
||||
This is the *source of truth* for the fixture media: `jray_audio_v1_tone.flac`
|
||||
is a lossless FLAC encoding of exactly the PCM this script emits, so any repo
|
||||
that wants to check its own audio-signature implementation against the golden
|
||||
vector in `jray_audio_v1_golden.json` can regenerate the input from scratch and
|
||||
confirm it is byte-identical (the golden file records `pcm_fnv1a64`, a hash of
|
||||
the decoded 16-bit samples).
|
||||
|
||||
Deliberately dependency-free (no numpy) and written in plain arithmetic so it
|
||||
ports to any language in ~20 lines.
|
||||
|
||||
Signal — 120.000 s, mono, 11025 Hz, 16-bit signed PCM:
|
||||
|
||||
* split into segments of 32768 samples (~2.97 s), 40.4 segments in total;
|
||||
* segment `s` carries one sine at the geometric centre of log-band
|
||||
`(s * 7) mod 32` of the 300-3000 Hz band, so all 32 bands are exercised;
|
||||
* its amplitude walks a golden-ratio low-discrepancy sequence over
|
||||
[10^-1.55, 10^-0.02] so frame energies spread continuously across ~1.5
|
||||
decades and all four energy classes are exercised, without a dense cluster
|
||||
of frames sitting on a class boundary;
|
||||
* phase is carried across segment boundaries (no clicks);
|
||||
* a constant, far quieter 777 Hz tone sits underneath so no frame is
|
||||
degenerate;
|
||||
* samples are quantised with floor(x * 32767 + 0.5).
|
||||
|
||||
Why FLAC and not WAV: 120 s of 11025 Hz 16-bit PCM is 2.6 MB and does not
|
||||
compress in git. FLAC is lossless — FFmpeg decodes it to exactly the PCM
|
||||
written here — and is ~3.5x smaller. `--wav` writes the uncompressed original
|
||||
if you want to diff it.
|
||||
"""
|
||||
import math
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
SAMPLE_RATE = 11025
|
||||
DURATION_SEC = 120.0
|
||||
SEGMENT = 32768 # samples per tone segment
|
||||
BAND_STRIDE = 7 # coprime with 32 -> visits every band
|
||||
BAND_LO_HZ = 300.0
|
||||
BAND_HI_HZ = 3000.0
|
||||
NUM_BANDS = 32
|
||||
AMP_LOG_MIN = -1.55 # 10^-1.55 ~= 0.028
|
||||
AMP_LOG_SPAN = 1.53 # up to 10^-0.02 ~= 0.955
|
||||
PHI_FRAC = 0.6180339887498949
|
||||
BG_HZ = 777.0
|
||||
BG_AMP = 0.004
|
||||
|
||||
OUT_FLAC = "jray_audio_v1_tone.flac"
|
||||
OUT_WAV = "jray_audio_v1_tone.wav"
|
||||
|
||||
|
||||
def generate():
|
||||
"""Return the 120 s signal as a list of int16 sample values."""
|
||||
n = int(round(SAMPLE_RATE * DURATION_SEC))
|
||||
out = [0] * n
|
||||
phase = 0.0
|
||||
two_pi = 2.0 * math.pi
|
||||
for start in range(0, n, SEGMENT):
|
||||
s = start // SEGMENT
|
||||
end = min(n, start + SEGMENT)
|
||||
band = (s * BAND_STRIDE) % NUM_BANDS
|
||||
# geometric centre of log-band `band`
|
||||
freq = BAND_LO_HZ * (BAND_HI_HZ / BAND_LO_HZ) ** ((band + 0.5) / NUM_BANDS)
|
||||
amp = 10.0 ** (AMP_LOG_MIN + AMP_LOG_SPAN * ((s * PHI_FRAC) % 1.0))
|
||||
step = two_pi * freq / SAMPLE_RATE
|
||||
for k in range(end - start):
|
||||
i = start + k
|
||||
x = amp * math.sin(phase + step * k)
|
||||
x += BG_AMP * math.sin(two_pi * BG_HZ * i / SAMPLE_RATE)
|
||||
if x > 1.0:
|
||||
x = 1.0
|
||||
elif x < -1.0:
|
||||
x = -1.0
|
||||
out[i] = int(math.floor(x * 32767.0 + 0.5))
|
||||
phase = (phase + step * (end - start)) % two_pi
|
||||
return out
|
||||
|
||||
|
||||
def write_wav(path, samples):
|
||||
data = struct.pack("<%dh" % len(samples), *samples)
|
||||
hdr = b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, SAMPLE_RATE,
|
||||
SAMPLE_RATE * 2, 2, 16)
|
||||
hdr += b"data" + struct.pack("<I", len(data))
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(hdr + data)
|
||||
|
||||
|
||||
def main():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
samples = generate()
|
||||
wav = os.path.join(here, OUT_WAV)
|
||||
write_wav(wav, samples)
|
||||
if "--wav" in sys.argv:
|
||||
print("wrote", wav)
|
||||
return
|
||||
flac = os.path.join(here, OUT_FLAC)
|
||||
# -compression_level 12 is deterministic for a given libFLAC/ffmpeg build;
|
||||
# only the container bytes vary, never the decoded PCM.
|
||||
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", wav,
|
||||
"-c:a", "flac", "-compression_level", "12", flac],
|
||||
check=True)
|
||||
os.remove(wav)
|
||||
print("wrote", flac)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
#
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay.Tests", "Jellyfin.Plugin.JRay.Tests\Jellyfin.Plugin.JRay.Tests.csproj", "{104C1021-3155-4404-9CA0-8ED8F310A152}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -12,7 +14,7 @@ Global
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
@@ -24,5 +26,20 @@ Global
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.Build.0 = Release|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// How far a configured manifest server is trusted. See the JRay public server
|
||||
/// specification, §9 "Trusting third-party servers".
|
||||
/// </summary>
|
||||
public enum ServerTrustLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Accept manifests, but never contribute to this server and never send
|
||||
/// library inventory beyond the single item being queried. The default for
|
||||
/// user-added servers.
|
||||
/// </summary>
|
||||
FetchOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Eligible to contribute to, subject to <see cref="ManifestServer.AllowContribute"/>.
|
||||
/// </summary>
|
||||
Full = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The minimum cut-match tier a fetched manifest must reach before it is stored.
|
||||
/// See the public server specification, §3 "Cut matching".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every tier is a claim about a <em>cut</em>, never about a copy. There is no
|
||||
/// file-level tier, and the plugin sends no <c>video_hash</c>.
|
||||
/// <para>
|
||||
/// <b>The <c>Exact</c> tier was withdrawn for legal reasons; do not re-add it
|
||||
/// without an explicit, recorded agreement.</b> It keyed on the OpenSubtitles
|
||||
/// file hash, which identifies the individual encode a user holds rather than
|
||||
/// the edit the timings describe. A TMDB id discloses "some copy of this film";
|
||||
/// a file hash discloses "<em>this exact release</em>", which turns a catalogue
|
||||
/// lookup into a release-identification service and turns the server's database
|
||||
/// into a mapping from file fingerprints to the instances holding them. That is
|
||||
/// a far more specific disclosure than PR-005 permits, and a dataset no
|
||||
/// volunteer operator should be holding.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The audio signature is the deliberate replacement: derived from content, it
|
||||
/// identifies the <em>cut</em>, so two different encodes of the same edit agree.
|
||||
/// It answers the question the exchange needs — "do these timings apply to this
|
||||
/// media?" — without answering the one it must not. <c>Audio</c> is therefore
|
||||
/// the top tier here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum MatchTier
|
||||
{
|
||||
/// <summary>Audio 0.60–0.85, or runtimes within ±30s. Surfaced as a caveat in the UI.</summary>
|
||||
Loose = 0,
|
||||
|
||||
/// <summary>Runtimes within ±2s.</summary>
|
||||
Runtime = 1,
|
||||
|
||||
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset. The top tier.</summary>
|
||||
Audio = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the ordered list of manifest servers the plugin queries
|
||||
/// (public server specification, §9 "Multiple servers").
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list is ordered because order *is* the user's trust ranking, made
|
||||
/// explicit: for a fetch, servers are tried in order and the first acceptable
|
||||
/// result wins. Querying every server for every item would multiply egress and
|
||||
/// leak the library to more parties.
|
||||
///
|
||||
/// <c>FetchOnly</c> is the default for user-added servers. Adding a third-party
|
||||
/// server means trusting its operator not to serve deliberately wrong actor
|
||||
/// data — the client-side controls bound the damage to bad overlay content,
|
||||
/// they cannot make wrong data right.
|
||||
/// </remarks>
|
||||
// TRACES: JR-025 | PR-005, PR-006
|
||||
public class ManifestServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestServer"/> class.
|
||||
/// </summary>
|
||||
public ManifestServer()
|
||||
{
|
||||
Url = string.Empty;
|
||||
Name = string.Empty;
|
||||
Token = string.Empty;
|
||||
Enabled = false;
|
||||
AllowContribute = false;
|
||||
TrustLevel = ServerTrustLevel.FetchOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base URL of the server, e.g. "https://jray.tourolle.paris".
|
||||
/// HTTPS is required for non-loopback servers: a plaintext server would let
|
||||
/// any network intermediary rewrite actor overlays.
|
||||
/// </summary>
|
||||
public string Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the display label shown in the configuration page.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API token used to contribute manifests. Optional —
|
||||
/// required only to contribute, never to fetch. This is an anonymous bearer
|
||||
/// capability rather than an account (public server specification, §5a).
|
||||
/// </summary>
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this server is queried at all.
|
||||
/// Lets an admin disable an entry without deleting it and losing its token.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether locally generated manifests may be
|
||||
/// contributed to this server. Independent of fetching, and off by default:
|
||||
/// contribution is never fanned out, because broadcasting uploads to every
|
||||
/// configured server would multiply privacy exposure without the user
|
||||
/// intending it.
|
||||
/// </summary>
|
||||
public bool AllowContribute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how far this server is trusted.
|
||||
/// </summary>
|
||||
public ServerTrustLevel TrustLevel { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||
@@ -5,8 +6,28 @@ namespace Jellyfin.Plugin.JRay.Configuration;
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every manifest-exchange switch here defaults to <b>off</b>, including the
|
||||
/// pre-configured community server, so no traffic leaves an installation until
|
||||
/// an admin acts. Fetching and contributing each reveal to a server operator
|
||||
/// that some instance holds a given title; that is inherent to the exchange, so
|
||||
/// the defaults bound the exposure rather than pretending to remove it.
|
||||
/// </remarks>
|
||||
// TRACES: JR-036, JR-038 | PR-005
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// The community manifest exchange. Shipped pre-configured but
|
||||
/// <b>disabled</b>, so no traffic leaves an installation until an admin opts
|
||||
/// in (public server specification, §9).
|
||||
/// </summary>
|
||||
public const string CommunityServerUrl = "https://jray.tourolle.paris";
|
||||
|
||||
/// <summary>
|
||||
/// Display name for <see cref="CommunityServerUrl"/>.
|
||||
/// </summary>
|
||||
public const string CommunityServerName = "JRay Community";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
/// </summary>
|
||||
@@ -15,6 +36,24 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
TruthFileSuffix = ".jray.json";
|
||||
CacheDurationMinutes = 60;
|
||||
EnableOverlay = true;
|
||||
|
||||
// Manifest sharing is a network egress feature, so every part of it is
|
||||
// off by default (public server specification, §9 "Configuration").
|
||||
EnableManifestSharing = false;
|
||||
ContributeManifests = false;
|
||||
ComputeAudioSignatures = false;
|
||||
MinimumMatchTier = MatchTier.Runtime;
|
||||
|
||||
// Pre-configured but disabled: the admin opts in by enabling it, rather
|
||||
// than by having to discover and type a URL.
|
||||
Servers.Add(new ManifestServer
|
||||
{
|
||||
Url = CommunityServerUrl,
|
||||
Name = CommunityServerName,
|
||||
Enabled = false,
|
||||
AllowContribute = false,
|
||||
TrustLevel = ServerTrustLevel.FetchOnly,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,4 +76,62 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// any previously injected script is removed.
|
||||
/// </summary>
|
||||
public bool EnableOverlay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether JRay may fetch actor-timeline
|
||||
/// manifests from the configured servers. Off by default — this is a network
|
||||
/// egress feature and must be opt-in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fetching reveals to a server operator that some instance holds a given
|
||||
/// title. That is inherent to the exchange, and each configured server
|
||||
/// multiplies the exposure, which the configuration page states plainly.
|
||||
/// </remarks>
|
||||
public bool EnableManifestSharing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether locally generated manifests may be
|
||||
/// contributed back. A separate opt-in from downloading, and off by default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Contribution additionally requires <see cref="ManifestServer.AllowContribute"/>
|
||||
/// on the specific server and a token for it. Uploads are never fanned out to
|
||||
/// every configured server.
|
||||
/// </remarks>
|
||||
public bool ContributeManifests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum cut-match tier a fetched manifest must reach
|
||||
/// before it is stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see cref="MatchTier.Runtime"/>. <see cref="MatchTier.Loose"/>
|
||||
/// admits manifests whose runtime differs by up to 30s, which may be a
|
||||
/// different trim of the same cut — usable, but it should be surfaced as a
|
||||
/// caveat rather than applied silently.
|
||||
/// </remarks>
|
||||
public MatchTier MinimumMatchTier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin computes audio
|
||||
/// signatures for library items, enabling content-based cut matching and
|
||||
/// identification of files whose providence is unknown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Off by default. Uses the FFmpeg binary Jellyfin already ships (via
|
||||
/// <c>IMediaEncoder.EncoderPath</c>), so there is no extra dependency, but it
|
||||
/// costs roughly a second or two of I/O per item and is therefore opt-in.
|
||||
/// </remarks>
|
||||
public bool ComputeAudioSignatures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of manifest servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Order is the user's trust ranking: for a fetch, servers are tried in order
|
||||
/// and the first result clearing <see cref="MinimumMatchTier"/> wins. For a
|
||||
/// series, first-match applies per <i>episode</i>, so a later server is
|
||||
/// queried only for the episodes earlier ones lacked.
|
||||
/// </remarks>
|
||||
public Collection<ManifestServer> Servers { get; } = new();
|
||||
}
|
||||
|
||||
@@ -29,10 +29,16 @@
|
||||
<span>Enable pause overlay</span>
|
||||
</label>
|
||||
<div class="fieldDescription">
|
||||
Injects a small script into the web client that shows on-screen actors
|
||||
when playback is paused. Disabling this removes the injected script.
|
||||
Shows the cast of the current scene when playback is paused. This
|
||||
requires the
|
||||
<a is="emby-linkbutton" class="button-link" href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation" target="_blank" rel="noopener">File Transformation</a>
|
||||
plugin, which rewrites the web client's index.html as it is served.
|
||||
JRay never modifies index.html on disk, so there is no fallback if
|
||||
that plugin is absent — only the overlay is affected, and every
|
||||
other JRay feature keeps working.
|
||||
</div>
|
||||
</div>
|
||||
<div id="JRayDependencyStatus" class="fieldDescription" style="margin:0 0 1.5em;padding:0.75em 1em;border-radius:0.25em;display:none;"></div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
@@ -131,6 +137,32 @@
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---- Dependency status ----
|
||||
// Jellyfin cannot install a plugin's dependency, so the only thing
|
||||
// that closes the gap is saying so where an admin can act on it.
|
||||
function loadDependencyStatus() {
|
||||
var el = document.querySelector('#JRayDependencyStatus');
|
||||
return jrayApi('Status/Dependencies').then(function (status) {
|
||||
el.style.display = '';
|
||||
if (status.file_transformation_available) {
|
||||
el.style.background = 'rgba(82,168,82,0.15)';
|
||||
el.innerHTML = '<strong>File Transformation detected.</strong> '
|
||||
+ 'The pause overlay is served by rewriting index.html as it is '
|
||||
+ 'sent, leaving the file on disk untouched.';
|
||||
return;
|
||||
}
|
||||
|
||||
el.style.background = 'rgba(220,160,60,0.18)';
|
||||
el.innerHTML = '<strong>File Transformation is not installed — the pause '
|
||||
+ 'overlay is disabled.</strong> Every other JRay feature is unaffected. '
|
||||
+ 'To enable it, add this repository in Dashboard → Plugins → '
|
||||
+ 'Repositories, then install "File Transformation" and restart:<br />'
|
||||
+ '<code>' + escapeHtml(status.file_transformation_manifest_url) + '</code>';
|
||||
}).catch(function () {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Coverage ----
|
||||
|
||||
var JRayCoverColors = {
|
||||
@@ -340,6 +372,7 @@
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
||||
loadDependencyStatus();
|
||||
populateValueSelect();
|
||||
loadRules();
|
||||
loadCoverage();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -11,12 +11,19 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
|
||||
/// at a given timestamp in a movie.
|
||||
/// Exposes scene-actor-extraction "truth" data: which actors are present in
|
||||
/// the scene at a given timestamp.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Presence is <b>scene-scoped</b>, not instantaneous: a window is a claim about
|
||||
/// scene membership, not a recognition event, so an actor who is off-camera
|
||||
/// during a reverse shot is still present. Windows are served exactly as stored
|
||||
/// — never merged, split or trimmed.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Items/{itemId}")]
|
||||
[Authorize]
|
||||
// TRACES: JR-004, JR-005, JR-010, JR-012, JR-013, JR-014 | SR-002
|
||||
public class ActorsController : ControllerBase
|
||||
{
|
||||
private readonly ITruthDataService _truthDataService;
|
||||
@@ -31,7 +38,7 @@ public class ActorsController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
|
||||
/// Gets the full actor timeline (every actor with their scene-presence windows) for a movie.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin item id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
@@ -51,7 +58,26 @@ public class ActorsController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
|
||||
/// Gets how this item's truth data was obtained.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <c>Timeline</c> on purpose: provenance is metadata *about*
|
||||
/// the claim, and folding it into the truth file would mean the bytes served
|
||||
/// back are not the bytes the producer wrote (JR-004).
|
||||
/// </remarks>
|
||||
/// <param name="itemId">The Jellyfin item id.</param>
|
||||
/// <returns>The provenance, or 404 if no truth data exists for this item.</returns>
|
||||
[HttpGet("Provenance")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<TruthProvenance> GetProvenance(Guid itemId)
|
||||
{
|
||||
var provenance = _truthDataService.GetProvenance(itemId);
|
||||
return provenance is null ? NotFound() : Ok(provenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JRay context (currently: the actors in the scene) at a given timestamp.
|
||||
/// This is an extensible envelope — future fields (locations, trivia, etc.)
|
||||
/// will be added here without changing the route.
|
||||
/// </summary>
|
||||
@@ -71,9 +97,9 @@ public class ActorsController : ControllerBase
|
||||
}
|
||||
|
||||
var context = new JRayContext();
|
||||
foreach (var actor in truth.Actors.Where(actor => actor.Scenes.Any(scene => scene.Length == 2 && scene[0] <= t && t <= scene[1])))
|
||||
foreach (var actor in PresenceLookup.ActorsPresentAt(truth, t))
|
||||
{
|
||||
context.Actors.Add(new ActorAtTime
|
||||
context.Actors.Add(new ActorInScene
|
||||
{
|
||||
Name = actor.Name,
|
||||
ImdbId = actor.ImdbId,
|
||||
|
||||
@@ -19,9 +19,15 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// by genre), and supplies the genre/series option lists the config page's
|
||||
/// rule editor needs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Percent done is <c>covered / (total - ignored)</c>: ignored items are
|
||||
/// intentionally out of scope, so excluding a genre must not drag the figure
|
||||
/// down as though it were outstanding work.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Coverage")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-018, JR-019 | PR-003
|
||||
public class CoverageController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches actor-timeline manifests from the configured public servers.
|
||||
/// </summary>
|
||||
// TRACES: JR-025, JR-031 | PR-006
|
||||
[ApiController]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[Route("Plugins/JRay")]
|
||||
[Produces("application/json")]
|
||||
public class ManifestController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IManifestExchangeClient _exchange;
|
||||
private readonly IManagedTruthStore _truthStore;
|
||||
private readonly ManifestAligner _aligner;
|
||||
private readonly ILogger<ManifestController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="exchange">Manifest exchange client.</param>
|
||||
/// <param name="truthStore">Managed truth store.</param>
|
||||
/// <param name="aligner">Aligns a fetched manifest to the local file.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ManifestController(
|
||||
ILibraryManager libraryManager,
|
||||
IManifestExchangeClient exchange,
|
||||
IManagedTruthStore truthStore,
|
||||
ManifestAligner aligner,
|
||||
ILogger<ManifestController> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_exchange = exchange;
|
||||
_truthStore = truthStore;
|
||||
_aligner = aligner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an item across the configured servers and stores the first
|
||||
/// acceptable manifest.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The library item id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>What was fetched, and from where.</returns>
|
||||
/// <response code="200">A manifest was stored.</response>
|
||||
/// <response code="404">The item does not exist, or no server had a manifest for it.</response>
|
||||
/// <response code="409">Manifest sharing is disabled in the plugin configuration.</response>
|
||||
[HttpPost("Items/{itemId}/Fetch")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ManifestFetchResult>> FetchItem(
|
||||
[FromRoute] Guid itemId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null || !config.EnableManifestSharing)
|
||||
{
|
||||
// Off by default and opt-in: this is a network egress feature, so it
|
||||
// never runs merely because an endpoint was called.
|
||||
return Conflict(new { error = "manifest sharing is disabled" });
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var query = BuildQuery(item);
|
||||
if (query is null)
|
||||
{
|
||||
return NotFound(new { error = "item has no TMDB or IMDB id to look up" });
|
||||
}
|
||||
|
||||
var servers = config.Servers.ToList();
|
||||
var outcome = item is Episode
|
||||
? await _exchange.FetchEpisodeAsync(servers, config.MinimumMatchTier, query, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
: await _exchange.FetchMovieAsync(servers, config.MinimumMatchTier, query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (outcome?.Manifest is null)
|
||||
{
|
||||
return NotFound(new { error = "no configured server had an acceptable manifest" });
|
||||
}
|
||||
|
||||
// Align before storing. The server has never seen this file, so where a
|
||||
// local audio alignment is possible it supersedes the offset the server
|
||||
// sent — and it needs no round trip, so no signature leaves the
|
||||
// instance (JR-047).
|
||||
var alignment = await _aligner.AlignAsync(
|
||||
item.Path ?? string.Empty,
|
||||
item.RunTimeTicks is { } ticks ? TimeSpan.FromTicks(ticks).TotalSeconds : 0.0,
|
||||
outcome.Manifest,
|
||||
outcome.Tier,
|
||||
outcome.OffsetSec,
|
||||
config.ComputeAudioSignatures,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var caveat = ManifestConverter.DescribeCaveat(alignment.Tier, alignment.OffsetSec, alignment);
|
||||
|
||||
// The offset is applied here, once, so the stored truth is always in the
|
||||
// local file's own timebase and no reader needs offset awareness.
|
||||
var truth = ManifestConverter.ToTruthFile(outcome.Manifest, alignment.OffsetSec, item.Path ?? string.Empty);
|
||||
var provenance = new TruthProvenance
|
||||
{
|
||||
Source = TruthSource.Fetched,
|
||||
ServerUrl = outcome.ServerUrl,
|
||||
MatchTier = alignment.Tier,
|
||||
OffsetSec = alignment.OffsetSec,
|
||||
Alignment = alignment,
|
||||
Caveat = caveat,
|
||||
RecordedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _truthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s, aligned by {Source})",
|
||||
itemId,
|
||||
outcome.ServerUrl,
|
||||
alignment.Tier,
|
||||
alignment.OffsetSec,
|
||||
alignment.Source);
|
||||
|
||||
return Ok(new ManifestFetchResult
|
||||
{
|
||||
ServerUrl = outcome.ServerUrl,
|
||||
Match = alignment.Tier.ToString().ToLowerInvariant(),
|
||||
OffsetSec = alignment.OffsetSec,
|
||||
ActorCount = truth.Actors.Count,
|
||||
Caveat = caveat,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-server reachability and last error, for the configuration page.
|
||||
/// </summary>
|
||||
/// <returns>One entry per configured server, in configured order.</returns>
|
||||
/// <response code="200">Status for each configured server.</response>
|
||||
[HttpGet("Servers/Status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IReadOnlyList<ServerStatus>> GetServerStatus()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
{
|
||||
return Ok(Array.Empty<ServerStatus>());
|
||||
}
|
||||
|
||||
return Ok(_exchange.GetStatus(config.Servers.ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a provider id from an item, or null when it is absent.
|
||||
/// </summary>
|
||||
private static string? ProviderId(BaseItem? item, string provider) =>
|
||||
item?.ProviderIds is { } ids && ids.TryGetValue(provider, out var value)
|
||||
&& !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the lookup query from an item's provider ids and measured runtime.
|
||||
/// </summary>
|
||||
private static TitleQuery? BuildQuery(BaseItem item)
|
||||
{
|
||||
var runtimeSec = item.RunTimeTicks.HasValue
|
||||
? TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds
|
||||
: (double?)null;
|
||||
|
||||
if (item is Episode episode)
|
||||
{
|
||||
var series = episode.Series;
|
||||
var seriesTmdb = ProviderId(series, "Tmdb");
|
||||
if (string.IsNullOrEmpty(seriesTmdb))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TitleQuery
|
||||
{
|
||||
SeriesTmdbId = seriesTmdb,
|
||||
Season = episode.ParentIndexNumber,
|
||||
Episode = episode.IndexNumber,
|
||||
RuntimeSec = runtimeSec,
|
||||
};
|
||||
}
|
||||
|
||||
var tmdb = ProviderId(item, "Tmdb");
|
||||
var imdb = ProviderId(item, "Imdb");
|
||||
if (string.IsNullOrEmpty(tmdb) && string.IsNullOrEmpty(imdb))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TitleQuery { TmdbId = tmdb, ImdbId = imdb, RuntimeSec = runtimeSec };
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,15 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// series, or a single item; setting a rule for a target that already has one
|
||||
/// replaces it, so a target can never be both prioritised and ignored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rules steer work discovery only. They never reach the read endpoints or the
|
||||
/// overlay, because a rule says "don't spend compute here", not "pretend this
|
||||
/// item does not exist".
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Policy")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-016, JR-014 | PR-003
|
||||
public class PolicyController : ControllerBase
|
||||
{
|
||||
private readonly IMediaPolicyStore _policyStore;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether JRay's hard dependency on the File Transformation plugin is
|
||||
/// satisfied, for the configuration page to surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Absent that plugin the pause overlay is disabled and every other JRay feature
|
||||
/// continues to work — so this is a status to display, not an error to raise.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Status")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-023 | PR-004
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the dependency status.
|
||||
/// </summary>
|
||||
/// <returns>The dependency status.</returns>
|
||||
[HttpGet("Dependencies")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<DependencyStatus> GetDependencies()
|
||||
{
|
||||
return Ok(new DependencyStatus
|
||||
{
|
||||
FileTransformationAvailable = Plugin.FileTransformationAvailable,
|
||||
OverlayEnabled = Plugin.OverlayEnabled,
|
||||
FileTransformationManifestUrl = FileTransformationRegistration.ManifestUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,19 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// Lets a remote extraction worker discover which library items still need
|
||||
/// to be processed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sample is random so that repeated polling spreads work across the
|
||||
/// backlog without the server tracking who holds what, and so two workers
|
||||
/// polling concurrently mostly do not collide.
|
||||
///
|
||||
/// Prioritise/ignore rules are applied <b>here and only here</b> (JR-017):
|
||||
/// they express "don't spend compute on this", not "pretend this does not
|
||||
/// exist", so they never reach the read endpoints or the overlay.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Tasks")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-015, JR-017 | PR-003
|
||||
public class TasksController : ControllerBase
|
||||
{
|
||||
private const int DefaultLimit = 10;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -12,15 +13,21 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// <summary>
|
||||
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
|
||||
/// extraction worker, for servers that cannot run the extraction pipeline
|
||||
/// locally. See SPEC.md.
|
||||
/// locally. See SPEC.md §2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>schema_version</c> check refuses an unrecognised version rather than
|
||||
/// guessing at its shape. It defers to <see cref="TruthSchema"/> rather than
|
||||
/// holding its own constant: this used to be the only source that checked while
|
||||
/// sidecar reads did not, so the version the plugin claimed to require and the
|
||||
/// one it would actually parse could drift apart.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-003, JR-009, JR-014 | SR-003
|
||||
public class TruthController : ControllerBase
|
||||
{
|
||||
private const int SupportedSchemaVersion = 1;
|
||||
|
||||
private readonly IManagedTruthStore _managedTruthStore;
|
||||
private readonly ITruthDataService _truthDataService;
|
||||
|
||||
@@ -47,12 +54,13 @@ public class TruthController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken)
|
||||
{
|
||||
if (truth.SchemaVersion != SupportedSchemaVersion)
|
||||
if (!TruthSchema.IsSupported(truth))
|
||||
{
|
||||
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
|
||||
return BadRequest(TruthSchema.DescribeRejection(truth.SchemaVersion));
|
||||
}
|
||||
|
||||
await _managedTruthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false);
|
||||
var provenance = TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow);
|
||||
await _managedTruthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false);
|
||||
_truthDataService.Invalidate(itemId);
|
||||
|
||||
return NoContent();
|
||||
|
||||
@@ -11,9 +11,14 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
|
||||
/// injected into the web client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anonymous by necessity, not by oversight: the script tag is injected into
|
||||
/// <c>index.html</c>, which is served before a user has logged in.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay")]
|
||||
[AllowAnonymous]
|
||||
// TRACES: JR-014, JR-020 | PR-001
|
||||
public class WebController : ControllerBase
|
||||
{
|
||||
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Lets the test project reach internals such as
|
||||
WebClientPatchService.RemoveInjection, which is factored out precisely
|
||||
so the removal logic is testable without touching a filesystem. -->
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>Jellyfin.Plugin.JRay.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
|
||||
+10
-2
@@ -3,9 +3,17 @@ using System.Text.Json.Serialization;
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// An actor visible on screen at a queried timestamp.
|
||||
/// An actor present in the scene at a queried timestamp.
|
||||
/// </summary>
|
||||
public class ActorAtTime
|
||||
/// <remarks>
|
||||
/// "Present in the scene", not "visible on screen". The truth file makes a claim
|
||||
/// about scene membership, so an actor who has turned away or is off-camera
|
||||
/// during a reverse shot is still present. The type was named
|
||||
/// <c>ActorAtTime</c>, which invited exactly the instantaneous reading SR-002
|
||||
/// forbids.
|
||||
/// </remarks>
|
||||
// TRACES: JR-005 | SR-002
|
||||
public class ActorInScene
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the actor's display name.
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Which comparison produced the offset that was applied to a fetched manifest.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recorded because the two are not equally strong evidence. The server has
|
||||
/// never seen the local file, so its offset is at best a runtime-difference
|
||||
/// inference; a local alignment compares the manifest's own audio signature
|
||||
/// against the file the windows will actually be drawn over.
|
||||
/// </remarks>
|
||||
public enum AlignmentSource
|
||||
{
|
||||
/// <summary>
|
||||
/// The server's offset was applied — no local alignment was possible.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Either signatures are switched off, the item is under the 120 s window,
|
||||
/// the manifest carried no signature, or the decode failed. A signature is
|
||||
/// an enhancement, so every one of those degrades to this rather than
|
||||
/// failing the fetch.
|
||||
/// </remarks>
|
||||
Server = 0,
|
||||
|
||||
/// <summary>
|
||||
/// A local audio alignment was computed and its offset was applied.
|
||||
/// </summary>
|
||||
Local = 1,
|
||||
|
||||
/// <summary>
|
||||
/// A local alignment was attempted and the two signatures did not match at
|
||||
/// any tier; the server's offset was applied and the disagreement recorded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not a failure. The audio may legitimately differ — a
|
||||
/// different language track, a heavy re-encode — and a signature must never
|
||||
/// be able to break a fetch. But it is the strongest available hint that a
|
||||
/// manifest describes different content, so it is surfaced as a caveat
|
||||
/// rather than discarded.
|
||||
/// </remarks>
|
||||
LocalMismatch = 2,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Whether JRay's one hard dependency is satisfied, and what to do if it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Jellyfin has no plugin dependency mechanism — a manifest cannot declare that
|
||||
/// another plugin is required, and nothing will install one. The gap is closed by
|
||||
/// telling the admin, on the page where they can act on it. A warning that exists
|
||||
/// only in the server log is one nobody reads.
|
||||
/// </remarks>
|
||||
public class DependencyStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the File Transformation plugin was
|
||||
/// found and JRay's overlay transformation registered with it.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file_transformation_available")]
|
||||
public bool FileTransformationAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the overlay is switched on in
|
||||
/// configuration. It is served only when this <i>and</i>
|
||||
/// <see cref="FileTransformationAvailable"/> hold.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overlay_enabled")]
|
||||
public bool OverlayEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the repository manifest URL an admin adds to install the
|
||||
/// missing dependency.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file_transformation_manifest_url")]
|
||||
public string FileTransformationManifestUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -11,8 +11,8 @@ namespace Jellyfin.Plugin.JRay.Models;
|
||||
public class JRayContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of actors visible on screen at the queried timestamp.
|
||||
/// Gets the list of actors visible in the scene at the queried timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actors")]
|
||||
public Collection<ActorAtTime> Actors { get; } = new();
|
||||
public Collection<ActorInScene> Actors { get; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One shareable actor timeline for one cut of one title, as the public server
|
||||
/// serves it. See the server specification §2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the <b>exchange envelope</b>, versioned by <c>jmanifest_version</c>
|
||||
/// and deliberately separate from the truth file's <c>schema_version</c>: a
|
||||
/// change to how manifests are transported need not force a truth-file bump.
|
||||
/// They currently coincide at 2 only because the SR-003 bump touched both.
|
||||
/// <para>
|
||||
/// A manifest is never trusted merely because a server served it (JR-027). Every
|
||||
/// field below is re-validated on receipt against the same rules the server
|
||||
/// applies on upload.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-025, JR-027 | SR-003
|
||||
public class Jmanifest
|
||||
{
|
||||
/// <summary>Gets or sets the exchange envelope version this manifest speaks.</summary>
|
||||
[JsonPropertyName("jmanifest_version")]
|
||||
public int JmanifestVersion { get; set; }
|
||||
|
||||
/// <summary>Gets or sets what the work is — TMDB/IMDB ids and episode coordinates.</summary>
|
||||
[JsonPropertyName("identity")]
|
||||
public JmanifestIdentity? Identity { get; set; }
|
||||
|
||||
/// <summary>Gets or sets which encode the timings apply to.</summary>
|
||||
[JsonPropertyName("cut")]
|
||||
public JmanifestCut? Cut { get; set; }
|
||||
|
||||
/// <summary>Gets or sets extraction provenance.</summary>
|
||||
[JsonPropertyName("extraction")]
|
||||
public JmanifestExtraction? Extraction { get; set; }
|
||||
|
||||
/// <summary>Gets the actors and their presence windows.</summary>
|
||||
[JsonPropertyName("actors")]
|
||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||
public Collection<JmanifestActor> Actors { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>One actor's timeline within a manifest.</summary>
|
||||
public class JmanifestActor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the display name. Server-authoritative on download: the
|
||||
/// server resolves each actor to a TMDB person and serves names from its own
|
||||
/// table, so a name a contributor invented never round-trips.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the IMDB person id.</summary>
|
||||
[JsonPropertyName("imdb_id")]
|
||||
public string? ImdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the TMDB person id — the primary join key.</summary>
|
||||
[JsonPropertyName("tmdb_id")]
|
||||
public string? TmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets the presence windows.</summary>
|
||||
[JsonPropertyName("scenes")]
|
||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||
public Collection<JmanifestScene> Scenes { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>Cut fingerprint (server spec §2, §3).</summary>
|
||||
public class JmanifestCut
|
||||
{
|
||||
/// <summary>Gets or sets the decoded duration the timings came from.</summary>
|
||||
[JsonPropertyName("runtime_sec")]
|
||||
public double RuntimeSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the container duration, if it differs.</summary>
|
||||
[JsonPropertyName("container_duration_sec")]
|
||||
public double? ContainerDurationSec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenSubtitles file hash, as a server may report it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read-only in practice: the plugin never <em>sends</em> one. The file-hash
|
||||
/// match tier is withdrawn on legal grounds — see
|
||||
/// <see cref="Configuration.MatchTier"/> — because a file hash identifies the
|
||||
/// exact release a user holds rather than the cut the timings describe.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("video_hash")]
|
||||
public string? VideoHash { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the version-prefixed spectral-peak signature.</summary>
|
||||
[JsonPropertyName("audio_signature")]
|
||||
public string? AudioSignature { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>Extraction provenance (server spec §2).</summary>
|
||||
public class JmanifestExtraction
|
||||
{
|
||||
/// <summary>Gets or sets the sampling rate used during extraction.</summary>
|
||||
[JsonPropertyName("sample_fps")]
|
||||
public double? SampleFps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the re-acquisition timeout that shapes window extent.
|
||||
/// Successor to the withdrawn <c>anneal_sec</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("extinction_sec")]
|
||||
public double? ExtinctionSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the producing pipeline's version string.</summary>
|
||||
[JsonPropertyName("pipeline_version")]
|
||||
public string? PipelineVersion { get; set; }
|
||||
|
||||
/// <summary>Gets or sets how many references the gallery held.</summary>
|
||||
[JsonPropertyName("gallery_size")]
|
||||
public int? GallerySize { get; set; }
|
||||
|
||||
/// <summary>Gets or sets <c>global</c> or <c>limited</c>.</summary>
|
||||
[JsonPropertyName("gallery_scope")]
|
||||
public string? GalleryScope { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>Title identity (server spec §2).</summary>
|
||||
public class JmanifestIdentity
|
||||
{
|
||||
/// <summary>Gets or sets <c>movie</c> or <c>episode</c>.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the TMDB id, for a movie.</summary>
|
||||
[JsonPropertyName("tmdb_id")]
|
||||
public string? TmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the IMDB id, for a movie.</summary>
|
||||
[JsonPropertyName("imdb_id")]
|
||||
public string? ImdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
|
||||
[JsonPropertyName("series_tmdb_id")]
|
||||
public string? SeriesTmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the series IMDB id, for an episode.</summary>
|
||||
[JsonPropertyName("series_imdb_id")]
|
||||
public string? SeriesImdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the season number, for an episode.</summary>
|
||||
[JsonPropertyName("season")]
|
||||
public int? Season { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the episode number, for an episode.</summary>
|
||||
[JsonPropertyName("episode")]
|
||||
public int? Episode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the display title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the release year.</summary>
|
||||
[JsonPropertyName("year")]
|
||||
public int? Year { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One presence window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>A window is a claim about scene membership, not a recognition event</b>
|
||||
/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
|
||||
/// shot cuts to whoever they are speaking to is still present — so a consumer
|
||||
/// must never read a window boundary as "the face was detected here", and must
|
||||
/// not merge, split or trim windows.
|
||||
/// </remarks>
|
||||
public class JmanifestScene
|
||||
{
|
||||
/// <summary>Gets or sets the window start, in seconds.</summary>
|
||||
[JsonPropertyName("start")]
|
||||
public double Start { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the window end, in seconds, inclusive.</summary>
|
||||
[JsonPropertyName("end")]
|
||||
public double End { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accumulated posterior that justified this claim, in [0, 1].
|
||||
/// </summary>
|
||||
[JsonPropertyName("belief")]
|
||||
public double? Belief { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how the actor was identified: <c>live</c>, <c>deferred</c>
|
||||
/// or <c>pooled</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("route")]
|
||||
public string? Route { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>The server's reply to a manifest fetch (server spec §4).</summary>
|
||||
public class ManifestFetchResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the cut-match tier that was achieved: <c>exact</c>,
|
||||
/// <c>audio</c>, <c>runtime</c> or <c>loose</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("match")]
|
||||
public string Match { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the offset, in seconds, the client must add to every window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Non-zero only for an <c>audio</c>-tier match, where the same cut was
|
||||
/// found at a different trim. <b>The server returns the offset; the client
|
||||
/// applies it</b> — manifests are never rewritten, so one stored manifest
|
||||
/// serves every trim of the same cut.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("offset_sec")]
|
||||
public double OffsetSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the manifest itself.</summary>
|
||||
[JsonPropertyName("manifest")]
|
||||
public Jmanifest? Manifest { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// What a fetch stored, and from where.
|
||||
/// </summary>
|
||||
public class ManifestFetchResult
|
||||
{
|
||||
/// <summary>Gets or sets the server that supplied the manifest.</summary>
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the cut-match tier achieved.</summary>
|
||||
public string Match { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the offset applied to every window, in seconds.</summary>
|
||||
public double OffsetSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets how many actors the stored truth file holds.</summary>
|
||||
public int ActorCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a caveat to surface in the UI, or null when the match needs
|
||||
/// no explanation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>loose</c> match must surface as a caveat rather than being applied
|
||||
/// silently — the runtimes differ by up to 30 seconds, which is usually a
|
||||
/// different trim of the same cut but is not guaranteed to be.
|
||||
/// </remarks>
|
||||
public string? Caveat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>A series bundle (server spec §2).</summary>
|
||||
public class SeriesBundle
|
||||
{
|
||||
/// <summary>Gets or sets the envelope version.</summary>
|
||||
[JsonPropertyName("jmanifest_version")]
|
||||
public int JmanifestVersion { get; set; }
|
||||
|
||||
/// <summary>Gets the episode manifests the server holds.</summary>
|
||||
[JsonPropertyName("episodes")]
|
||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||
public Collection<Jmanifest> Episodes { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>What a server says it supports (server spec §9a).</summary>
|
||||
public class ServerCapabilities
|
||||
{
|
||||
/// <summary>Gets or sets the server's own identity.</summary>
|
||||
[JsonPropertyName("server_id")]
|
||||
public string? ServerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exchange envelope versions the server accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Checked once rather than discovered as a rejection per manifest across a
|
||||
/// whole library sweep.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("jmanifest_versions")]
|
||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||
public Collection<int> JmanifestVersions { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The object handed to JRay's transformation callback by the File
|
||||
/// Transformation plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// That plugin builds a Newtonsoft <c>JObject</c> with a single <c>contents</c>
|
||||
/// key and calls <c>JObject.ToObject(parameterType)</c> against this type.
|
||||
/// Newtonsoft binds member names case-insensitively, so <see cref="Contents"/>
|
||||
/// binds to <c>contents</c> without an attribute — and a System.Text.Json
|
||||
/// attribute would have no effect here.
|
||||
/// </remarks>
|
||||
public class TransformationPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current contents of the file being served, including
|
||||
/// any transformations applied by other plugins ahead of JRay in the
|
||||
/// chain.
|
||||
/// </summary>
|
||||
public string? Contents { get; set; }
|
||||
}
|
||||
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
|
||||
/// which they are visible on screen.
|
||||
/// which they are present in the scene.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Present in the scene", not "visible on screen": a window is a claim about
|
||||
/// scene membership, so an actor who turns away or is off-camera during a
|
||||
/// reverse shot is still present. Two windows mean a genuine departure and
|
||||
/// return, not a break in detection.
|
||||
///
|
||||
/// Identity is public identifiers — never a name alone, which is ambiguous and
|
||||
/// unstable. <c>jellyfin_id</c> is preferred locally and stripped on
|
||||
/// contribution, being meaningless outside the instance that produced it.
|
||||
/// </remarks>
|
||||
// TRACES: JR-004, JR-007 | SR-001, SR-002
|
||||
public class TruthActor
|
||||
{
|
||||
/// <summary>
|
||||
@@ -34,9 +45,13 @@ public class TruthActor
|
||||
public string JellyfinId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
|
||||
/// Gets the windows during which the actor is in the scene.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Objects since <c>schema_version</c> 2, not the float pairs v1 used, so a
|
||||
/// window can carry the belief and route behind the claim.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("scenes")]
|
||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||
public Collection<double[]> Scenes { get; } = new();
|
||||
public Collection<TruthScene> Scenes { get; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// How a fetched manifest was aligned to the local file, recorded alongside the
|
||||
/// truth data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The offset is applied once, at store time (JR-030), after which the stored
|
||||
/// windows look native and nothing would say they had been shifted. This block
|
||||
/// is what makes that reconstructable: which comparison produced the offset, how
|
||||
/// strong it was, and the local file's own signature, so a later fetch can
|
||||
/// re-align without decoding the media again.
|
||||
/// </remarks>
|
||||
// TRACES: JR-047 | SR-003
|
||||
public class TruthAlignment
|
||||
{
|
||||
/// <summary>Gets or sets which comparison produced the applied offset.</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public AlignmentSource Source { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the tier that was applied.</summary>
|
||||
[JsonPropertyName("tier")]
|
||||
public MatchTier Tier { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the offset applied to every window, in seconds.</summary>
|
||||
[JsonPropertyName("offset_sec")]
|
||||
public double OffsetSec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the local alignment's score, or <c>null</c> when none was
|
||||
/// computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the recovered slide between the two analysis windows, in
|
||||
/// frames, or <c>null</c> when no local alignment was computed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only part of <see cref="OffsetSec"/>: the rest comes from the two windows
|
||||
/// being anchored at different points when the runtimes differ.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("offset_frames")]
|
||||
public int? OffsetFrames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the local file's own <c>v1:</c> audio signature, or
|
||||
/// <c>null</c> when none was computed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept so a later fetch can align against a new manifest without running
|
||||
/// FFmpeg over the media again — the expensive half of the operation, and
|
||||
/// the reason signatures are opt-in. It never leaves the instance: it is
|
||||
/// stored beside the truth file, and contribution strips provenance
|
||||
/// entirely (JR-034).
|
||||
/// </remarks>
|
||||
[JsonPropertyName("local_signature")]
|
||||
public string? LocalSignature { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the offset the server claimed, retained even when a local
|
||||
/// alignment superseded it.
|
||||
/// </summary>
|
||||
[JsonPropertyName("server_offset_sec")]
|
||||
public double ServerOffsetSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the tier the server claimed.</summary>
|
||||
[JsonPropertyName("server_tier")]
|
||||
public MatchTier ServerTier { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Which encode the timings in a <see cref="TruthFile"/> apply to (SPEC.md §1,
|
||||
/// JR-002).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Timings are only meaningful against a particular cut. Recording the runtime
|
||||
/// they were measured from is what lets a consumer notice that a file has been
|
||||
/// re-encoded, re-trimmed, or replaced with a different release — rather than
|
||||
/// silently showing an actor twenty seconds late.
|
||||
/// </remarks>
|
||||
// TRACES: JR-002 | SR-003
|
||||
public class TruthCut
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the decoded duration the timings came from, in seconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runtime_sec")]
|
||||
public double? RuntimeSec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the version-prefixed spectral-peak audio signature, or
|
||||
/// <c>null</c> when the producer emitted none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carries its own <c>v1:</c> prefix so a DSP change is detectable rather
|
||||
/// than silently non-matching (JR-045). Media shorter than 120 s carries no
|
||||
/// signature at all (JR-044).
|
||||
/// </remarks>
|
||||
[JsonPropertyName("audio_signature")]
|
||||
public string? AudioSignature { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Extraction provenance for a <see cref="TruthFile"/> (SPEC.md §1, JR-002).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These fields moved here from the top level in the SR-003 bump so that the
|
||||
/// truth file and the Jmanifest's <c>extraction</c> block have the same shape.
|
||||
/// They differed for no reason, and two nearly-identical shapes are what makes a
|
||||
/// converter quietly drop a field.
|
||||
/// <para>
|
||||
/// <b>There is no <c>anneal_sec</c>.</b> It was withdrawn rather than retained
|
||||
/// as a vestigial zero: presence now follows track extent, so a track survives
|
||||
/// its own gaps and there is nothing to anneal (extraction AR-012/AR-013). A
|
||||
/// field naming a mechanism the pipeline no longer has is actively misleading,
|
||||
/// and would outlive everyone who remembers why it reads zero.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-002 | SR-003
|
||||
public class TruthExtraction
|
||||
{
|
||||
/// <summary>Gets or sets the sampling rate used during extraction.</summary>
|
||||
[JsonPropertyName("sample_fps")]
|
||||
public double? SampleFps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the re-acquisition timeout that shapes window extent, in
|
||||
/// seconds. Successor to the withdrawn <c>anneal_sec</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what a consumer needs in order to interpret a window: it bounds
|
||||
/// how long an actor could be unseen without the window being closed.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("extinction_sec")]
|
||||
public double? ExtinctionSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the producing pipeline's version string.</summary>
|
||||
[JsonPropertyName("pipeline_version")]
|
||||
public string? PipelineVersion { get; set; }
|
||||
|
||||
/// <summary>Gets or sets how many references the gallery held.</summary>
|
||||
[JsonPropertyName("gallery_size")]
|
||||
public int? GallerySize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets <c>global</c> or <c>limited</c> — the strongest single
|
||||
/// quality signal when two manifests compete for one cut.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gallery_scope")]
|
||||
public string? GalleryScope { get; set; }
|
||||
}
|
||||
@@ -5,12 +5,30 @@ namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Root object of a scene-actor-extraction "truth" file
|
||||
/// (schema_version 1, minimal verbosity). See SPEC.md.
|
||||
/// (<c>schema_version</c> 2). See SPEC.md §1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// JRay <b>owns</b> this format; the extraction pipeline is its producer and the
|
||||
/// public server carries a derived envelope. Because three repos ship
|
||||
/// independently, breaking changes are batched into one coordinated
|
||||
/// <c>schema_version</c> bump rather than made piecemeal.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>This is the v2 shape, and v1 is gone rather than deprecated.</b> The bump
|
||||
/// removed <c>anneal_sec</c>, moved <c>sample_fps</c> into
|
||||
/// <see cref="TruthExtraction"/>, added <see cref="TruthCut"/>, and turned
|
||||
/// <c>scenes</c> from float pairs into <see cref="TruthScene"/> objects carrying
|
||||
/// belief and route. Nothing here reads a v1 file: see
|
||||
/// <see cref="Services.TruthSchema"/> for why that is a decision rather than an
|
||||
/// omission.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-001, JR-002 | SR-003
|
||||
public class TruthFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the schema version of this file.
|
||||
/// Gets or sets the schema version of this file. Only
|
||||
/// <see cref="Services.TruthSchema.SupportedVersion"/> is accepted.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public int SchemaVersion { get; set; }
|
||||
@@ -18,23 +36,29 @@ public class TruthFile
|
||||
/// <summary>
|
||||
/// Gets or sets the source media path at extraction time (informational).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stripped on contribution (JR-034): it is a contributor's directory
|
||||
/// layout, which is nobody else's business and identifies them.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("movie")]
|
||||
public string Movie { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sampling rate (frames per second) used during extraction.
|
||||
/// Gets or sets extraction provenance, or <c>null</c> when the producer
|
||||
/// recorded none.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sample_fps")]
|
||||
public double SampleFps { get; set; }
|
||||
[JsonPropertyName("extraction")]
|
||||
public TruthExtraction? Extraction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
|
||||
/// Gets or sets which encode the timings apply to, or <c>null</c> when the
|
||||
/// producer recorded none.
|
||||
/// </summary>
|
||||
[JsonPropertyName("anneal_sec")]
|
||||
public double AnnealSec { get; set; }
|
||||
[JsonPropertyName("cut")]
|
||||
public TruthCut? Cut { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of actors detected in the film, each with their on-screen scene windows.
|
||||
/// Gets the list of actors in the film, each with their scene-presence windows.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actors")]
|
||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Where an item's truth data came from.
|
||||
/// </summary>
|
||||
public enum TruthSource
|
||||
{
|
||||
/// <summary>A <c>.jray.json</c> file beside the media, written locally.</summary>
|
||||
Sidecar = 0,
|
||||
|
||||
/// <summary>Pushed over HTTP by a worker that cannot write beside the media.</summary>
|
||||
Pushed = 1,
|
||||
|
||||
/// <summary>Fetched from a manifest server and converted to a truth file.</summary>
|
||||
Fetched = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How an item's truth data was obtained, recorded alongside it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three sources are not interchangeable. A locally computed sidecar and a
|
||||
/// <c>loose</c>-tier manifest from a third-party server make claims of very
|
||||
/// different strength about the same item, and once stored they are otherwise
|
||||
/// indistinguishable — the truth file itself records nothing about how it
|
||||
/// arrived.
|
||||
///
|
||||
/// <para>
|
||||
/// This is stored <b>beside</b> the truth file rather than 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-010, JR-036 | PR-001
|
||||
public class TruthProvenance
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets which of the three routes delivered this truth data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public TruthSource Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server a fetched manifest came from. Empty for the
|
||||
/// local sources, whose origin is this instance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("server_url")]
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cut-match tier a fetched manifest reached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null for local sources: a sidecar or a push is about *this* file, so
|
||||
/// there is no cut to match. The tier is what makes a fetched claim
|
||||
/// interpretable — <c>loose</c> means "probably the same cut", which the UI
|
||||
/// must surface rather than apply silently (JR-036).
|
||||
/// </remarks>
|
||||
[JsonPropertyName("match_tier")]
|
||||
public MatchTier? MatchTier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the offset, in seconds, applied to every window before
|
||||
/// storage so the stored timings are in this file's own timebase (JR-030).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recorded because it is otherwise unrecoverable: once applied, the stored
|
||||
/// windows look native, and nothing would say they had been shifted.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("offset_sec")]
|
||||
public double OffsetSec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how the applied offset was arrived at, or null for local
|
||||
/// sources and for fetches made before alignment was recorded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="OffsetSec"/> says what was applied; this says why, and keeps
|
||||
/// the local file's own signature so a later fetch can re-align without
|
||||
/// decoding the media again (JR-047).
|
||||
/// </remarks>
|
||||
[JsonPropertyName("alignment")]
|
||||
public TruthAlignment? Alignment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a human-readable caveat to surface with the overlay, or
|
||||
/// null when the claim needs none.
|
||||
/// </summary>
|
||||
[JsonPropertyName("caveat")]
|
||||
public string? Caveat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this truth data was recorded, UTC.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recorded_at")]
|
||||
public DateTime RecordedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates provenance for truth data produced on this instance.
|
||||
/// </summary>
|
||||
/// <param name="source">Either <see cref="TruthSource.Sidecar"/> or <see cref="TruthSource.Pushed"/>.</param>
|
||||
/// <param name="recordedAt">When it was recorded, UTC.</param>
|
||||
/// <returns>The provenance record.</returns>
|
||||
public static TruthProvenance Local(TruthSource source, DateTime recordedAt)
|
||||
=> new() { Source = source, RecordedAt = recordedAt };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One presence window in a <see cref="TruthFile"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>A window is a claim about scene membership, not a recognition event</b>
|
||||
/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
|
||||
/// shot cuts to whoever they are speaking to is still present — so a consumer
|
||||
/// must never read a boundary as "the face was detected here", and must not
|
||||
/// merge, split, trim or reorder windows.
|
||||
/// <para>
|
||||
/// In <c>schema_version</c> 1 this was a bare <c>[start, end]</c> float pair. It
|
||||
/// became an object in the SR-003 bump so a window can carry the evidence behind
|
||||
/// it: a consumer that shows presence should be able to say how strongly it is
|
||||
/// believed and how it was arrived at, which a pair of numbers cannot express.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-002, JR-004 | SR-002, SR-003
|
||||
public class TruthScene
|
||||
{
|
||||
/// <summary>Gets or sets the window start, in seconds, inclusive.</summary>
|
||||
[JsonPropertyName("start")]
|
||||
public double Start { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the window end, in seconds, inclusive.</summary>
|
||||
[JsonPropertyName("end")]
|
||||
public double End { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accumulated posterior that justified this claim, in
|
||||
/// <c>[0, 1]</c>, or <c>null</c> when the producer did not record one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Optional rather than defaulted to zero: absent and "believed with
|
||||
/// probability zero" are different statements, and a claim nobody believes
|
||||
/// would not have been written.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("belief")]
|
||||
public double? Belief { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how the actor was identified: <c>live</c>, <c>deferred</c>
|
||||
/// or <c>pooled</c> (extraction AR-017).
|
||||
/// </summary>
|
||||
[JsonPropertyName("route")]
|
||||
public string? Route { get; set; }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay;
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
private readonly bool _usingFileTransformation;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
@@ -30,8 +31,25 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
Instance = this;
|
||||
_logger = logger;
|
||||
|
||||
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||
// The File Transformation plugin rewrites index.html as it is served.
|
||||
// Registration is unconditional: the transformation itself checks
|
||||
// EnableOverlay at request time, so toggling the setting takes effect
|
||||
// without re-registering.
|
||||
_usingFileTransformation = FileTransformationRegistration.TryRegister(_logger);
|
||||
|
||||
if (!_usingFileTransformation)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"JRay: the pause overlay is disabled because the File Transformation plugin " +
|
||||
"is not available. Install it from " +
|
||||
"https://github.com/IAmParadox27/jellyfin-plugin-file-transformation. " +
|
||||
"All other JRay features are unaffected.");
|
||||
}
|
||||
|
||||
// JRay never injects into index.html. This only ever *removes* a patch
|
||||
// left by an earlier version of JRay, identified by its own marker —
|
||||
// see SPEC.md JR-021/JR-022.
|
||||
WebClientPatchService.RemoveLegacyPatch(ApplicationPaths, _logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -45,6 +63,20 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the pause overlay should currently be
|
||||
/// served. Read by the File Transformation callback at request time, so
|
||||
/// toggling the setting takes effect without re-registering.
|
||||
/// </summary>
|
||||
internal static bool OverlayEnabled => Instance?.Configuration.EnableOverlay ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the File Transformation plugin was found
|
||||
/// at startup. When it was not, the overlay is disabled and every other JRay
|
||||
/// feature continues to work.
|
||||
/// </summary>
|
||||
internal static bool FileTransformationAvailable => Instance?._usingFileTransformation ?? false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
|
||||
@@ -17,5 +17,18 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
|
||||
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
|
||||
serviceCollection.AddSingleton<IMediaPolicyStore, MediaPolicyStore>();
|
||||
|
||||
// Singleton so per-server backoff state survives across requests: a
|
||||
// server that is down should be skipped for the whole sweep, not
|
||||
// retried once per item (JR-037).
|
||||
serviceCollection.AddSingleton<IManifestExchangeClient, ManifestExchangeClient>();
|
||||
|
||||
// Registered concretely: there is no second implementation for an
|
||||
// interface to abstract over and nothing to gain from inventing one
|
||||
// (JR-042). The aligner consumes the signature on the fetch path, so a
|
||||
// manifest is checked against the local file before its windows are
|
||||
// stored (JR-047).
|
||||
serviceCollection.AddSingleton<AudioSignatureService>();
|
||||
serviceCollection.AddSingleton<ManifestAligner>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The JRay v1 content-derived audio signature: spectral peak bins taken from
|
||||
/// the centre of a media file, so a truth file is self-identifying.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The construction is owned by the public server specification §3 and is
|
||||
/// implemented a second time, in C++, by the extraction pipeline
|
||||
/// (<c>src/audio_signature.*</c>, extraction <c>IR-004</c>). <b>The two must
|
||||
/// agree byte for byte</b> — a signature that differs in any parameter simply
|
||||
/// does not match, which defeats the entire point of having one. Every constant
|
||||
/// below is therefore load-bearing, and any change to one is a change to the
|
||||
/// <c>v1:</c> prefix as well.
|
||||
/// <para>
|
||||
/// The server specification's prose is not sufficient to reproduce a byte
|
||||
/// stream, so the details it leaves open are pinned by the golden fixture
|
||||
/// shared with the extraction repo (JR-043,
|
||||
/// <c>fixtures/audio/jray_audio_v1_golden.json</c>), and restated here:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Arithmetic is IEEE-754 <c>double</c> throughout. <c>float</c> is not
|
||||
/// sufficient: the fixture has frames whose two strongest bands are within 1.3%
|
||||
/// of each other.</item>
|
||||
/// <item>Samples arrive as FFmpeg's native <c>s16 -> flt</c> conversion,
|
||||
/// <c>x * (1/32768)</c>, widened to double here.</item>
|
||||
/// <item>Whole frames only:
|
||||
/// <c>n_frames = 1 + (n_samples - 4096) / 1024</c>, integer division.</item>
|
||||
/// <item>Hann window, <b>periodic</b>: <c>0.5 * (1 - cos(2*pi*n/4096))</c>, not
|
||||
/// the symmetric <c>N-1</c> variant.</item>
|
||||
/// <item>Plain radix-2 FFT, no normalisation; magnitude is
|
||||
/// <c>sqrt(re^2 + im^2)</c>.</item>
|
||||
/// <item>A band's value is the <b>mean</b> of the linear magnitudes in it, so a
|
||||
/// wide high band is not favoured over a narrow low one.</item>
|
||||
/// <item>The peak is the <c>argmax</c> over the 32 bands, ties to the lowest
|
||||
/// index. The specification's log is a monotone squash and so cannot change an
|
||||
/// argmax; it is applied only where it is observable, in the energy class.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The FFT is written out here rather than taken from a library for the same
|
||||
/// reason the pipeline writes its own: it is a fixed, fully specified transform,
|
||||
/// and a dependency whose version could change the numerics is a liability when
|
||||
/// the output has to be identical across two languages.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-042 | SR-003
|
||||
public static class AudioSignature
|
||||
{
|
||||
/// <summary>Sample rate the signature is computed at, in Hz.</summary>
|
||||
public const int SampleRate = 11025;
|
||||
|
||||
/// <summary>STFT frame size, in samples.</summary>
|
||||
public const int FrameSize = 4096;
|
||||
|
||||
/// <summary>STFT hop size, in samples (~93 ms).</summary>
|
||||
public const int HopSize = 1024;
|
||||
|
||||
/// <summary>Number of logarithmically spaced bands.</summary>
|
||||
public const int NumBands = 32;
|
||||
|
||||
/// <summary>Low edge of the analysed band, in Hz.</summary>
|
||||
public const double BandLoHz = 300.0;
|
||||
|
||||
/// <summary>High edge of the analysed band, in Hz.</summary>
|
||||
public const double BandHiHz = 3000.0;
|
||||
|
||||
/// <summary>Length of the analysed window, in seconds.</summary>
|
||||
public const double WindowSec = 120.0;
|
||||
|
||||
/// <summary>
|
||||
/// Length of the analysed window, in samples (120.000 s at 11025 Hz).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The decoded window is truncated to exactly this, so the frame count is
|
||||
/// the same for every input and does not wobble with seek granularity or a
|
||||
/// resampler tail.
|
||||
/// </remarks>
|
||||
public const int WindowSamples = 1323000;
|
||||
|
||||
/// <summary>Frames a full window yields: <c>1 + (1323000 - 4096) / 1024</c>.</summary>
|
||||
/// <remarks>
|
||||
/// The server specification says "~1290" and accepts a tolerance; the exact
|
||||
/// count follows from the framing rule and is 1288.
|
||||
/// </remarks>
|
||||
public const int ExpectedFrames = 1288;
|
||||
|
||||
/// <summary>Guard added to both sides of the energy ratio.</summary>
|
||||
public const double EnergyEps = 1e-12;
|
||||
|
||||
/// <summary>
|
||||
/// The signature's own version prefix, separate from <c>schema_version</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A future change to the DSP chain must be <i>detectable</i> rather than
|
||||
/// silently producing signatures that no longer match (JR-045).
|
||||
/// </remarks>
|
||||
public const string VersionPrefix = "v1:";
|
||||
|
||||
// Class thresholds on log10(E_frame / E_median). They deliberately straddle
|
||||
// r = 0 rather than sit on it, so the median frame itself is never on a
|
||||
// boundary.
|
||||
private static readonly double[] EnergyClassEdges = [-0.6, -0.2, 0.2];
|
||||
|
||||
private static readonly (int Low, int High)[] Bands = BuildBandTable();
|
||||
private static readonly double[] Window = BuildHannWindow();
|
||||
private static readonly int[] BitReversal = BuildBitReversal();
|
||||
private static readonly double[][] TwiddleReal = BuildTwiddles(cosine: true);
|
||||
private static readonly double[][] TwiddleImag = BuildTwiddles(cosine: false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the half-open FFT bin range <c>[Low, High)</c> owned by each of the
|
||||
/// 32 log-spaced bands.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed so the golden fixture can assert the table itself rather than
|
||||
/// only the signature it produces: the band table is the part of the
|
||||
/// construction most likely to drift between two implementations.
|
||||
/// </remarks>
|
||||
/// <returns>One range per band, contiguous and non-overlapping.</returns>
|
||||
public static IReadOnlyList<(int Low, int High)> BandFftBins() => Bands;
|
||||
|
||||
/// <summary>
|
||||
/// Packs one byte per whole STFT frame: a 5-bit peak band index and a 2-bit
|
||||
/// energy class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The byte layout is <c>(band << 2) | class</c>, so bit 7 is always
|
||||
/// clear and an arbitrary byte is not a valid signature. That structural
|
||||
/// constraint is what the server validates on upload, and what keeps the
|
||||
/// field from being usable as a payload channel.
|
||||
/// </remarks>
|
||||
/// <param name="mono">Mono samples at <see cref="SampleRate"/>, in [-1, 1).</param>
|
||||
/// <returns>One byte per frame; empty when not even one frame fits.</returns>
|
||||
public static byte[] PackFrames(ReadOnlySpan<float> mono)
|
||||
{
|
||||
if (mono.Length < FrameSize)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var frames = 1 + ((mono.Length - FrameSize) / HopSize);
|
||||
var binLow = Bands[0].Low;
|
||||
var binHigh = Bands[NumBands - 1].High; // exclusive
|
||||
double binCount = binHigh - binLow;
|
||||
|
||||
var re = new double[FrameSize];
|
||||
var im = new double[FrameSize];
|
||||
var peak = new byte[frames];
|
||||
var energy = new double[frames];
|
||||
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var src = mono.Slice(f * HopSize, FrameSize);
|
||||
for (var n = 0; n < FrameSize; n++)
|
||||
{
|
||||
re[n] = src[n] * Window[n];
|
||||
im[n] = 0.0;
|
||||
}
|
||||
|
||||
Fft(re, im);
|
||||
|
||||
// Per-band mean magnitude. The bands tile 300-3000 Hz with no gaps
|
||||
// and no overlaps, so the frame's band-limited energy is the sum of
|
||||
// the band sums — accumulated in band order, because the order of a
|
||||
// floating-point summation is part of the contract.
|
||||
var best = -1.0;
|
||||
var bestBand = 0;
|
||||
var total = 0.0;
|
||||
for (var b = 0; b < NumBands; b++)
|
||||
{
|
||||
var (low, high) = Bands[b];
|
||||
var sum = 0.0;
|
||||
for (var k = low; k < high; k++)
|
||||
{
|
||||
sum += Math.Sqrt((re[k] * re[k]) + (im[k] * im[k]));
|
||||
}
|
||||
|
||||
total += sum;
|
||||
var mean = sum / (high - low);
|
||||
if (mean > best)
|
||||
{
|
||||
best = mean; // ties -> lowest index
|
||||
bestBand = b;
|
||||
}
|
||||
}
|
||||
|
||||
peak[f] = (byte)bestBand;
|
||||
energy[f] = total / binCount;
|
||||
}
|
||||
|
||||
// The reference is the upper median of the frame energies: an actually
|
||||
// observed value rather than the average of the two middle ones, so it
|
||||
// is bit-reproducible. It is also gain-invariant — loudness
|
||||
// normalisation must not change a signature — and barely moves when the
|
||||
// window is trimmed.
|
||||
var sorted = (double[])energy.Clone();
|
||||
Array.Sort(sorted);
|
||||
var reference = sorted[sorted.Length / 2];
|
||||
|
||||
var packed = new byte[frames];
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var r = Math.Log10((energy[f] + EnergyEps) / (reference + EnergyEps));
|
||||
packed[f] = (byte)(((peak[f] & 0x1F) << 2) | (EnergyClass(r) & 0x03));
|
||||
}
|
||||
|
||||
return packed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the full signature string for a decoded centre window.
|
||||
/// </summary>
|
||||
/// <param name="mono">Mono samples at <see cref="SampleRate"/>, in [-1, 1).</param>
|
||||
/// <returns>
|
||||
/// <c>v1:</c> followed by the base64 of <see cref="PackFrames"/>, or
|
||||
/// <c>null</c> when not even one frame fits.
|
||||
/// </returns>
|
||||
public static string? FromMonoSamples(ReadOnlySpan<float> mono)
|
||||
{
|
||||
var packed = PackFrames(mono);
|
||||
if (packed.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard alphabet with '=' padding, which is what the server and the
|
||||
// pipeline both emit.
|
||||
return VersionPrefix + Convert.ToBase64String(packed);
|
||||
}
|
||||
|
||||
private static int EnergyClass(double ratio)
|
||||
{
|
||||
if (ratio < EnergyClassEdges[0])
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (ratio < EnergyClassEdges[1])
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (ratio < EnergyClassEdges[2])
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
// edge[b] = 300 * (3000/300)^(b/32); band b owns FFT bins
|
||||
// [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] / hz_per_bin). Taking the
|
||||
// ceiling once, into an integer table, means membership is never decided by
|
||||
// a float comparison per bin per frame — which is where two implementations
|
||||
// would otherwise be free to disagree.
|
||||
private static (int Low, int High)[] BuildBandTable()
|
||||
{
|
||||
var hzPerBin = (double)SampleRate / FrameSize;
|
||||
var edges = new int[NumBands + 1];
|
||||
for (var b = 0; b <= NumBands; b++)
|
||||
{
|
||||
var hz = BandLoHz * Math.Pow(BandHiHz / BandLoHz, (double)b / NumBands);
|
||||
edges[b] = (int)Math.Ceiling(hz / hzPerBin);
|
||||
}
|
||||
|
||||
var table = new (int Low, int High)[NumBands];
|
||||
for (var b = 0; b < NumBands; b++)
|
||||
{
|
||||
table[b] = (edges[b], edges[b + 1]);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static double[] BuildHannWindow()
|
||||
{
|
||||
var w = new double[FrameSize];
|
||||
for (var n = 0; n < FrameSize; n++)
|
||||
{
|
||||
w[n] = 0.5 * (1.0 - Math.Cos(2.0 * Math.PI * n / FrameSize));
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
private static int[] BuildBitReversal()
|
||||
{
|
||||
var bits = 0;
|
||||
while ((1 << bits) < FrameSize)
|
||||
{
|
||||
bits++;
|
||||
}
|
||||
|
||||
var rev = new int[FrameSize];
|
||||
for (var i = 0; i < FrameSize; i++)
|
||||
{
|
||||
var r = 0;
|
||||
for (var b = 0; b < bits; b++)
|
||||
{
|
||||
if ((i & (1 << b)) != 0)
|
||||
{
|
||||
r |= 1 << (bits - 1 - b);
|
||||
}
|
||||
}
|
||||
|
||||
rev[i] = r;
|
||||
}
|
||||
|
||||
return rev;
|
||||
}
|
||||
|
||||
// Twiddles are precomputed per stage from cos/sin of -2*pi*j/len, so the
|
||||
// angle is an exactly reproducible double in either language and only the
|
||||
// library's own rounding of cos/sin (<= 1 ulp) can differ — orders of
|
||||
// magnitude below the decision margins the golden fixture records.
|
||||
private static double[][] BuildTwiddles(bool cosine)
|
||||
{
|
||||
var stages = new List<double[]>();
|
||||
for (var len = 2; len <= FrameSize; len <<= 1)
|
||||
{
|
||||
var half = len / 2;
|
||||
var stage = new double[half];
|
||||
for (var j = 0; j < half; j++)
|
||||
{
|
||||
var angle = -2.0 * Math.PI * j / len;
|
||||
stage[j] = cosine ? Math.Cos(angle) : Math.Sin(angle);
|
||||
}
|
||||
|
||||
stages.Add(stage);
|
||||
}
|
||||
|
||||
return [.. stages];
|
||||
}
|
||||
|
||||
// Radix-2 decimation-in-time complex FFT, in place, no normalisation.
|
||||
private static void Fft(double[] re, double[] im)
|
||||
{
|
||||
for (var i = 0; i < FrameSize; i++)
|
||||
{
|
||||
var j = BitReversal[i];
|
||||
if (i < j)
|
||||
{
|
||||
(re[i], re[j]) = (re[j], re[i]);
|
||||
(im[i], im[j]) = (im[j], im[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var stage = 0;
|
||||
for (var len = 2; len <= FrameSize; len <<= 1, stage++)
|
||||
{
|
||||
var half = len / 2;
|
||||
var wr = TwiddleReal[stage];
|
||||
var wi = TwiddleImag[stage];
|
||||
for (var start = 0; start < FrameSize; start += len)
|
||||
{
|
||||
for (var j = 0; j < half; j++)
|
||||
{
|
||||
var a = start + j;
|
||||
var b = a + half;
|
||||
var tr = (re[b] * wr[j]) - (im[b] * wi[j]);
|
||||
var ti = (re[b] * wi[j]) + (im[b] * wr[j]);
|
||||
re[b] = re[a] - tr;
|
||||
im[b] = im[a] - ti;
|
||||
re[a] += tr;
|
||||
im[a] += ti;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of comparing a local audio signature against a remote one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Produced only when the two signatures actually align; a comparison that
|
||||
/// reaches no tier yields no result at all rather than a zero-scored one, so a
|
||||
/// caller cannot mistake "did not match" for "matched at the bottom".
|
||||
/// </remarks>
|
||||
public readonly struct AudioSignatureMatch : IEquatable<AudioSignatureMatch>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the fraction of overlapping frames whose peak band agreed, in [0, 1].
|
||||
/// </summary>
|
||||
public double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the best-scoring alignment, in frames, between the two analysis
|
||||
/// windows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Positive means the local window lags the remote one. This is a
|
||||
/// window-relative quantity and is <b>not</b> the offset to apply to
|
||||
/// timings — see <see cref="OffsetSec"/>, which additionally accounts for
|
||||
/// the two windows being anchored at different points in their files.
|
||||
/// </remarks>
|
||||
public int OffsetFrames { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seconds to add to every remote window to bring it into the local
|
||||
/// file's timebase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the quantity JR-030 applies at store time, and the one
|
||||
/// <see cref="ManifestConverter.ToTruthFile"/> takes.
|
||||
/// </remarks>
|
||||
public double OffsetSec { get; init; }
|
||||
|
||||
/// <summary>Gets the tier this score earns.</summary>
|
||||
public MatchTier Tier { get; init; }
|
||||
|
||||
/// <summary>Compares two matches for equality.</summary>
|
||||
/// <param name="left">Left operand.</param>
|
||||
/// <param name="right">Right operand.</param>
|
||||
/// <returns><c>true</c> when the two are equal.</returns>
|
||||
public static bool operator ==(AudioSignatureMatch left, AudioSignatureMatch right) => left.Equals(right);
|
||||
|
||||
/// <summary>Compares two matches for inequality.</summary>
|
||||
/// <param name="left">Left operand.</param>
|
||||
/// <param name="right">Right operand.</param>
|
||||
/// <returns><c>true</c> when the two differ.</returns>
|
||||
public static bool operator !=(AudioSignatureMatch left, AudioSignatureMatch right) => !left.Equals(right);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(AudioSignatureMatch other)
|
||||
=> Score.Equals(other.Score)
|
||||
&& OffsetFrames == other.OffsetFrames
|
||||
&& OffsetSec.Equals(other.OffsetSec)
|
||||
&& Tier == other.Tier;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => obj is AudioSignatureMatch other && Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => HashCode.Combine(Score, OffsetFrames, OffsetSec, Tier);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads v1 audio signatures and aligns two of them, recovering the time offset
|
||||
/// between differently trimmed releases of one cut.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <b>consumer</b> half of the signature feature: <see cref="AudioSignature"/>
|
||||
/// produces, this reads. Matching belongs to the plugin rather than the server
|
||||
/// because the offset is applied client-side (JR-030) and manifests are never
|
||||
/// rewritten — one stored manifest serves every trim of the same cut.
|
||||
/// <para>
|
||||
/// The construction is public server specification §3 "Matching and offset
|
||||
/// recovery": slide one signature against the other over ±600 frames, score the
|
||||
/// fraction of overlapping frames whose <b>peak band</b> agrees, and take the
|
||||
/// argmax. The energy class is deliberately not scored — it is the coarser and
|
||||
/// less re-encoding-stable of the two fields, and the specification's rule names
|
||||
/// the peak bin alone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-044, JR-045 | SR-003
|
||||
public static class AudioSignatureMatcher
|
||||
{
|
||||
/// <summary>Widest alignment searched, in frames (±56 s).</summary>
|
||||
/// <remarks>
|
||||
/// Covers realistic trim differences. A release that differs in *speed*
|
||||
/// (a PAL 4% speed-up) is not a constant offset and is correctly rejected by
|
||||
/// the score threshold rather than mis-aligned by this search.
|
||||
/// </remarks>
|
||||
public const int MaxOffsetFrames = 600;
|
||||
|
||||
/// <summary>Score at or above which two signatures are the same cut.</summary>
|
||||
public const double AudioThreshold = 0.85;
|
||||
|
||||
/// <summary>Score at or above which two signatures are possibly the same cut.</summary>
|
||||
public const double LooseThreshold = 0.60;
|
||||
|
||||
/// <summary>
|
||||
/// Fewest overlapping frames an alignment must have before its score counts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not from the specification</b> — a guard this implementation adds. The
|
||||
/// slide is defined over overlapping frames, and without a floor the extreme
|
||||
/// offsets compare a handful of frames, where a chance agreement scores 1.0
|
||||
/// and beats the true alignment. 64 frames is ~6 s. It only ever excludes
|
||||
/// alignments near the ±600 limit: two full-length signatures overlap by 688
|
||||
/// frames even at the widest offset, so this never binds on the case the
|
||||
/// feature exists for.
|
||||
/// </remarks>
|
||||
public const int MinOverlapFrames = 64;
|
||||
|
||||
/// <summary>Gets the duration one STFT frame advances, in seconds.</summary>
|
||||
public static double FrameSeconds => (double)AudioSignature.HopSize / AudioSignature.SampleRate;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a signature string into its per-frame bytes, refusing anything that
|
||||
/// is not a well-formed <c>v1:</c> signature.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// JR-045. An unknown prefix is <b>refused, not parsed</b>: a <c>v2:</c>
|
||||
/// signature from a future producer describes a DSP chain this build does not
|
||||
/// implement, so scoring it as v1 would silently produce a wrong answer where
|
||||
/// declining produces a correct one — the item simply falls to the runtime
|
||||
/// tier. That is the entire purpose of the prefix being separate from
|
||||
/// <c>schema_version</c>.
|
||||
/// <para>
|
||||
/// Structure is checked too, matching what the server validates on upload:
|
||||
/// every byte is a 5-bit band index and a 2-bit energy class, so bit 7 is
|
||||
/// always clear.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="signature">A signature string, or <c>null</c>.</param>
|
||||
/// <returns>The frame bytes, or <c>null</c> if this is not a v1 signature.</returns>
|
||||
public static byte[]? TryParseFrames(string? signature)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature)
|
||||
|| !signature.StartsWith(AudioSignature.VersionPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = signature[AudioSignature.VersionPrefix.Length..];
|
||||
Span<byte> decoded = new byte[((payload.Length / 4) + 1) * 3];
|
||||
if (!Convert.TryFromBase64String(payload, decoded, out var written) || written == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var frames = decoded[..written].ToArray();
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
if ((frame & 0x80) != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aligns a local signature against a remote one and reports the tier and
|
||||
/// offset it earns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns <c>null</c> — no match, and therefore <b>no offset</b> — when
|
||||
/// either signature is absent or is not a v1 signature (JR-045), when either
|
||||
/// item is shorter than the analysis window (JR-044), or when the best
|
||||
/// alignment scores below <see cref="LooseThreshold"/>.
|
||||
/// <para>
|
||||
/// The short-media rule is checked on the runtimes rather than inferred from
|
||||
/// a missing signature, because the two producers must apply the identical
|
||||
/// rule and the runtime is what both of them test. An item under 120 s falls
|
||||
/// back to the runtime tier, which is adequate: a sub-two-minute item is
|
||||
/// rarely the ambiguous-providence case the signature exists to solve.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="localSignature">Signature computed from the local file.</param>
|
||||
/// <param name="remoteSignature">Signature carried by the fetched manifest.</param>
|
||||
/// <param name="localRuntimeSec">Runtime of the local file, in seconds.</param>
|
||||
/// <param name="remoteRuntimeSec">Runtime the manifest records, in seconds.</param>
|
||||
/// <returns>The match, or <c>null</c> when the two do not align.</returns>
|
||||
public static AudioSignatureMatch? Compare(
|
||||
string? localSignature,
|
||||
string? remoteSignature,
|
||||
double localRuntimeSec,
|
||||
double remoteRuntimeSec)
|
||||
{
|
||||
// JR-044 — below the window there is no signature to trust and no offset
|
||||
// to apply, whatever the strings happen to contain.
|
||||
if (localRuntimeSec < AudioSignature.WindowSec || remoteRuntimeSec < AudioSignature.WindowSec)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var local = TryParseFrames(localSignature);
|
||||
var remote = TryParseFrames(remoteSignature);
|
||||
if (local is null || remote is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bestScore = -1.0;
|
||||
var bestOffset = 0;
|
||||
var found = false;
|
||||
|
||||
for (var d = -MaxOffsetFrames; d <= MaxOffsetFrames; d++)
|
||||
{
|
||||
// i indexes the remote signature; the local frame it is compared
|
||||
// against is i + d, which must fall inside the local signature.
|
||||
var first = Math.Max(0, -d);
|
||||
var last = Math.Min(remote.Length, local.Length - d);
|
||||
var overlap = last - first;
|
||||
if (overlap < MinOverlapFrames)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var agreed = 0;
|
||||
for (var i = first; i < last; i++)
|
||||
{
|
||||
if (((local[i + d] >> 2) & 0x1F) == ((remote[i] >> 2) & 0x1F))
|
||||
{
|
||||
agreed++;
|
||||
}
|
||||
}
|
||||
|
||||
var score = (double)agreed / overlap;
|
||||
|
||||
// Ties go to the alignment closest to zero: when a signature is
|
||||
// degenerate enough that several offsets score alike, "not shifted"
|
||||
// is the reading that does least damage, and the choice must be
|
||||
// deterministic rather than an artefact of iteration order.
|
||||
if (!found || score > bestScore || (score == bestScore && Math.Abs(d) < Math.Abs(bestOffset)))
|
||||
{
|
||||
bestScore = score;
|
||||
bestOffset = d;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found || bestScore < LooseThreshold)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Both windows are centred on their own file's midpoint, so an offset
|
||||
// between the two windows is only part of the answer: the windows start
|
||||
// at different absolute times whenever the runtimes differ, and that
|
||||
// difference is the rest of it. Runtimes are >= WindowSec here, so
|
||||
// neither start clamps at zero — the same condition under which
|
||||
// AudioSignatureService does not clamp either.
|
||||
var localStart = (localRuntimeSec / 2.0) - (AudioSignature.WindowSec / 2.0);
|
||||
var remoteStart = (remoteRuntimeSec / 2.0) - (AudioSignature.WindowSec / 2.0);
|
||||
|
||||
return new AudioSignatureMatch
|
||||
{
|
||||
Score = bestScore,
|
||||
OffsetFrames = bestOffset,
|
||||
OffsetSec = (localStart - remoteStart) + (bestOffset * FrameSeconds),
|
||||
Tier = bestScore >= AudioThreshold ? MatchTier.Audio : MatchTier.Loose,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the v1 audio signature for a media file, decoding its centre window
|
||||
/// with the FFmpeg binary Jellyfin already ships.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No new dependency.</b> FFmpeg performs decode, downmix and resample; the
|
||||
/// plugin adds only the fixed FFT and bin-peak extraction in
|
||||
/// <see cref="AudioSignature"/>. The binary is reached through
|
||||
/// <see cref="IMediaEncoder.EncoderPath"/>, so an installation that can
|
||||
/// transcode can compute signatures, with nothing further to install and no
|
||||
/// second copy of FFmpeg to keep in step.
|
||||
/// <para>
|
||||
/// The pipeline computes the same signature for files it processes locally
|
||||
/// (extraction <c>IR-004</c>); this exists for the files it never sees. Both
|
||||
/// producers must therefore agree exactly, including on the decode: the command
|
||||
/// below is the CLI spelling of what the pipeline asks libswresample for — best
|
||||
/// audio stream, mono, 11025 Hz, 32-bit float — and the golden fixture pins the
|
||||
/// decoded PCM as well as the signature, so a codec-level divergence is
|
||||
/// distinguishable from a DSP-level one (JR-043).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every failure degrades to no signature rather than to an error.</b> A
|
||||
/// signature is an enhancement to cut matching; a missing one costs a tier, and
|
||||
/// must never be able to break a fetch.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-042 | SR-003
|
||||
public class AudioSignatureService
|
||||
{
|
||||
private readonly IMediaEncoder _encoder;
|
||||
private readonly ILogger<AudioSignatureService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AudioSignatureService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="encoder">Supplies the path of the FFmpeg binary Jellyfin ships.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public AudioSignatureService(IMediaEncoder encoder, ILogger<AudioSignatureService> logger)
|
||||
{
|
||||
_encoder = encoder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the signature of the 120 s window centred on the media's
|
||||
/// midpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The centre is used because the head and tail are the least
|
||||
/// content-specific parts of a release: logos and cold opens at one end,
|
||||
/// credits at the other.
|
||||
/// <para>
|
||||
/// Media shorter than <see cref="AudioSignature.WindowSec"/> yields
|
||||
/// <c>null</c>: the window underflows, so there is no signature — the
|
||||
/// identical rule the extraction producer applies, since diverging here
|
||||
/// would break exactly the short items most likely to be misidentified
|
||||
/// (JR-044, whose remaining half — applying no sync offset — belongs with
|
||||
/// signature matching).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="path">Path of the media file.</param>
|
||||
/// <param name="runtimeSeconds">The item's runtime, as Jellyfin knows it.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <c>v1:</c>-prefixed signature, or <c>null</c> for short media, media
|
||||
/// with no usable audio, and any decode failure.
|
||||
/// </returns>
|
||||
public Task<string?> ComputeAsync(
|
||||
string path,
|
||||
double runtimeSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
=> ComputeWithEncoderAsync(_encoder?.EncoderPath, path, runtimeSeconds, _logger, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ComputeAsync"/> with the FFmpeg binary named explicitly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Internal so the golden-fixture test can drive the real decode path with
|
||||
/// whatever FFmpeg the machine has, rather than standing up a fake
|
||||
/// <see cref="IMediaEncoder"/> — a stub of a thirty-member interface would
|
||||
/// be the larger risk of the two, and it is the decode that is under test.
|
||||
/// </remarks>
|
||||
/// <param name="encoderPath">Path of the FFmpeg binary to run.</param>
|
||||
/// <param name="path">Path of the media file.</param>
|
||||
/// <param name="runtimeSeconds">The item's runtime, as Jellyfin knows it.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The <c>v1:</c>-prefixed signature, or <c>null</c>.</returns>
|
||||
internal static async Task<string?> ComputeWithEncoderAsync(
|
||||
string? encoderPath,
|
||||
string path,
|
||||
double runtimeSeconds,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || runtimeSeconds < AudioSignature.WindowSec)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(encoderPath))
|
||||
{
|
||||
logger.LogDebug("No FFmpeg binary available; skipping the audio signature for {Path}", path);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var samples = await DecodeCentreWindowAsync(encoderPath, path, runtimeSeconds, logger, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (samples is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return AudioSignature.FromMonoSamples(samples);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Audio signature failed for {Path}; continuing without one", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the centre window as mono 32-bit float PCM at 11025 Hz.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stream is truncated to exactly
|
||||
/// <see cref="AudioSignature.WindowSamples"/> samples, so the frame count is
|
||||
/// the same for every input rather than wobbling with seek granularity or a
|
||||
/// resampler tail.
|
||||
/// <para>
|
||||
/// No <c>-map</c> is given: FFmpeg's default audio selection is the same
|
||||
/// "best stream" choice the pipeline makes with
|
||||
/// <c>av_find_best_stream</c>, and naming <c>0:a:0</c> instead would pick a
|
||||
/// different track from the pipeline's on any file whose first audio stream
|
||||
/// is not its main one — a commentary track, say.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<float[]?> DecodeCentreWindowAsync(
|
||||
string encoderPath,
|
||||
string path,
|
||||
double runtimeSeconds,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var start = (runtimeSeconds / 2.0) - (AudioSignature.WindowSec / 2.0);
|
||||
if (start < 0.0)
|
||||
{
|
||||
start = 0.0;
|
||||
}
|
||||
|
||||
var startArgument = start.ToString("0.000", CultureInfo.InvariantCulture);
|
||||
var windowArgument = AudioSignature.WindowSec.ToString("0.000", CultureInfo.InvariantCulture);
|
||||
var rateArgument = AudioSignature.SampleRate.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = encoderPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = false,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
// Input seeking, so FFmpeg does not decode the whole file to reach the
|
||||
// middle of it. Accurate by default: it seeks to the preceding keyframe
|
||||
// and discards the excess, which is what the pipeline does by hand.
|
||||
startInfo.ArgumentList.Add("-ss");
|
||||
startInfo.ArgumentList.Add(startArgument);
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-t");
|
||||
startInfo.ArgumentList.Add(windowArgument);
|
||||
startInfo.ArgumentList.Add("-vn");
|
||||
startInfo.ArgumentList.Add("-sn");
|
||||
startInfo.ArgumentList.Add("-dn");
|
||||
startInfo.ArgumentList.Add("-ac");
|
||||
startInfo.ArgumentList.Add("1");
|
||||
startInfo.ArgumentList.Add("-ar");
|
||||
startInfo.ArgumentList.Add(rateArgument);
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("f32le");
|
||||
startInfo.ArgumentList.Add("-");
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
if (!process.Start())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read stderr concurrently: it is redirected, so leaving it unread
|
||||
// would deadlock the moment FFmpeg filled the pipe.
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
var bytes = await ReadWindowAsync(process.StandardOutput.BaseStream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var error = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (bytes.Length < AudioSignature.FrameSize * sizeof(float))
|
||||
{
|
||||
// No audio stream, an unreadable file, or a runtime Jellyfin
|
||||
// knows but the container does not support seeking into.
|
||||
logger.LogDebug(
|
||||
"FFmpeg returned {Bytes} bytes of audio for {Path}: {Error}",
|
||||
bytes.Length,
|
||||
path,
|
||||
error);
|
||||
return null;
|
||||
}
|
||||
|
||||
var samples = new float[bytes.Length / sizeof(float)];
|
||||
for (var i = 0; i < samples.Length; i++)
|
||||
{
|
||||
samples[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * sizeof(float)));
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Exited between the check and the kill.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadWindowAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var wanted = AudioSignature.WindowSamples * sizeof(float);
|
||||
var buffer = new byte[wanted];
|
||||
var filled = 0;
|
||||
|
||||
while (filled < wanted)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(filled, wanted - filled), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
filled += read;
|
||||
}
|
||||
|
||||
if (filled == wanted)
|
||||
{
|
||||
return buffer;
|
||||
}
|
||||
|
||||
var truncated = new byte[filled];
|
||||
Array.Copy(buffer, truncated, filled);
|
||||
return truncated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Registers JRay's overlay script with the
|
||||
/// <see href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation">File Transformation</see>
|
||||
/// plugin, which rewrites <c>index.html</c> as it is served instead of
|
||||
/// modifying the file on disk. This is non-destructive and composes with
|
||||
/// other plugins that patch the same file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File Transformation is referenced by reflection (rather than a NuGet
|
||||
/// package reference) so JRay still loads when it isn't installed. JRay must
|
||||
/// never bundle the assembly: a bundled copy would sit in a different
|
||||
/// <c>AssemblyLoadContext</c> from the real one, which is precisely the failure
|
||||
/// the reflection integration exists to avoid.
|
||||
///
|
||||
/// This is the only route by which JRay reaches the web client. There is no
|
||||
/// on-disk fallback (JR-021), so when registration fails the overlay is simply
|
||||
/// disabled — which is why both failure paths log a warning naming the missing
|
||||
/// plugin rather than quietly degrading.
|
||||
/// </remarks>
|
||||
// TRACES: JR-020, JR-023 | PR-004
|
||||
public static class FileTransformationRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// The marker comment written alongside the injected script tag, used to
|
||||
/// keep the transformation idempotent.
|
||||
/// </summary>
|
||||
internal const string Marker = "<!-- jray-overlay -->";
|
||||
|
||||
/// <summary>
|
||||
/// Repository manifest an admin adds to install the dependency. Surfaced on
|
||||
/// the configuration page rather than only in the log, since that is where
|
||||
/// it can be acted on.
|
||||
/// </summary>
|
||||
public const string ManifestUrl = "https://www.iamparadox.dev/jellyfin/plugins/manifest.json";
|
||||
|
||||
private const string PluginInterfaceTypeName = "Jellyfin.Plugin.FileTransformation.PluginInterface";
|
||||
private const string RegisterMethodName = "RegisterTransformation";
|
||||
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
||||
private const string BodyClose = "</body>";
|
||||
|
||||
/// <summary>
|
||||
/// Stable id for JRay's index.html transformation. File Transformation
|
||||
/// keys registrations on this, so re-registering replaces rather than
|
||||
/// duplicates.
|
||||
/// </summary>
|
||||
private static readonly Guid TransformationId = Guid.Parse("2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90");
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to register JRay's index.html transformation with the File
|
||||
/// Transformation plugin.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <returns><see langword="true"/> if the transformation was registered;
|
||||
/// <see langword="false"/> if the File Transformation plugin is not
|
||||
/// installed or its interface could not be invoked.</returns>
|
||||
public static bool TryRegister(ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
try
|
||||
{
|
||||
var registerMethod = ResolveRegisterMethod();
|
||||
if (registerMethod is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"JRay: the File Transformation plugin was not found, so the pause overlay is "
|
||||
+ "disabled. JRay does not modify index.html on disk and has no fallback. "
|
||||
+ "Install it from {ManifestUrl} to enable the overlay; every other JRay "
|
||||
+ "feature is unaffected.",
|
||||
ManifestUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = BuildPayload(registerMethod);
|
||||
registerMethod.Invoke(null, [payload]);
|
||||
|
||||
logger.LogInformation("JRay: registered index.html transformation with the File Transformation plugin.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is TargetInvocationException or InvalidOperationException or JsonException or MissingMethodException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"JRay: the File Transformation plugin is present but registration failed, so the "
|
||||
+ "pause overlay is disabled. JRay does not modify index.html on disk and has no "
|
||||
+ "fallback. Every other JRay feature is unaffected.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transformation callback invoked by the File Transformation plugin.
|
||||
/// It is resolved by name via reflection, so the signature (public,
|
||||
/// static, single payload parameter, returns <see cref="string"/>) must
|
||||
/// not change.
|
||||
/// </summary>
|
||||
/// <param name="payload">The current state of the file being served.</param>
|
||||
/// <returns>The transformed file contents.</returns>
|
||||
public static string TransformIndexHtml(TransformationPayload payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var contents = payload.Contents ?? string.Empty;
|
||||
if (!Plugin.OverlayEnabled || contents.Contains(Marker, StringComparison.Ordinal))
|
||||
{
|
||||
return contents;
|
||||
}
|
||||
|
||||
var index = contents.LastIndexOf(BodyClose, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
return contents;
|
||||
}
|
||||
|
||||
return contents[..index]
|
||||
+ ScriptTag
|
||||
+ Marker
|
||||
+ "\n"
|
||||
+ contents[index..];
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveRegisterMethod()
|
||||
{
|
||||
var assembly = AssemblyLoadContext.All
|
||||
.SelectMany(context => context.Assemblies)
|
||||
.FirstOrDefault(candidate => candidate.FullName?.Contains(".FileTransformation", StringComparison.Ordinal) ?? false);
|
||||
|
||||
return assembly?
|
||||
.GetType(PluginInterfaceTypeName)?
|
||||
.GetMethod(RegisterMethodName, BindingFlags.Public | BindingFlags.Static);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the registration payload. File Transformation expects a
|
||||
/// Newtonsoft <c>JObject</c>, which JRay does not reference, so the
|
||||
/// payload is serialized to JSON and parsed back through the type the
|
||||
/// target method actually declares.
|
||||
/// </summary>
|
||||
private static object BuildPayload(MethodInfo registerMethod)
|
||||
{
|
||||
// File Transformation matches this against Assembly.FullName exactly,
|
||||
// so it must be the full display name, not the short name.
|
||||
var assemblyName = typeof(FileTransformationRegistration).Assembly.FullName
|
||||
?? throw new InvalidOperationException("JRay assembly has no name.");
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = TransformationId.ToString("D", CultureInfo.InvariantCulture),
|
||||
fileNamePattern = "index.html",
|
||||
callbackAssembly = assemblyName,
|
||||
callbackClass = typeof(FileTransformationRegistration).FullName,
|
||||
callbackMethod = nameof(TransformIndexHtml)
|
||||
});
|
||||
|
||||
var payloadType = registerMethod.GetParameters().FirstOrDefault()?.ParameterType
|
||||
?? throw new InvalidOperationException("RegisterTransformation has no parameters.");
|
||||
|
||||
var parseMethod = payloadType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, [typeof(string)])
|
||||
?? throw new InvalidOperationException($"Cannot construct File Transformation payload of type '{payloadType.FullName}'.");
|
||||
|
||||
return parseMethod.Invoke(null, [json])
|
||||
?? throw new InvalidOperationException("File Transformation payload parsed to null.");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches actor-timeline manifests from the configured public servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Satisfies <c>JRay-public-server</c> UR-007: the plugin queries a configurable,
|
||||
/// <b>ordered</b> list of servers, and the first result clearing the configured
|
||||
/// match tier wins.
|
||||
/// </remarks>
|
||||
// TRACES: JR-025 | PR-006
|
||||
public interface IManifestExchangeClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a movie manifest from the first server that has an acceptable one.
|
||||
/// </summary>
|
||||
/// <param name="servers">The configured servers, in trust order.</param>
|
||||
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
|
||||
/// <param name="query">Identity and cut parameters for the local item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The accepted manifest, or null when no server had one.</returns>
|
||||
Task<ManifestFetchOutcome?> FetchMovieAsync(
|
||||
IReadOnlyList<ManifestServer> servers,
|
||||
MatchTier minimumTier,
|
||||
TitleQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single episode manifest.
|
||||
/// </summary>
|
||||
/// <param name="servers">The configured servers, in trust order.</param>
|
||||
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
|
||||
/// <param name="query">Identity and cut parameters for the local item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The accepted manifest, or null when no server had one.</returns>
|
||||
Task<ManifestFetchOutcome?> FetchEpisodeAsync(
|
||||
IReadOnlyList<ManifestServer> servers,
|
||||
MatchTier minimumTier,
|
||||
TitleQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Per-server reachability and last error, for the configuration page.
|
||||
/// </summary>
|
||||
/// <param name="servers">The configured servers.</param>
|
||||
/// <returns>One status entry per configured server.</returns>
|
||||
IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -14,6 +14,11 @@ 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately outside the media library filesystem, so a worker that cannot
|
||||
/// write beside the media file is not a second-class producer.
|
||||
/// </remarks>
|
||||
// TRACES: JR-009, JR-010 | PR-004
|
||||
public sealed class ManagedTruthStore : IManagedTruthStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -44,18 +49,36 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
|
||||
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(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}", path);
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.");
|
||||
@@ -68,11 +91,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))
|
||||
{
|
||||
@@ -93,4 +157,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Aligns a freshly fetched manifest to the local file before its windows are
|
||||
/// stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why the client re-derives an offset the server already sent.</b> The
|
||||
/// server has never seen the local file. Its offset is a claim about a runtime
|
||||
/// it was told, so it can only ever be a runtime-difference inference. A local
|
||||
/// alignment compares the manifest's own audio signature against the file the
|
||||
/// windows will actually be drawn over, which is the authoritative comparison —
|
||||
/// and it needs no round trip, so no signature leaves the instance. Where the
|
||||
/// two disagree, the local one wins.
|
||||
/// <para>
|
||||
/// This is what jRay's specification means by matching being "a consumer
|
||||
/// concern": the server never rewrites a manifest, so one stored manifest serves
|
||||
/// every trim of the same cut, and each client shifts it to its own timebase
|
||||
/// (JR-030).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Degradation, not failure.</b> Signatures switched off, an item under the
|
||||
/// 120 s window, a manifest with no signature, a missing FFmpeg binary, a decode
|
||||
/// error, or two signatures that simply do not match — every one of these falls
|
||||
/// back to the server's offset. A signature is an enhancement to cut matching; a
|
||||
/// missing one costs a tier and must never be able to break a fetch.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-047 | SR-003
|
||||
public class ManifestAligner
|
||||
{
|
||||
private readonly AudioSignatureService _signatures;
|
||||
private readonly ILogger<ManifestAligner> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestAligner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="signatures">Computes the local file's audio signature.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ManifestAligner(AudioSignatureService signatures, ILogger<ManifestAligner> logger)
|
||||
{
|
||||
_signatures = signatures;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides which offset to apply, given a local signature that has already
|
||||
/// been computed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="AlignAsync"/> so the decision is testable without
|
||||
/// an FFmpeg binary or a media file: everything interesting happens here,
|
||||
/// and the caller only supplies the two strings.
|
||||
/// </remarks>
|
||||
/// <param name="localSignature">The local file's signature, or <c>null</c>.</param>
|
||||
/// <param name="manifestSignature">The manifest's signature, or <c>null</c>.</param>
|
||||
/// <param name="localRuntimeSec">Local file runtime, in seconds.</param>
|
||||
/// <param name="manifestRuntimeSec">Runtime the manifest records, in seconds.</param>
|
||||
/// <param name="serverTier">The tier the server reported.</param>
|
||||
/// <param name="serverOffsetSec">The offset the server reported.</param>
|
||||
/// <returns>What to apply, and how it was decided.</returns>
|
||||
public static TruthAlignment Resolve(
|
||||
string? localSignature,
|
||||
string? manifestSignature,
|
||||
double localRuntimeSec,
|
||||
double manifestRuntimeSec,
|
||||
MatchTier serverTier,
|
||||
double serverOffsetSec)
|
||||
{
|
||||
var alignment = new TruthAlignment
|
||||
{
|
||||
Source = AlignmentSource.Server,
|
||||
Tier = serverTier,
|
||||
OffsetSec = serverOffsetSec,
|
||||
ServerTier = serverTier,
|
||||
ServerOffsetSec = serverOffsetSec,
|
||||
LocalSignature = localSignature,
|
||||
};
|
||||
|
||||
// "No alignment was possible" and "the audio does not match" are very
|
||||
// different things to tell someone, and `Compare` returns null for both.
|
||||
// A comparison is only possible when both items clear the 120 s window
|
||||
// (JR-044) and both signatures parse as v1 (JR-045) — a `v2:` signature
|
||||
// from a future producer is un-comparable, not a mismatch. Separating
|
||||
// them here is what keeps a 90-second extra from being reported as
|
||||
// content that disagrees with its own manifest.
|
||||
var comparable = localSignature is not null
|
||||
&& manifestSignature is not null
|
||||
&& localRuntimeSec >= AudioSignature.WindowSec
|
||||
&& manifestRuntimeSec >= AudioSignature.WindowSec
|
||||
&& AudioSignatureMatcher.TryParseFrames(localSignature) is not null
|
||||
&& AudioSignatureMatcher.TryParseFrames(manifestSignature) is not null;
|
||||
|
||||
if (!comparable)
|
||||
{
|
||||
return alignment;
|
||||
}
|
||||
|
||||
var match = AudioSignatureMatcher.Compare(
|
||||
localSignature, manifestSignature, localRuntimeSec, manifestRuntimeSec);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
// Both sides had a signature and they did not align at any tier. The
|
||||
// server's offset still applies — the audio may legitimately differ,
|
||||
// and a signature must not break a fetch — but this is the strongest
|
||||
// available hint that the manifest describes different content, so
|
||||
// it is recorded rather than silently discarded.
|
||||
alignment.Source = AlignmentSource.LocalMismatch;
|
||||
return alignment;
|
||||
}
|
||||
|
||||
alignment.Source = AlignmentSource.Local;
|
||||
alignment.Tier = match.Value.Tier;
|
||||
alignment.OffsetSec = match.Value.OffsetSec;
|
||||
alignment.Score = match.Value.Score;
|
||||
alignment.OffsetFrames = match.Value.OffsetFrames;
|
||||
return alignment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the local file's signature if it can, then resolves the
|
||||
/// alignment.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Path of the local media file.</param>
|
||||
/// <param name="localRuntimeSec">Local file runtime, in seconds.</param>
|
||||
/// <param name="manifest">The fetched manifest.</param>
|
||||
/// <param name="serverTier">The tier the server reported.</param>
|
||||
/// <param name="serverOffsetSec">The offset the server reported.</param>
|
||||
/// <param name="computeSignatures">Whether signatures are enabled in configuration.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>What to apply, and how it was decided.</returns>
|
||||
public async Task<TruthAlignment> AlignAsync(
|
||||
string mediaPath,
|
||||
double localRuntimeSec,
|
||||
Jmanifest manifest,
|
||||
MatchTier serverTier,
|
||||
double serverOffsetSec,
|
||||
bool computeSignatures,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var manifestSignature = manifest.Cut?.AudioSignature;
|
||||
string? localSignature = null;
|
||||
|
||||
// The decode is the expensive half, so it is skipped outright when it
|
||||
// could not change the answer: no manifest signature to compare against
|
||||
// means no local alignment is possible.
|
||||
if (computeSignatures && manifestSignature is not null && !string.IsNullOrEmpty(mediaPath))
|
||||
{
|
||||
localSignature = await _signatures
|
||||
.ComputeAsync(mediaPath, localRuntimeSec, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var alignment = Resolve(
|
||||
localSignature,
|
||||
manifestSignature,
|
||||
localRuntimeSec,
|
||||
manifest.Cut?.RuntimeSec ?? 0.0,
|
||||
serverTier,
|
||||
serverOffsetSec);
|
||||
|
||||
switch (alignment.Source)
|
||||
{
|
||||
case AlignmentSource.Local:
|
||||
_logger.LogInformation(
|
||||
"Local audio alignment for {Path}: score {Score:F3}, {Frames} frames, offset {Offset:F3}s at tier {Tier} (server said {ServerOffset:F3}s at {ServerTier})",
|
||||
mediaPath,
|
||||
alignment.Score,
|
||||
alignment.OffsetFrames,
|
||||
alignment.OffsetSec,
|
||||
alignment.Tier,
|
||||
alignment.ServerOffsetSec,
|
||||
alignment.ServerTier);
|
||||
break;
|
||||
|
||||
case AlignmentSource.LocalMismatch:
|
||||
_logger.LogWarning(
|
||||
"Audio signatures for {Path} did not align with the fetched manifest; applying the server's {Offset:F3}s at {Tier}. This may be a different cut, a different language track, or a heavy re-encode.",
|
||||
mediaPath,
|
||||
alignment.OffsetSec,
|
||||
alignment.Tier);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogDebug(
|
||||
"No local alignment for {Path}; applying the server's {Offset:F3}s at {Tier}",
|
||||
mediaPath,
|
||||
alignment.OffsetSec,
|
||||
alignment.Tier);
|
||||
break;
|
||||
}
|
||||
|
||||
return alignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a fetched manifest into the truth file the plugin stores.
|
||||
/// </summary>
|
||||
// TRACES: JR-030 | SR-002, SR-003
|
||||
public static class ManifestConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a truth file from a manifest, shifting every window by
|
||||
/// <paramref name="offsetSec"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The offset is applied here, once, at store time.</b> The server returns
|
||||
/// it and the client applies it, so a single stored manifest serves every
|
||||
/// trim of the same cut without ever being rewritten upstream. Applying it on
|
||||
/// the way in means the stored truth is always in the local file's own
|
||||
/// timebase, so the overlay and the <c>jray?t=</c> query need no offset
|
||||
/// awareness at read time — the alternative would put the same correction in
|
||||
/// every reader, forever, and one of them would eventually forget.
|
||||
/// <para>
|
||||
/// Windows are shifted, never reshaped: a window is a claim about scene
|
||||
/// membership (SR-002), so merging or trimming would answer a different
|
||||
/// question than the one the extraction pipeline answered.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="manifest">The validated manifest.</param>
|
||||
/// <param name="offsetSec">Seconds to add to every window.</param>
|
||||
/// <param name="mediaPath">Local media path, recorded informationally.</param>
|
||||
/// <returns>The truth file to store.</returns>
|
||||
public static TruthFile ToTruthFile(Jmanifest manifest, double offsetSec, string mediaPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var truth = new TruthFile
|
||||
{
|
||||
SchemaVersion = TruthSchema.SupportedVersion,
|
||||
Movie = mediaPath ?? string.Empty,
|
||||
};
|
||||
|
||||
// Provenance is carried across rather than flattened. The two blocks
|
||||
// have the same shape by design (JR-002), so anything the server knew
|
||||
// about how a manifest was produced survives into the stored truth.
|
||||
if (manifest.Extraction is { } extraction)
|
||||
{
|
||||
truth.Extraction = new TruthExtraction
|
||||
{
|
||||
SampleFps = extraction.SampleFps,
|
||||
ExtinctionSec = extraction.ExtinctionSec,
|
||||
PipelineVersion = extraction.PipelineVersion,
|
||||
GallerySize = extraction.GallerySize,
|
||||
GalleryScope = extraction.GalleryScope,
|
||||
};
|
||||
}
|
||||
|
||||
// The cut is the *manifest's*, not the local file's: it records the
|
||||
// encode the timings were measured against, which is what makes the
|
||||
// applied offset interpretable later. Recording the local runtime here
|
||||
// instead would erase the very discrepancy the offset corrects.
|
||||
if (manifest.Cut is { } cut)
|
||||
{
|
||||
truth.Cut = new TruthCut
|
||||
{
|
||||
RuntimeSec = cut.RuntimeSec,
|
||||
AudioSignature = cut.AudioSignature,
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var actor in manifest.Actors)
|
||||
{
|
||||
var converted = new TruthActor
|
||||
{
|
||||
Name = actor.Name ?? string.Empty,
|
||||
ImdbId = actor.ImdbId ?? string.Empty,
|
||||
TmdbId = actor.TmdbId ?? string.Empty,
|
||||
};
|
||||
|
||||
foreach (var scene in actor.Scenes)
|
||||
{
|
||||
// Clamped at zero: a negative offset on an early window would
|
||||
// otherwise produce a start before the file begins, which no
|
||||
// reader can index.
|
||||
var start = Math.Max(0, scene.Start + offsetSec);
|
||||
var end = Math.Max(start, scene.End + offsetSec);
|
||||
converted.Scenes.Add(new TruthScene
|
||||
{
|
||||
Start = start,
|
||||
End = end,
|
||||
|
||||
// Belief and route survive the conversion. They are what a
|
||||
// consumer needs to know how far to trust a window, and
|
||||
// dropping them here would silently downgrade every fetched
|
||||
// manifest against a locally extracted one.
|
||||
Belief = scene.Belief,
|
||||
Route = scene.Route,
|
||||
});
|
||||
}
|
||||
|
||||
truth.Actors.Add(converted);
|
||||
}
|
||||
|
||||
return truth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A short, human-readable description of how a manifest matched, for the
|
||||
/// UI to show as a caveat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>loose</c> match should surface as a caveat rather than being applied
|
||||
/// silently: it means the runtimes differ by up to 30 seconds, which is
|
||||
/// usually a different trim of the same cut but is not guaranteed to be.
|
||||
/// </remarks>
|
||||
/// <param name="tier">The tier achieved.</param>
|
||||
/// <param name="offsetSec">The offset applied.</param>
|
||||
/// <param name="alignment">How the offset was arrived at, when known.</param>
|
||||
/// <returns>A caveat string, or null when the match needs no explanation.</returns>
|
||||
public static string? DescribeCaveat(MatchTier tier, double offsetSec, TruthAlignment? alignment = null)
|
||||
{
|
||||
// Ranked before the tier, because it is the stronger statement: the
|
||||
// server's tier says the runtimes are compatible, while a signature
|
||||
// mismatch says the audio itself is not. The second outranks the first.
|
||||
if (alignment?.Source == AlignmentSource.LocalMismatch)
|
||||
{
|
||||
return "This file's audio does not match the fetched manifest — it may be a different cut, "
|
||||
+ "a different language track, or a heavy re-encode. Timings may be wrong.";
|
||||
}
|
||||
|
||||
if (tier == MatchTier.Loose)
|
||||
{
|
||||
return "Matched loosely — the runtime differs from this server's copy, so timings may drift.";
|
||||
}
|
||||
|
||||
if (Math.Abs(offsetSec) > 0.001)
|
||||
{
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Matched by audio content and shifted by {offsetSec:0.##}s to align with this file.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches actor-timeline manifests from the configured servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Servers are an <b>ordered list</b>, and order is the user's trust ranking made
|
||||
/// explicit: for a fetch, servers are tried in order and the <i>first acceptable</i>
|
||||
/// result wins — acceptable meaning it clears the configured match tier.
|
||||
/// <para>
|
||||
/// First-match rather than best-match is deliberate. Querying every server for
|
||||
/// every item multiplies egress, leaks the library to more parties, and the
|
||||
/// ordering already encodes which source the admin prefers. Each configured
|
||||
/// server multiplies the privacy exposure described in the server spec §9, so
|
||||
/// later servers are queried only for what earlier ones lacked.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-025, JR-029, JR-030, JR-037 | PR-005, PR-006
|
||||
public class ManifestExchangeClient : IManifestExchangeClient, IDisposable
|
||||
{
|
||||
/// <summary>Server spec §9: a single manifest response is capped at 2 MiB.</summary>
|
||||
public const long MaxManifestBytes = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Server spec §9: a bundle response is capped at 25 MiB.</summary>
|
||||
public const long MaxBundleBytes = 25L * 1024 * 1024;
|
||||
|
||||
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long a server that failed is skipped for, doubling each consecutive
|
||||
/// failure. One dead server must never stall a library sweep.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BaseBackoff = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<ManifestExchangeClient> _logger;
|
||||
private readonly ConcurrentDictionary<string, ServerHealth> _health = new(StringComparer.Ordinal);
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger)
|
||||
: this(logger, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class
|
||||
/// with an injected transport, for testing.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="httpClient">Transport to use, or null to build the default.</param>
|
||||
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger, HttpClient? httpClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_ownsClient = httpClient is null;
|
||||
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
ConnectTimeout = ConnectTimeout,
|
||||
// Certificate validation is never disabled: a plaintext or
|
||||
// unverified server would let any network intermediary rewrite
|
||||
// actor overlays.
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All,
|
||||
})
|
||||
{
|
||||
Timeout = ReadTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ManifestFetchOutcome?> FetchMovieAsync(
|
||||
IReadOnlyList<ManifestServer> servers,
|
||||
MatchTier minimumTier,
|
||||
TitleQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(servers);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
foreach (var server in Eligible(servers))
|
||||
{
|
||||
var url = BuildUrl(server, "manifests/movie", query);
|
||||
if (url is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (outcome is not null)
|
||||
{
|
||||
// First acceptable result wins — no further servers are queried,
|
||||
// which is what bounds the privacy exposure.
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ManifestFetchOutcome?> FetchEpisodeAsync(
|
||||
IReadOnlyList<ManifestServer> servers,
|
||||
MatchTier minimumTier,
|
||||
TitleQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(servers);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
foreach (var server in Eligible(servers))
|
||||
{
|
||||
var url = BuildUrl(server, "manifests/episode", query);
|
||||
if (url is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (outcome is not null)
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(servers);
|
||||
|
||||
return servers.Select(s =>
|
||||
{
|
||||
_health.TryGetValue(s.Url, out var h);
|
||||
return new ServerStatus
|
||||
{
|
||||
Url = s.Url,
|
||||
Name = s.Name,
|
||||
Enabled = s.Enabled,
|
||||
Reachable = h is null || h.ConsecutiveFailures == 0,
|
||||
LastError = h?.LastError,
|
||||
SkippedUntil = h?.SkipUntil,
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Servers that are enabled and not currently in backoff, in configured order.
|
||||
/// </summary>
|
||||
private IEnumerable<ManifestServer> Eligible(IReadOnlyList<ManifestServer> servers)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var s in servers)
|
||||
{
|
||||
if (!s.Enabled || string.IsNullOrWhiteSpace(s.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_health.TryGetValue(s.Url, out var h) && h.SkipUntil > now)
|
||||
{
|
||||
_logger.LogDebug("Skipping {Url} until {Until} after {Failures} failures", s.Url, h.SkipUntil, h.ConsecutiveFailures);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return s;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ManifestFetchOutcome?> TryFetchOneAsync(
|
||||
ManifestServer server,
|
||||
Uri url,
|
||||
TitleQuery query,
|
||||
MatchTier minimumTier,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _http
|
||||
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// Not an error: this server simply does not hold it. The next
|
||||
// server in the list gets a turn.
|
||||
RecordSuccess(server.Url);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
RecordFailure(server.Url, $"HTTP {(int)response.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await ReadCappedAsync(response, MaxManifestBytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (json is null)
|
||||
{
|
||||
RecordFailure(server.Url, "response exceeded the size cap");
|
||||
return null;
|
||||
}
|
||||
|
||||
var body = JsonSerializer.Deserialize<ManifestFetchResponse>(json);
|
||||
RecordSuccess(server.Url);
|
||||
|
||||
if (body?.Manifest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tier = ParseTier(body.Match);
|
||||
if (tier is null || tier < minimumTier)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"{Url} matched at {Tier}, below the configured minimum {Minimum}",
|
||||
server.Url,
|
||||
body.Match,
|
||||
minimumTier);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ManifestValidator.TryValidate(body.Manifest, query.RuntimeSec, out var error))
|
||||
{
|
||||
// A manifest is never trusted merely because a server served it.
|
||||
_logger.LogWarning("Rejected manifest from {Url}: {Error}", server.Url, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ManifestFetchOutcome
|
||||
{
|
||||
ServerUrl = server.Url,
|
||||
Tier = tier.Value,
|
||||
OffsetSec = body.OffsetSec,
|
||||
Manifest = body.Manifest,
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
// A slow, unreachable or nonsense-returning server is skipped and
|
||||
// backed off; it must never stall the sweep or fail the whole fetch.
|
||||
RecordFailure(server.Url, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a response body, aborting once it exceeds <paramref name="cap"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Capped <b>while streaming</b> rather than after buffering: a hostile
|
||||
/// server can declare any <c>Content-Length</c> it likes, so reading to
|
||||
/// completion and then measuring is exactly the denial-of-service primitive
|
||||
/// the cap exists to prevent.
|
||||
/// </remarks>
|
||||
// TRACES: JR-028 | SR-004
|
||||
private static async Task<string?> ReadCappedAsync(
|
||||
HttpResponseMessage response,
|
||||
long cap,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The declared length is a cheap early rejection, never the enforcement.
|
||||
if (response.Content.Headers.ContentLength is { } declared && declared > cap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var buffer = new byte[8192];
|
||||
using var accumulated = new System.IO.MemoryStream();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (accumulated.Length + read > cap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await accumulated.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(accumulated.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fetch URL, or null when the server's URL is unusable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>HTTPS is required for anything that is not loopback.</b> A plaintext
|
||||
/// community server would let any network intermediary rewrite actor
|
||||
/// overlays, and the overlay is displayed to the user as fact.
|
||||
/// </remarks>
|
||||
/// <param name="server">The configured server.</param>
|
||||
/// <param name="path">API path below <c>/api/v1/</c>.</param>
|
||||
/// <param name="query">Identity and cut parameters.</param>
|
||||
/// <returns>The URL to request, or null when the server URL is unusable.</returns>
|
||||
internal static Uri? BuildUrl(ManifestServer server, string path, TitleQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
if (!Uri.TryCreate(server.Url, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IsTransportAcceptable(baseUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var q = query.ToQueryString();
|
||||
var trimmed = baseUri.AbsoluteUri.TrimEnd('/');
|
||||
return Uri.TryCreate($"{trimmed}/api/v1/{path}?{q}", UriKind.Absolute, out var built)
|
||||
? built
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the URL may be used: HTTPS anywhere, or HTTP on loopback only.
|
||||
/// </summary>
|
||||
/// <param name="uri">The server base URL.</param>
|
||||
/// <returns><c>true</c> when the transport is acceptable.</returns>
|
||||
internal static bool IsTransportAcceptable(Uri uri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uri);
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttp)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Loopback is exempt because there is no network path to intercept.
|
||||
return uri.IsLoopback;
|
||||
}
|
||||
|
||||
/// <summary>Parses a tier name the server reported.</summary>
|
||||
/// <param name="tier">The tier string.</param>
|
||||
/// <returns>The tier, or null when unrecognised.</returns>
|
||||
internal static MatchTier? ParseTier(string? tier) => tier switch
|
||||
{
|
||||
// `exact` is deliberately absent: the file-hash tier is withdrawn on
|
||||
// legal grounds (see MatchTier). A server cannot report it to us anyway,
|
||||
// since we send no `video_hash` — and if one did, treating it as
|
||||
// unrecognised means the manifest is declined rather than silently
|
||||
// accepted under a tier this plugin has no policy for.
|
||||
"audio" => MatchTier.Audio,
|
||||
"runtime" => MatchTier.Runtime,
|
||||
"loose" => MatchTier.Loose,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private void RecordSuccess(string url) => _health.TryRemove(url, out _);
|
||||
|
||||
private void RecordFailure(string url, string error)
|
||||
{
|
||||
var updated = _health.AddOrUpdate(
|
||||
url,
|
||||
_ => new ServerHealth { ConsecutiveFailures = 1, LastError = error, SkipUntil = DateTimeOffset.UtcNow + BaseBackoff },
|
||||
(_, existing) =>
|
||||
{
|
||||
var failures = existing.ConsecutiveFailures + 1;
|
||||
// Exponential, capped: a server that is down for a day should
|
||||
// not be retried every minute for that whole day.
|
||||
var delayTicks = Math.Min(
|
||||
BaseBackoff.Ticks * (long)Math.Pow(2, Math.Min(failures - 1, 6)),
|
||||
MaxBackoff.Ticks);
|
||||
return new ServerHealth
|
||||
{
|
||||
ConsecutiveFailures = failures,
|
||||
LastError = error,
|
||||
SkipUntil = DateTimeOffset.UtcNow + TimeSpan.FromTicks(delayTicks),
|
||||
};
|
||||
});
|
||||
|
||||
_logger.LogWarning(
|
||||
"Server {Url} failed ({Failures} consecutive): {Error}. Skipping until {Until}",
|
||||
url,
|
||||
updated.ConsecutiveFailures,
|
||||
error,
|
||||
updated.SkipUntil);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the transport when this instance created it.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Whether managed resources should be released.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _ownsClient)
|
||||
{
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ServerHealth
|
||||
{
|
||||
public int ConsecutiveFailures { get; init; }
|
||||
|
||||
public string? LastError { get; init; }
|
||||
|
||||
public DateTimeOffset SkipUntil { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>A manifest accepted from a server.</summary>
|
||||
public class ManifestFetchOutcome
|
||||
{
|
||||
/// <summary>Gets or sets which server supplied it.</summary>
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the cut-match tier achieved.</summary>
|
||||
public MatchTier Tier { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the offset the client must apply to every window.</summary>
|
||||
public double OffsetSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the manifest.</summary>
|
||||
public Jmanifest? Manifest { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Re-validates a manifest received from a server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Every server is untrusted, including the pre-configured community one.</b>
|
||||
/// Everything the server specification guarantees is a property of a *correctly
|
||||
/// operated* server; pointing the plugin at an arbitrary URL inherits none of
|
||||
/// it. So the plugin re-applies client-side what the server applies on upload:
|
||||
/// unknown-shaped data rejected, identifiers format-checked, windows
|
||||
/// bounds-checked against the item's real runtime.
|
||||
/// <para>
|
||||
/// The honest framing for the configuration page is that adding a third-party
|
||||
/// server means trusting its operator not to serve you deliberately wrong actor
|
||||
/// data. These checks bound the damage to bad overlay content; they cannot make
|
||||
/// wrong data right.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-027 | SR-004
|
||||
public static class ManifestValidator
|
||||
{
|
||||
/// <summary>The exchange envelope version this plugin speaks (SR-003).</summary>
|
||||
public const int SupportedJmanifestVersion = 2;
|
||||
|
||||
/// <summary>Server spec §6: no more than 500 actors in one manifest.</summary>
|
||||
public const int MaxActors = 500;
|
||||
|
||||
/// <summary>Server spec §6: no more than 2000 windows for one actor.</summary>
|
||||
public const int MaxScenesPerActor = 2000;
|
||||
|
||||
/// <summary>Server spec §6: no more than 20000 windows in total.</summary>
|
||||
public const int MaxTotalScenes = 20000;
|
||||
|
||||
/// <summary>Server spec §6: names are capped at 200 characters.</summary>
|
||||
public const int MaxNameLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Windows may exceed the measured runtime by this much before being
|
||||
/// rejected, covering rounding and container-duration disagreement.
|
||||
/// </summary>
|
||||
public const double RuntimeToleranceSec = 5.0;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a manifest against the local item's measured runtime.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The manifest as received.</param>
|
||||
/// <param name="localRuntimeSec">
|
||||
/// The runtime of the local file, or null when it is not known. Windows are
|
||||
/// bounds-checked against it when it is available.
|
||||
/// </param>
|
||||
/// <param name="error">The first problem found, naming the offending field.</param>
|
||||
/// <returns><c>true</c> when the manifest is safe to store.</returns>
|
||||
public static bool TryValidate(Jmanifest? manifest, double? localRuntimeSec, out string error)
|
||||
{
|
||||
if (manifest is null)
|
||||
{
|
||||
error = "manifest: absent";
|
||||
return false;
|
||||
}
|
||||
|
||||
// An unknown envelope version is refused, never guessed at (JR-003).
|
||||
// A server one version ahead may have changed the meaning of a field
|
||||
// this plugin thinks it understands.
|
||||
if (manifest.JmanifestVersion != SupportedJmanifestVersion)
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"jmanifest_version: unsupported version {manifest.JmanifestVersion}, expected {SupportedJmanifestVersion}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Identity is null)
|
||||
{
|
||||
error = "identity: absent";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Cut is null || !IsSaneRuntime(manifest.Cut.RuntimeSec))
|
||||
{
|
||||
error = "cut.runtime_sec: absent or not a plausible duration";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Actors.Count == 0)
|
||||
{
|
||||
error = "actors: empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Actors.Count > MaxActors)
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors: more than {MaxActors} entries");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bounds are checked against the *local* file where known, because that
|
||||
// is what the overlay will index into. A window past the end of the file
|
||||
// is not merely useless, it is evidence the manifest is for another cut.
|
||||
var limit = (localRuntimeSec ?? manifest.Cut.RuntimeSec) + RuntimeToleranceSec;
|
||||
|
||||
var total = 0;
|
||||
var seenTmdb = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < manifest.Actors.Count; i++)
|
||||
{
|
||||
var actor = manifest.Actors[i];
|
||||
|
||||
if (actor.Name is { Length: > MaxNameLength })
|
||||
{
|
||||
error = string.Create(CultureInfo.InvariantCulture, $"actors[{i}].name: too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actor.Name is not null && ContainsControlCharacters(actor.Name))
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].name: contains control characters");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actor.TmdbId is { Length: > 0 } tmdb)
|
||||
{
|
||||
if (!IsDigits(tmdb, 9))
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].tmdb_id: not a TMDB id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!seenTmdb.Add(tmdb))
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].tmdb_id: duplicate actor");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (actor.ImdbId is { Length: > 0 } imdb && !IsPersonImdbId(imdb))
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].imdb_id: not an IMDB person id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actor.Scenes.Count > MaxScenesPerActor)
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].scenes: more than {MaxScenesPerActor} entries");
|
||||
return false;
|
||||
}
|
||||
|
||||
total += actor.Scenes.Count;
|
||||
if (total > MaxTotalScenes)
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors: more than {MaxTotalScenes} windows in total");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var j = 0; j < actor.Scenes.Count; j++)
|
||||
{
|
||||
var scene = actor.Scenes[j];
|
||||
if (!IsFinite(scene.Start) || !IsFinite(scene.End))
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].scenes[{j}]: non-finite value");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scene.Start < 0 || scene.End < scene.Start)
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].scenes[{j}]: negative or inverted window");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scene.End > limit)
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].scenes[{j}]: ends beyond the item's runtime");
|
||||
return false;
|
||||
}
|
||||
|
||||
// A posterior outside [0, 1] is not a probability.
|
||||
if (scene.Belief is { } b && (!IsFinite(b) || b < 0 || b > 1))
|
||||
{
|
||||
error = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"actors[{i}].scenes[{j}].belief: outside [0, 1]");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsSaneRuntime(double v) => IsFinite(v) && v > 0 && v < 200_000;
|
||||
|
||||
private static bool IsFinite(double v) => !double.IsNaN(v) && !double.IsInfinity(v);
|
||||
|
||||
private static bool IsDigits(string s, int maxLength) =>
|
||||
s.Length > 0 && s.Length <= maxLength && s.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool IsPersonImdbId(string s) =>
|
||||
s.StartsWith("nm", StringComparison.Ordinal)
|
||||
&& (s.Length == 9 || s.Length == 10)
|
||||
&& s.AsSpan(2).ToString().All(char.IsAsciiDigit);
|
||||
|
||||
/// <summary>
|
||||
/// Control characters are refused outright. The overlay renders names as
|
||||
/// text nodes (JR-024), so markup is already inert, but a bidi override or a
|
||||
/// zero-width joiner can still make a name display as something other than
|
||||
/// what was stored.
|
||||
/// </summary>
|
||||
private static bool ContainsControlCharacters(string s)
|
||||
{
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsControl(c))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Zero-width and bidi-control codepoints.
|
||||
if (c is >= '' and <= ''
|
||||
or >= '' and <= ''
|
||||
or >= '' and <= ''
|
||||
or '')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// reads. All access is synchronised so the work-discovery API and the
|
||||
/// config page can touch it concurrently.
|
||||
/// </summary>
|
||||
// TRACES: JR-016 | PR-003
|
||||
public sealed class MediaPolicyStore : IMediaPolicyStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// hold only one action, the only conflicts possible are across scopes, and
|
||||
/// specificity resolves those.
|
||||
/// </summary>
|
||||
// TRACES: JR-016 | PR-003
|
||||
public static class PolicyResolver
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Answers "which actors are in the scene at time <c>t</c>" from a truth file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the unit that decides presence, so the scene-scoped semantics live
|
||||
/// here rather than being spread through the controller.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>A window is a claim about scene membership, not a recognition event.</b>
|
||||
/// An actor who turns away, is occluded, or is off-camera while the shot cuts to
|
||||
/// whoever they are speaking to is still present. Two windows mean a genuine
|
||||
/// departure and return, not a break in detection — so this code reads windows
|
||||
/// exactly as given and never merges, splits, trims, or reorders them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Bounds are inclusive at both ends, matching the format's definition. That
|
||||
/// makes adjacent windows such as <c>[0,10]</c> and <c>[10,20]</c> both contain
|
||||
/// <c>t = 10</c>; reporting the actor present once is correct, and is not a
|
||||
/// reason to merge the windows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-004, JR-005, JR-006 | SR-002
|
||||
public static class PresenceLookup
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether an actor is present in the scene at <paramref name="t"/>.
|
||||
/// </summary>
|
||||
/// <param name="actor">The actor entry from a truth file.</param>
|
||||
/// <param name="t">The timestamp, in seconds.</param>
|
||||
/// <returns><c>true</c> when any window contains <paramref name="t"/>.</returns>
|
||||
public static bool IsPresentAt(TruthActor actor, double t)
|
||||
{
|
||||
if (actor is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A full scan, deliberately: windows may be numerous, but correctness
|
||||
// must not depend on the producer having honoured the sortedness
|
||||
// guarantee. An early exit on `start > t` would be faster and would
|
||||
// silently under-report the moment one file arrived out of order —
|
||||
// trading a correctness risk for a saving that does not matter at this
|
||||
// scale (see JR-006).
|
||||
foreach (var window in actor.Scenes)
|
||||
{
|
||||
if (window is not null && window.Start <= t && t <= window.End)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists the actors present in the scene at <paramref name="t"/>, in the
|
||||
/// order the truth file lists them.
|
||||
/// </summary>
|
||||
/// <param name="truth">The truth file.</param>
|
||||
/// <param name="t">The timestamp, in seconds.</param>
|
||||
/// <returns>The actors whose windows contain <paramref name="t"/>.</returns>
|
||||
public static IEnumerable<TruthActor> ActorsPresentAt(TruthFile truth, double t)
|
||||
{
|
||||
if (truth is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var actor in truth.Actors)
|
||||
{
|
||||
if (IsPresentAt(actor, t))
|
||||
{
|
||||
yield return actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an actor's windows are sorted by start time, as the
|
||||
/// truth-file format requires of producers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Presence lookup does not depend on this — it is a diagnostic. A file that
|
||||
/// fails it is still read correctly, but it signals a producer bug worth
|
||||
/// surfacing rather than absorbing silently.
|
||||
/// </remarks>
|
||||
/// <param name="actor">The actor entry from a truth file.</param>
|
||||
/// <returns><c>true</c> when every window starts at or after its predecessor.</returns>
|
||||
public static bool WindowsAreSorted(TruthActor actor)
|
||||
{
|
||||
if (actor is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
double previousStart = double.NegativeInfinity;
|
||||
foreach (var window in actor.Scenes)
|
||||
{
|
||||
if (window is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (window.Start < previousStart)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
previousStart = window.Start;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>Per-server reachability, for the configuration page.</summary>
|
||||
public class ServerStatus
|
||||
{
|
||||
/// <summary>Gets or sets the server's base URL.</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the display name.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the server is enabled.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the last attempt succeeded.</summary>
|
||||
public bool Reachable { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the last error seen, if any.</summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>Gets or sets when this server will next be tried.</summary>
|
||||
public DateTimeOffset? SkippedUntil { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>Identity and cut parameters for a fetch.</summary>
|
||||
public class TitleQuery
|
||||
{
|
||||
/// <summary>Gets or sets the movie's TMDB id.</summary>
|
||||
public string? TmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the movie's IMDB id.</summary>
|
||||
public string? ImdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
|
||||
public string? SeriesTmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the season number, for an episode.</summary>
|
||||
public int? Season { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the episode number, for an episode.</summary>
|
||||
public int? Episode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the local file's measured runtime, in seconds.</summary>
|
||||
public double? RuntimeSec { get; set; }
|
||||
|
||||
/// <summary>Renders the query parameters the server expects.</summary>
|
||||
/// <remarks>
|
||||
/// There is deliberately no <c>video_hash</c> parameter. The file-hash tier
|
||||
/// is withdrawn on legal grounds (see <see cref="MatchTier"/>), and omitting
|
||||
/// the field here is what makes that structural: there is nothing to send,
|
||||
/// so no future caller can start sending one by setting a property.
|
||||
/// </remarks>
|
||||
/// <returns>An escaped query string, without the leading '?'.</returns>
|
||||
public string ToQueryString()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
void Add(string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
||||
}
|
||||
}
|
||||
|
||||
Add("tmdb_id", TmdbId);
|
||||
Add("imdb_id", ImdbId);
|
||||
Add("series_tmdb_id", SeriesTmdbId);
|
||||
Add("season", Season?.ToString(CultureInfo.InvariantCulture));
|
||||
Add("episode", Episode?.ToString(CultureInfo.InvariantCulture));
|
||||
Add("runtime_sec", RuntimeSec?.ToString("0.###", CultureInfo.InvariantCulture));
|
||||
return string.Join('&', parts);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// 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);
|
||||
@@ -76,16 +83,68 @@ public sealed class TruthDataService : ITruthDataService
|
||||
using var stream = File.OpenRead(truthPath);
|
||||
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!TruthSchema.IsSupported(truth))
|
||||
{
|
||||
// Named, not silent. A stale v1 sidecar makes an item look
|
||||
// un-extracted, and re-extracting a library that has already
|
||||
// been processed is the most expensive mistake this plugin can
|
||||
// cause a user to make. The log line is what distinguishes the
|
||||
// two states.
|
||||
_logger.LogWarning(
|
||||
"JRay: ignoring truth file {TruthPath} — schema_version {Found}, expected {Expected}. Re-extract this item.",
|
||||
truthPath,
|
||||
truth?.SchemaVersion ?? 0,
|
||||
TruthSchema.SupportedVersion);
|
||||
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
|
||||
return null;
|
||||
}
|
||||
|
||||
_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);
|
||||
// A v1 file also lands here rather than above: `scenes` was a float
|
||||
// pair in v1 and is an object in v2, so it fails to deserialise
|
||||
// before the version can be inspected. Both routes must therefore
|
||||
// name the file, which is why the message below says the same thing.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"JRay: failed to read truth file {TruthPath}. If this is a v1 file, re-extract the item — v1 is no longer read.",
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that decides whether a truth file speaks a version this plugin
|
||||
/// understands.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Flag day, not dual-accept.</b> Only <see cref="SupportedVersion"/> is
|
||||
/// accepted; every other value is refused on every path — sidecar read, managed
|
||||
/// <c>PUT</c>, managed store load, and converted manifest. There is no
|
||||
/// transitional v1 read path.
|
||||
/// <para>
|
||||
/// All three components are pre-release and move together, and the alternative
|
||||
/// carries a cost that outlasts the transition: a v1 read path is the one nobody
|
||||
/// exercises, so it is the one that rots, and it would have to be dragged
|
||||
/// through every subsequent change to the reader.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The consequence is stated rather than discovered:</b> v1 sidecar files
|
||||
/// already on disk stop being read at the bump and stay dark until the library
|
||||
/// is re-extracted. Callers log the rejection naming the file and the version
|
||||
/// found, because an item that looks un-extracted when it is merely stale is the
|
||||
/// failure mode that wastes a user's compute.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This exists as a shared unit because the check used to live only in the
|
||||
/// <c>PUT</c> controller while sidecar reads did not check at all — so the
|
||||
/// format the plugin claimed to require and the format it would actually parse
|
||||
/// were different things.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-003 | SR-003
|
||||
public static class TruthSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// The only <c>schema_version</c> this plugin reads or writes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// System-level (SR-003): the same number means the same format in all three
|
||||
/// repos, and is incremented once per breaking change across all of them.
|
||||
/// </remarks>
|
||||
public const int SupportedVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a truth file speaks the supported version.
|
||||
/// </summary>
|
||||
/// <param name="truth">The parsed truth file, which may be <c>null</c>.</param>
|
||||
/// <returns><c>true</c> only when the version matches exactly.</returns>
|
||||
public static bool IsSupported(TruthFile? truth)
|
||||
=> truth is not null && truth.SchemaVersion == SupportedVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message describing why a truth file was refused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Names both the version found and the version expected. A rejection that
|
||||
/// says only "unsupported" leaves the reader unable to tell a stale file
|
||||
/// from a corrupt one.
|
||||
/// </remarks>
|
||||
/// <param name="found">The version encountered.</param>
|
||||
/// <returns>A message naming both versions.</returns>
|
||||
public static string DescribeRejection(int found)
|
||||
=> string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Unsupported schema_version {found}; expected {SupportedVersion}. Re-extract the item — v1 truth data is no longer read.");
|
||||
}
|
||||
@@ -6,26 +6,40 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
|
||||
/// that loads JRay's pause-overlay script. This follows the pattern used by
|
||||
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
|
||||
/// plugin hook for player-overlay UI.
|
||||
/// Removes the pause-overlay script tag that an earlier version of JRay
|
||||
/// injected into the web client's <c>index.html</c> on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This class cannot inject.</b> JRay reaches the web client only through
|
||||
/// <see cref="FileTransformationRegistration"/>, which rewrites
|
||||
/// <c>index.html</c> as it is served. Patching the file on disk was removed
|
||||
/// rather than left switched off: the patch outlives an uninstall, a web-client
|
||||
/// upgrade discards it silently, and it races any other plugin patching the same
|
||||
/// file. An unreachable write path is also the one nobody runs, which is the one
|
||||
/// a later refactor re-enables by accident.
|
||||
///
|
||||
/// Removal remains because users upgrading from a version that did patch the
|
||||
/// file must not be left with a stale injection pointing at endpoints that have
|
||||
/// since changed. It keys on JRay's own <c><!-- jray-overlay --></c>
|
||||
/// marker, so it is unambiguous and touches nothing another plugin added.
|
||||
/// </remarks>
|
||||
// TRACES: JR-021, JR-022 | PR-004
|
||||
public static class WebClientPatchService
|
||||
{
|
||||
private const string Marker = "<!-- jray-overlay -->";
|
||||
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
||||
private const string Injected = ScriptTag + Marker + "\n</body>";
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the web client's index.html either has or does not have the
|
||||
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
|
||||
/// Removes a legacy on-disk overlay injection, if one is present. Safe to
|
||||
/// call on every startup: it is a no-op once the marker is gone.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">The Jellyfin application paths.</param>
|
||||
/// <param name="enableOverlay">Whether the overlay script should be present.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public static void Apply(IApplicationPaths applicationPaths, bool enableOverlay, ILogger logger)
|
||||
public static void RemoveLegacyPatch(IApplicationPaths applicationPaths, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
|
||||
|
||||
try
|
||||
@@ -37,36 +51,38 @@ public static class WebClientPatchService
|
||||
}
|
||||
|
||||
var html = File.ReadAllText(indexPath);
|
||||
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
|
||||
if (!html.Contains(Marker, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableOverlay && !hasMarker)
|
||||
{
|
||||
var patched = ReplaceLast(html, "</body>", Injected);
|
||||
File.WriteAllText(indexPath, patched);
|
||||
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
|
||||
}
|
||||
else if (!enableOverlay && hasMarker)
|
||||
{
|
||||
var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
|
||||
File.WriteAllText(indexPath, patched);
|
||||
logger.LogInformation("JRay: removed pause-overlay script from {Path}", indexPath);
|
||||
}
|
||||
var cleaned = RemoveInjection(html);
|
||||
File.WriteAllText(indexPath, cleaned);
|
||||
logger.LogInformation(
|
||||
"JRay: removed a pause-overlay script left in {Path} by an earlier version. "
|
||||
+ "JRay no longer modifies this file; the overlay is served through File Transformation.",
|
||||
indexPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogWarning(ex, "JRay: failed to patch web client index.html at {Path}", indexPath);
|
||||
logger.LogWarning(ex, "JRay: failed to remove the legacy overlay patch from {Path}", indexPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReplaceLast(string source, string find, string replace)
|
||||
/// <summary>
|
||||
/// Strips the marked script tag from the document.
|
||||
/// </summary>
|
||||
/// <param name="html">The document contents.</param>
|
||||
/// <returns>The contents with JRay's injection removed.</returns>
|
||||
internal static string RemoveInjection(string html)
|
||||
{
|
||||
var index = source.LastIndexOf(find, StringComparison.Ordinal);
|
||||
if (index < 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
|
||||
return source[..index] + replace + source[(index + find.Length)..];
|
||||
// The trailing newline is stripped with the tag when present, so removing
|
||||
// a patch restores the document byte-for-byte rather than leaving a blank
|
||||
// line that accumulates across upgrades.
|
||||
return html
|
||||
.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/*
|
||||
* JRay pause overlay: lists the cast of the scene the viewer paused in.
|
||||
*
|
||||
* Presence is scene-scoped. An actor who has turned away, is occluded, or is
|
||||
* off-camera during a reverse shot is still in the scene, so this must not be
|
||||
* presented as "who is visible right now" — that is a different, and weaker,
|
||||
* claim than the data makes.
|
||||
*
|
||||
* Every server-supplied string is written with textContent, never innerHTML.
|
||||
* With the manifest exchange these strings may originate from a third-party
|
||||
* server, and this is the one control that holds even if every other check is
|
||||
* bypassed.
|
||||
*
|
||||
* TRACES: JR-005, JR-020, JR-024 | SR-002, SR-004
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -203,6 +218,22 @@
|
||||
overlayEl.style.gap = '12px';
|
||||
overlayEl.style.pointerEvents = 'none';
|
||||
|
||||
// "In this scene", not "on screen now". Presence is scene-scoped, so
|
||||
// this list includes people the camera is not currently pointing at —
|
||||
// without the heading a viewer reads a paused frame and concludes the
|
||||
// overlay is wrong whenever someone is off-camera mid-conversation.
|
||||
var heading = document.createElement('div');
|
||||
heading.className = 'jrayOverlayHeading';
|
||||
heading.textContent = 'In this scene';
|
||||
heading.style.width = '100%';
|
||||
heading.style.color = '#fff';
|
||||
heading.style.opacity = '0.75';
|
||||
heading.style.fontSize = '13px';
|
||||
heading.style.textTransform = 'uppercase';
|
||||
heading.style.letterSpacing = '0.08em';
|
||||
heading.style.textShadow = '0 1px 3px rgba(0,0,0,0.9)';
|
||||
overlayEl.appendChild(heading);
|
||||
|
||||
actors.forEach(function (actor) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'jrayActorCard';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# JRay
|
||||
|
||||
A Jellyfin plugin that brings an actor-overlay (think Amazon "X-Ray") feature to your media: pause a movie and JRay shows you which actors are on screen at that exact moment.
|
||||
A Jellyfin plugin that brings an actor-overlay (think Amazon "X-Ray") feature to your media: pause a movie and JRay shows you which actors are in the scene you paused in.
|
||||
|
||||
JRay reads "truth" files produced offline by the
|
||||
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
||||
pipeline (face detection + recognition) and exposes an API to query which actors
|
||||
are visible at a given timestamp. A small overlay, injected into the Jellyfin web
|
||||
are present in the scene at a given timestamp. A small overlay, injected into the Jellyfin web
|
||||
client, displays the result when you pause playback.
|
||||
|
||||
## Status
|
||||
@@ -25,10 +25,29 @@ https://gitea.tourolle.paris/dtourolle/jRay/raw/branch/master/manifest.json
|
||||
|
||||
Then install "JRay" from the plugin catalog and restart Jellyfin.
|
||||
|
||||
### Required for the overlay: File Transformation
|
||||
|
||||
JRay's pause overlay needs a script tag in the web client's `index.html`.
|
||||
Install [File Transformation](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation)
|
||||
(repository `https://www.iamparadox.dev/jellyfin/plugins/manifest.json`) **before**
|
||||
installing JRay. It rewrites the page as it is served, so the file on disk is
|
||||
never touched — that survives server upgrades and coexists with other plugins
|
||||
patching the same file.
|
||||
|
||||
**There is no fallback.** JRay never edits `index.html` on disk: a patch there
|
||||
outlives an uninstall, is silently discarded by a web-client upgrade, and races
|
||||
any other plugin touching the file. Without File Transformation the pause
|
||||
overlay is simply disabled — every other JRay feature works normally, and the
|
||||
plugin's configuration page tells you what is missing and how to install it.
|
||||
|
||||
Upgrading from an older JRay that did patch `index.html`? It removes its own
|
||||
patch on startup, so there is nothing to clean up by hand. See
|
||||
[SPEC.md](SPEC.md) JR-021/JR-022.
|
||||
|
||||
## Features
|
||||
|
||||
- **Pause overlay** — pause a movie or episode in the web client and see the
|
||||
actors currently on screen, without leaving the player.
|
||||
actors in the current scene, without leaving the player.
|
||||
- **Sidecar truth files** — drop a `Movie.jray.json` next to `Movie.mkv` and JRay
|
||||
picks it up automatically (suffix configurable).
|
||||
- **Remote truth push** — for servers that can't run the extraction pipeline
|
||||
@@ -63,12 +82,12 @@ Then install "JRay" from the plugin catalog and restart Jellyfin.
|
||||
```
|
||||
|
||||
1. The extraction pipeline analyses a film offline and emits a **truth file**
|
||||
listing each detected actor and the time windows they're on screen.
|
||||
listing each actor and the time windows they are present in the film.
|
||||
2. JRay loads that truth file either from a **sidecar** next to the media
|
||||
(`Movie.jray.json`) or from a **managed store** populated via the push API.
|
||||
3. On startup JRay injects a small `<script>` into the web client's `index.html`.
|
||||
When you pause, the script calls JRay for the current item and timestamp and
|
||||
renders the on-screen actors as an overlay.
|
||||
renders the scene's cast as an overlay.
|
||||
|
||||
## Truth File Format
|
||||
|
||||
@@ -93,8 +112,11 @@ configurable). Schema (`schema_version: 1`, minimal verbosity):
|
||||
}
|
||||
```
|
||||
|
||||
An actor is considered visible at timestamp `t` (seconds) if any of their
|
||||
`scenes` windows satisfies `start <= t <= end`. JRay prefers `jellyfin_id` (a
|
||||
An actor is present at timestamp `t` (seconds) if any of their `scenes` windows
|
||||
satisfies `start <= t <= end`. **A window is a claim about scene membership, not
|
||||
a recognition event** — an actor who has turned away or is off-camera during a
|
||||
reverse shot is still present, and two windows mean a genuine departure and
|
||||
return rather than a break in detection. JRay prefers `jellyfin_id` (a
|
||||
Jellyfin Person GUID) when present, otherwise resolves `imdb_id`/`tmdb_id`
|
||||
against the item's People `ProviderIds`.
|
||||
|
||||
@@ -108,7 +130,7 @@ the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token
|
||||
|
||||
| Method & Route | Auth | Description |
|
||||
| --- | --- | --- |
|
||||
| `GET /Items/{itemId}/jray?t={seconds}` | user | "Context at time t" envelope (on-screen actors), or `404`. |
|
||||
| `GET /Items/{itemId}/jray?t={seconds}` | user | "Context at time t" envelope (the scene's cast), or `404`. |
|
||||
| `GET /Items/{itemId}/Timeline` | user | Full truth file for an item, or `404` if none. |
|
||||
| `PUT /Items/{itemId}/Truth` | admin | Push managed truth data (schema v1). `204` on success, `400` on bad schema. |
|
||||
| `DELETE /Items/{itemId}/Truth` | admin | Remove managed truth data (idempotent, `204`). Falls back to sidecar. |
|
||||
@@ -129,12 +151,12 @@ the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token
|
||||
|
||||
JRay is designed so that *any* Jellyfin client (not just the bundled web overlay)
|
||||
can build an actor-overlay feature. The integration is two calls: figure out
|
||||
**what is playing and where**, then ask JRay **who is on screen**.
|
||||
**what is playing and where**, then ask JRay **who is in the scene**.
|
||||
|
||||
### 1. Query on-screen actors: `GET /Items/{itemId}/jray?t={seconds}`
|
||||
### 1. Query the scene's cast: `GET /Items/{itemId}/jray?t={seconds}`
|
||||
|
||||
Given a Jellyfin item id and a playback position in **seconds**, returns the
|
||||
actors visible at that timestamp. This is the only call most clients need.
|
||||
the actors in that scene. This is the only call most clients need.
|
||||
|
||||
**Request**
|
||||
|
||||
@@ -158,7 +180,7 @@ X-Emby-Token: <user-or-api-token>
|
||||
}
|
||||
```
|
||||
|
||||
- `actors` may be an **empty array** when no one is on screen at `t` — that's a
|
||||
- `actors` may be an **empty array** when no one is in the scene at `t` — that's a
|
||||
`200`, not a `404`.
|
||||
- `404 Not Found` means the item has **no truth data at all** (no managed upload
|
||||
and no sidecar file). Treat this as "JRay isn't available for this item" and
|
||||
@@ -174,7 +196,7 @@ X-Emby-Token: <user-or-api-token>
|
||||
|
||||
Returns the complete truth file (the [schema above](#truth-file-format)) — every
|
||||
actor with all their scene windows. Use this if you'd rather fetch once and
|
||||
compute "who's on screen" client-side (e.g. to drive a scrubber-bar heatmap)
|
||||
compute "who is in the scene" client-side (e.g. to drive a scrubber-bar heatmap)
|
||||
instead of polling `jray?t=` on each pause. `404` if no truth data exists.
|
||||
|
||||
### Reference implementation (web client)
|
||||
@@ -193,7 +215,7 @@ var s = sessions[0];
|
||||
var itemId = s.NowPlayingItem.Id;
|
||||
var t = (s.PlayState.PositionTicks || 0) / 10000000; // ticks → seconds
|
||||
|
||||
// 2. Ask JRay who is on screen. ApiClient adds the auth token for you.
|
||||
// 2. Ask JRay who is in the scene. ApiClient adds the auth token for you.
|
||||
var ctx = await ApiClient.ajax({
|
||||
url: ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t }),
|
||||
type: 'GET', dataType: 'json'
|
||||
@@ -336,7 +358,7 @@ Jellyfin.Plugin.JRay/
|
||||
`<script>` tag in the web client's `index.html`, marked with `<!-- jray-overlay -->`
|
||||
so it's idempotent. Re-applied whenever configuration changes.
|
||||
5. **Overlay Script** (`jray-overlay.js`): listens for the player's pause event,
|
||||
calls `jray?t=`, and renders the on-screen actors.
|
||||
calls `jray?t=`, and renders the scene's cast.
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
+6
-1
@@ -19,4 +19,9 @@ dotnet_configuration: "Release"
|
||||
dotnet_framework: "net9.0"
|
||||
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
|
||||
changelog: >
|
||||
Initial scaffold
|
||||
The pause overlay is now served through the File Transformation plugin, which
|
||||
rewrites the web client's index.html as it is sent. JRay no longer edits that
|
||||
file on disk and there is no fallback: without File Transformation the overlay
|
||||
is disabled and every other feature works normally. Any stale on-disk patch
|
||||
left by an earlier JRay is removed on startup, and the plugin's configuration
|
||||
page now reports whether the dependency is satisfied.
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# Audio signature and alignment — how it works
|
||||
|
||||
How a manifest fetched from a public server is checked against, and shifted onto,
|
||||
your own copy of a film.
|
||||
|
||||
Requirements: `JR-042` … `JR-045`, `JR-047`. Construction is owned by
|
||||
[`JRay-public-server/SPEC.md` §3](../../JRay-public-server/SPEC.md); this document
|
||||
is the mechanism end to end.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
A manifest says "Peter Capaldi is on screen from 42:10 to 42:38". Your file may
|
||||
not agree, because releases of the same film are trimmed differently — a
|
||||
distributor logo here, a longer certificate card there. Thirty seconds of extra
|
||||
head material makes every window in the manifest wrong by thirty seconds.
|
||||
|
||||
The old defence was the runtime tier: accept a manifest only if the runtimes
|
||||
agree within ±2 s. That rejects exactly the releases it should be fixing, and
|
||||
accepts anything that happens to be the same length.
|
||||
|
||||
A **content-derived signature** answers the question the exchange actually needs
|
||||
— *do these timings apply to this media?* — and, when the answer is "yes but
|
||||
shifted", says by how much.
|
||||
|
||||
Deliberately, it does not answer *what file is this?* There is no file hash
|
||||
anywhere in the system; the tier was withdrawn on legal grounds. The signature
|
||||
identifies a **cut**, so two different encodes of the same edit agree.
|
||||
|
||||
---
|
||||
|
||||
## The signature
|
||||
|
||||
120 seconds from the **centre** of the media — the head and tail are the least
|
||||
content-specific parts of a release, being logos and credits.
|
||||
|
||||
```
|
||||
decode centre window runtime/2 ± 60 s
|
||||
downmix mono
|
||||
resample 11025 Hz
|
||||
STFT 4096-sample frame, 1024-sample hop (~93 ms), Hann
|
||||
band 300–3000 Hz, 32 logarithmically spaced bands
|
||||
per frame peak band index (5 bits) + energy class (2 bits)
|
||||
-> one byte, bit 7 always clear
|
||||
result 1288 bytes, base64, prefixed "v1:"
|
||||
```
|
||||
|
||||
Peak bins are used because they survive lossy re-encoding, loudness
|
||||
normalisation and channel-layout differences, where absolute magnitudes do not.
|
||||
Same principle as Chromaprint/AcoustID, but self-contained: no external service
|
||||
is queried, so no lookup leaks which titles an instance holds.
|
||||
|
||||
Bit 7 being always clear is not decoration. It is what makes an arbitrary byte
|
||||
string *not* a valid signature, which is what the server validates on upload, and
|
||||
what stops the field being usable as a payload channel. The length is fixed by
|
||||
the construction rather than merely bounded, for the same reason — a caller
|
||||
cannot choose it, so it cannot become a variable-size container.
|
||||
|
||||
### Why the parameters are written down twice
|
||||
|
||||
The specification's prose does not determine a byte stream. Two people
|
||||
implementing "32 log-spaced bands, take the peak" will disagree on at least:
|
||||
|
||||
- `float` or `double` — the fixture has frames whose two strongest bands are
|
||||
within 1.3% of each other, so `float` is not sufficient;
|
||||
- periodic Hann (`/N`) or symmetric (`/(N-1)`);
|
||||
- whether a band's value is the **mean** or the **sum** of its magnitudes (sum
|
||||
favours wide high bands over narrow low ones);
|
||||
- which way an `argmax` breaks ties;
|
||||
- whether the frame count is `1 + (n - 4096)/1024` or something that wobbles
|
||||
with the resampler tail.
|
||||
|
||||
Every one of those is pinned at the top of
|
||||
[`AudioSignature.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs) and
|
||||
matched in the C++ producer. A signature that differs in any parameter simply
|
||||
does not match, which defeats the entire point of having one.
|
||||
|
||||
### Two implementations, proven equal
|
||||
|
||||
The extraction pipeline (C++, `IR-004`) computes this for files it processes
|
||||
locally. The plugin (C#, `JR-042`) computes it for the files the pipeline never
|
||||
sees. Both must agree exactly.
|
||||
|
||||
That is a *checked* claim, not an aspiration. `fixtures/audio/` holds three files
|
||||
byte-identical to the extraction repo's copies:
|
||||
|
||||
| File | What it pins |
|
||||
|---|---|
|
||||
| `jray_audio_v1_tone.flac` | 120 s of tones stepping through all 32 bands, amplitudes walking a golden-ratio sequence so all four energy classes appear |
|
||||
| `jray_audio_v1_golden.json` | The expected signature, the band→FFT-bin table, and checksums of the decoded PCM |
|
||||
| `make_fixture.py` | Regenerates the media from plain arithmetic — no numpy, ports to any language in ~20 lines |
|
||||
|
||||
The binding check regenerates the fixture PCM from `make_fixture.py`'s arithmetic
|
||||
and verifies it against the recorded `s16le`/`f32le` checksums **before** making
|
||||
any DSP claim. So it runs on a CI host with no codec at all, and a decode
|
||||
divergence stays distinguishable from a DSP one. The two tests that drive real
|
||||
FFmpeg self-skip without a binary — a check that skips is not a check, so it is
|
||||
never the only cover for a claim.
|
||||
|
||||
---
|
||||
|
||||
## Matching, and the offset
|
||||
|
||||
Two signatures are compared by sliding one against the other:
|
||||
|
||||
```
|
||||
for offset in -600 .. +600 frames: # ±56 s
|
||||
score(offset) = fraction of overlapping frames whose peak band matches
|
||||
best = argmax score
|
||||
```
|
||||
|
||||
| Score | Meaning |
|
||||
|---|---|
|
||||
| `≥ 0.85` | Same cut. `audio` tier. The offset applies |
|
||||
| `0.60 – 0.85` | Possibly the same cut, degraded audio. `loose` tier, surfaced as a caveat |
|
||||
| `< 0.60` | Different content. No match |
|
||||
|
||||
Only the **peak band** is scored. The energy class is the coarser and less
|
||||
re-encoding-stable of the two fields, and the specification's rule names the peak
|
||||
bin alone.
|
||||
|
||||
Speed-differing releases (a PAL 4% speed-up) are not a constant offset and are
|
||||
correctly rejected by the score threshold rather than mis-aligned.
|
||||
|
||||
### The offset has two terms
|
||||
|
||||
This is the part that is easy to get wrong. Both windows are centred on **their
|
||||
own file's** midpoint, so when the runtimes differ the two windows do not start
|
||||
at the same point in the content:
|
||||
|
||||
```
|
||||
offset = (local_window_start - manifest_window_start) + slide × 1024/11025
|
||||
└────────── anchor difference ──────────┘ └──── recovered ────┘
|
||||
```
|
||||
|
||||
A release carrying 40 s of extra head material recovers **20 s from each term**.
|
||||
Using the slide alone would be wrong by half the runtime difference on every
|
||||
shifted release. The specification's pseudocode describes only the slide, because
|
||||
it is written from the server's position, where both signatures are being
|
||||
compared against one stored manifest.
|
||||
|
||||
### One parameter that is not from the specification
|
||||
|
||||
An alignment must overlap by at least **64 frames** (~6 s) before its score
|
||||
counts. Without a floor the extreme offsets compare a handful of frames, where a
|
||||
chance agreement scores 1.0 and beats the true alignment. It never binds on the
|
||||
real case: two full-length signatures still overlap by 688 frames at the widest
|
||||
offset. It is marked as a local addition in the code.
|
||||
|
||||
---
|
||||
|
||||
## What happens when a manifest arrives
|
||||
|
||||
`ManifestController.FetchItem` → `ManifestAligner.AlignAsync`, before anything is
|
||||
stored:
|
||||
|
||||
```
|
||||
1. Server returns a manifest, a tier, and an offset.
|
||||
2. Does the manifest carry cut.audio_signature? no -> use the server's offset
|
||||
Are signatures enabled in configuration? no -> use the server's offset
|
||||
3. Decode this file's centre window, compute its signature.
|
||||
4. Compare the two.
|
||||
match -> apply the LOCAL offset and tier
|
||||
no match -> apply the server's offset, record the disagreement
|
||||
not comparable -> apply the server's offset
|
||||
5. Apply the offset to every window, once, at store time.
|
||||
6. Write the truth file, and the alignment beside it.
|
||||
```
|
||||
|
||||
### Why the local answer wins
|
||||
|
||||
**The server has never seen your file.** Its offset is a claim about a runtime it
|
||||
was told — at best a runtime-difference inference. A local alignment compares the
|
||||
manifest's own signature against the media the windows will actually be drawn
|
||||
over, which is the authoritative comparison. It also needs no round trip, so no
|
||||
signature ever leaves the instance.
|
||||
|
||||
This is what the specification means by matching being "a consumer concern": the
|
||||
server never rewrites a manifest, so **one stored manifest serves every trim of
|
||||
the same cut**, and each client shifts it onto its own timebase.
|
||||
|
||||
### Degradation, never failure
|
||||
|
||||
A signature is an enhancement to cut matching. A missing one costs a tier and
|
||||
**must never be able to break a fetch**. Every one of these stores the manifest
|
||||
on the server's terms:
|
||||
|
||||
- signatures switched off in configuration (they are opt-in — the decode costs a
|
||||
second or two of I/O per item);
|
||||
- the item is under 120 s, so the window underflows and there is no signature to
|
||||
compute (`JR-044`);
|
||||
- the manifest carried no signature;
|
||||
- the manifest's signature is `v2:` from a future producer — **refused, not
|
||||
parsed** (`JR-045`), because scoring an unknown DSP chain as v1 would be a
|
||||
confident wrong answer where declining is a correct one;
|
||||
- no FFmpeg binary, no audio stream, or a decode error.
|
||||
|
||||
### "Un-comparable" and "does not match" are different
|
||||
|
||||
The distinction matters more than it looks. A 90-second extra is not content that
|
||||
disagrees with its manifest — it is content that could not be compared. Reporting
|
||||
the first as the second would show a user a scary warning about the wrong thing.
|
||||
|
||||
A genuine mismatch — both signatures present, both valid, both items long enough,
|
||||
and the best alignment still below 0.60 — is the strongest available hint that a
|
||||
manifest describes different content. It is **still not a failure**: the audio may
|
||||
legitimately differ, a different language track being the obvious case. So the
|
||||
manifest is stored on the server's terms and the disagreement is surfaced as a
|
||||
caveat, which outranks the tier's own caveat because it is the stronger
|
||||
statement.
|
||||
|
||||
---
|
||||
|
||||
## What gets stored
|
||||
|
||||
The offset is applied **once**, at store time, so the stored windows are always
|
||||
in your file's own timebase and no read path needs offset awareness (`JR-030`).
|
||||
That makes the offset unrecoverable afterwards — the windows look native — which
|
||||
is why the alignment is recorded beside the truth file:
|
||||
|
||||
```json
|
||||
"alignment": {
|
||||
"source": "Local",
|
||||
"tier": "Audio",
|
||||
"offset_sec": 40.0,
|
||||
"score": 0.97,
|
||||
"offset_frames": 215,
|
||||
"local_signature": "v1:AAAAA…",
|
||||
"server_offset_sec": 0.0,
|
||||
"server_tier": "Runtime"
|
||||
}
|
||||
```
|
||||
|
||||
`local_signature` is kept so a **later fetch aligns for free** — the decode is
|
||||
the expensive half and the reason signatures are opt-in. It never leaves the
|
||||
instance: provenance is stored beside the truth file, not inside it, and
|
||||
contribution strips provenance entirely (`JR-034`).
|
||||
|
||||
The truth file itself is untouched by any of this. Injecting fields would mean
|
||||
the bytes served back are not the bytes the producer wrote, which is the property
|
||||
`JR-004` turns on.
|
||||
|
||||
---
|
||||
|
||||
## Where the code is
|
||||
|
||||
| Concern | File |
|
||||
|---|---|
|
||||
| The DSP | [`Services/AudioSignature.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs) |
|
||||
| The decode | [`Services/AudioSignatureService.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs) |
|
||||
| Reading and matching | [`Services/AudioSignatureMatcher.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs) |
|
||||
| The fetch-path decision | [`Services/ManifestAligner.cs`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs) |
|
||||
| What is recorded | [`Models/TruthAlignment.cs`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs) |
|
||||
| Server-side validation | `JRay-public-server/src/validate.rs`, `validate_audio_signature` |
|
||||
| C++ producer | `scene-actor-extraction/src/audio_signature.{hpp,cpp}` |
|
||||
|
||||
Tests: `AudioSignatureTests` (UT-038 … UT-044), `AudioSignatureMatcherTests`
|
||||
(UT-045 … UT-052), `ManifestAlignerTests` (UT-053 … UT-057).
|
||||
|
||||
## Verifying the cross-repo claim by hand
|
||||
|
||||
```sh
|
||||
# C# side
|
||||
cd jRay && dotnet test Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj
|
||||
|
||||
# C++ side, against the same fixture
|
||||
cd scene-actor-extraction/build && python3 -c "
|
||||
import json, sae_audio
|
||||
g = json.load(open('../tests/fixtures/audio/jray_audio_v1_golden.json'))
|
||||
print(sae_audio.compute_signature('../tests/fixtures/audio/jray_audio_v1_tone.flac') == g['signature'])
|
||||
"
|
||||
|
||||
# and that the fixtures really are the same bytes
|
||||
md5sum jRay/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_golden.json \
|
||||
scene-actor-extraction/tests/fixtures/audio/jray_audio_v1_golden.json
|
||||
```
|
||||
|
||||
## Not built yet
|
||||
|
||||
- **Matching against a server's catalogue.** `POST /manifests/search` and the
|
||||
`audio` tier server-side are `UR-009`, still in progress. The sequencing is
|
||||
deliberate — accumulate signatures first, enable matching once coverage is
|
||||
useful. Nothing here depends on it: alignment works from the signature the
|
||||
manifest already carries.
|
||||
- **Contribution.** Uploads do not yet attach a signature (`JR-034`).
|
||||
- **Re-using the stored signature.** It is written but not yet read back on a
|
||||
second fetch, so today every fetch decodes.
|
||||
@@ -0,0 +1,426 @@
|
||||
# jRay — requirements register
|
||||
|
||||
Stable IDs for every requirement in [`../SPEC.md`](../SPEC.md), which holds the
|
||||
prose. This file is the **authoritative list**; the CI gate reads its
|
||||
denominators from here (see [`../scripts/vendor/jray-project/SPEC.md`](../scripts/vendor/jray-project/SPEC.md) §6).
|
||||
|
||||
**IDs are permanent.** A withdrawn requirement is marked `Withdrawn` and its
|
||||
number is never reused — renumbering is what produces orphan TRACES tags. This
|
||||
register replaces the earlier section-numbering of `SPEC.md`, which gave the
|
||||
plugin no way to be traced to and left it outside the chain entirely.
|
||||
|
||||
Tag code with `// TRACES: JR-012 | SR-002`.
|
||||
|
||||
| Type | Scope |
|
||||
|---|---|
|
||||
| `JR` | Everything this plugin does — truth format, API, overlay, exchange client |
|
||||
| `UT` / `IT` | Unit / integration tests |
|
||||
|
||||
## Tests (UT)
|
||||
|
||||
| ID | Asserts | Covers | Status |
|
||||
|---|---|---|---|
|
||||
| UT-001 | Marked tag **and its trailing newline** removed — no blank line accumulates per upgrade cycle | JR-022 | **Passing** |
|
||||
| UT-002 | Marked tag with no trailing newline removed | JR-022 | **Passing** |
|
||||
| UT-003 | Document without the marker left byte-identical | JR-022 | **Passing** |
|
||||
| UT-004 | Removal is idempotent — startup runs it on every boot forever after | JR-022 | **Passing** |
|
||||
| UT-005 | **Another plugin's injection left intact** — it is their file too | JR-022 | **Passing** |
|
||||
| UT-006 | An unmarked look-alike script tag is left alone — JRay did not write it | JR-022 | **Passing** |
|
||||
| UT-007 | No matching rule resolves to `null` | JR-016 | **Passing** |
|
||||
| UT-008 | Item rule beats Series rule | JR-016 | **Passing** |
|
||||
| UT-009 | **Prioritised series inside an ignored genre — series wins** | JR-016 | **Passing** |
|
||||
| UT-010 | Genre matching is case-insensitive | JR-016 | **Passing** |
|
||||
| UT-011 | A Series rule valued `Guid.Empty` does not swallow every movie | JR-016 | **Passing** |
|
||||
| UT-012 | `TryRegister` returns `false` when File Transformation is absent | JR-023 | **Passing** |
|
||||
| UT-013 | …and **warns** naming the install URL, with no "falling back" claim | JR-023 | **Passing** |
|
||||
| UT-014 | Overlay disabled ⇒ `index.html` returned unchanged | JR-023 | **Passing** |
|
||||
| UT-015 | Null contents return empty rather than throwing — this callback runs on every page another plugin serves | JR-023 | **Passing** |
|
||||
| UT-016 | Both bounds **inclusive** — start, interior and end all present | JR-005 | **Passing** |
|
||||
| UT-017 | Just outside either bound is absent | JR-005 | **Passing** |
|
||||
| UT-018 | A zero-length window is a real sighting, not a degenerate one to discard | JR-005 | **Passing** |
|
||||
| UT-019 | **Overlapping windows** — present inside an enclosing window | JR-005 | **Passing** |
|
||||
| UT-020 | **Unsorted windows still resolve**; sortedness is a producer guarantee, not a correctness dependency | JR-006 | **Passing** |
|
||||
| UT-021 | **Adjacent windows are never merged** — reported once, from two windows | JR-004 | **Passing** |
|
||||
| UT-022 | Truth file round-trips with windows byte-identical | JR-004 | **Passing** |
|
||||
| UT-023 | 50 actors × 1000 windows: response bounded by actor count, lookup not quadratic | JR-006 | **Passing** |
|
||||
| UT-024 | A fetched claim round-trips: server, tier, **offset**, caveat, timestamp | JR-010 | **Passing** |
|
||||
| UT-025 | A local push records no server and **no tier** — there is no cut to match | JR-010 | **Passing** |
|
||||
| UT-026 | Provenance is **not** written into the truth file | JR-010, JR-004 | **Passing** |
|
||||
| UT-027 | `Delete` removes provenance too — no record outliving its claim | JR-010 | **Passing** |
|
||||
| UT-028 | Unknown item yields null rather than a fabricated record | JR-010 | **Passing** |
|
||||
| UT-029 | A v2 file round-trips; `extraction.*` and `cut.*` survive intact — `sample_fps` read from the block, not the top level | JR-002 | **Passing** |
|
||||
| UT-030 | `scenes` objects retain **belief and route** — a window that loses them is indistinguishable from v1 | JR-002 | **Passing** |
|
||||
| UT-031 | All three routes (`live`, `deferred`, `pooled`) survive a round trip | JR-002 | **Passing** |
|
||||
| UT-032 | A window without belief reads `null`, **not `0.0`** — absent and disbelieved are different claims | JR-002 | **Passing** |
|
||||
| UT-033 | `schema_version` 1, 3 and 0 are all **refused**, and the message names the version found | JR-003 | **Passing** |
|
||||
| UT-034 | A **missing** `schema_version` is refused, never assumed current | JR-003 | **Passing** |
|
||||
| UT-035 | A `null` truth file is refused without throwing | JR-003 | **Passing** |
|
||||
| UT-036 | **A v1 file never half-parses into usable windows** — it either fails to deserialise or is stopped by the gate | JR-003 | **Passing** |
|
||||
| UT-037 | **The producer's actual output parses** — the exact shape `result_sink_node.hpp` writes, omitting `cut` and two `extraction` fields, not the spec's fully populated example | JR-002 | **Passing** |
|
||||
| UT-038 | The **regenerated** fixture PCM matches the recorded `s16le` and `f32le` checksums — the input is proven identical before any DSP claim is made | JR-043 | **Passing** |
|
||||
| UT-039 | **Signature equals the shared golden vector, byte for byte** — the same string the C++ producer emits | JR-042, JR-043 | **Passing** |
|
||||
| UT-040 | Band → FFT-bin table matches the recorded one, and tiles 300–3000 Hz contiguously with no empty band | JR-042 | **Passing** |
|
||||
| UT-041 | Well-formed: `v1:` prefix, 1288 frames, bit 7 always clear, **and the fixture still exercises all 32 bands and all 4 energy classes** | JR-042 | **Passing** |
|
||||
| UT-042 | Whole frames only — 4095 samples yield nothing, 5120 yield two; a partial frame is never padded into a signature | JR-042 | **Passing** |
|
||||
| UT-043 | The **real FFmpeg decode** of the fixture reproduces the golden signature | JR-042 | **Passing** (needs a binary) |
|
||||
| UT-044 | **The window is taken from the centre**: the fixture wrapped in 90 s of silence either side signs identically | JR-042 | **Passing** (needs a binary) |
|
||||
| UT-045 | **The 120 s boundary, on one file**: runtime 119.999 emits nothing, runtime 120.000 emits the golden signature — only the runtime differs, so a null cannot be blamed on the decode | JR-044 | **Passing** (boundary needs a binary) |
|
||||
| UT-046 | Two identical, perfectly valid signatures still yield **no match and no offset** when either runtime is under the window — the rule is read off the runtime, not inferred from a missing string | JR-044 | **Passing** |
|
||||
| UT-047 | A `v2:` signature whose payload is byte-identical to a valid v1 one is **refused, not parsed** — by the matcher as well as the parser | JR-045 | **Passing** |
|
||||
| UT-048 | A v1 signature parses to exactly the produced frames, checked against the **golden vector** rather than against the producer's own output | JR-045 | **Passing** |
|
||||
| UT-049 | Missing prefix, empty payload, invalid base64 and a **set reserved bit** are each refused without throwing — the client never accepts what the server would reject | JR-045 | **Passing** |
|
||||
| UT-050 | Identical signatures score 1.0 at offset 0 and reach the `audio` tier | JR-044 | **Passing** |
|
||||
| UT-051 | **A shifted release recovers its offset** rather than failing to match — the case the feature exists for | JR-044 | **Passing** |
|
||||
| UT-052 | Unrelated content yields **no match at all**, and runtime skew contributes its window-anchor term to the offset | JR-044 | **Passing** |
|
||||
| UT-053 | **A local alignment supersedes the server's offset**, and the server's claim is retained rather than overwritten | JR-047 | **Passing** |
|
||||
| UT-054 | The local signature is recorded, so a later fetch aligns without decoding the media again | JR-047 | **Passing** |
|
||||
| UT-055 | Every unavailable local path — off, no manifest signature, neither, short media, **and a `v2:` producer** — falls back to the server rather than refusing | JR-047 | **Passing** |
|
||||
| UT-056 | Two signatures that genuinely disagree are recorded as a mismatch and **still do not break the fetch** | JR-047 | **Passing** |
|
||||
| UT-057 | A mismatch **outranks the tier** in the caveat shown to the user — a `runtime` match would otherwise show nothing at all | JR-047 | **Passing** |
|
||||
|
||||
All execute and pass. UT-043 and UT-044 are the two that need an FFmpeg binary,
|
||||
which the plugin gets from Jellyfin at run time and a bare CI container may not
|
||||
have; they self-skip without one. That is why the cross-repo claim rests on
|
||||
UT-038 and UT-039, which regenerate the fixture PCM from `make_fixture.py`'s
|
||||
arithmetic and need no codec at all — a check that skips is not a check.
|
||||
|
||||
UT-045 follows the same rule: its *below-the-boundary* half is codec-free and
|
||||
always binds, because the runtime check short-circuits before the encoder is
|
||||
consulted — asserted with a deliberately invalid encoder path, so passing proves
|
||||
the short-circuit rather than merely a failed decode.
|
||||
|
||||
The suite is also checked to **fail** on deliberate mutations, because a suite
|
||||
that has only ever passed is not evidence that it tests anything. Each was
|
||||
restored and re-verified afterwards:
|
||||
|
||||
| Mutation | Fails | Blast radius |
|
||||
|---|---|---|
|
||||
| Drop the newline-stripping in `RemoveInjection` | UT-001 | 1 test |
|
||||
| Downgrade the missing-dependency warning to `Information` | UT-013 | 1 test |
|
||||
| Make the window end bound exclusive (`t < end`) | UT-016, UT-018 | 2 tests |
|
||||
| Stop `Delete` removing provenance | UT-027 | 1 test |
|
||||
| Make `TruthSchema.IsSupported` accept any version | UT-033, UT-034, UT-035, UT-036 | 4 tests |
|
||||
| Emit `v2:` as the signature prefix | UT-039, UT-043, UT-044 | 3 tests |
|
||||
| Aggregate a band by **sum** instead of mean | UT-039, UT-043, UT-044 | 3 tests |
|
||||
| Anchor the decode window at the head instead of the centre | UT-044 | 1 test |
|
||||
|
||||
Two further mutations were tried and **did not fail**, which is worth recording
|
||||
rather than hiding: the symmetric `N-1` Hann window in place of the periodic one,
|
||||
and the lower median in place of the upper as the energy reference. Both are
|
||||
pinned by prose in the shared fixture, and on this synthetic vector neither moves
|
||||
a peak bin or crosses an energy-class edge. They are conventions the golden
|
||||
vector does not police, so a second implementation could get either wrong and
|
||||
still pass — the fixture would need frames sitting nearer those boundaries to
|
||||
catch it.
|
||||
|
||||
The window-bound mutation is the one worth keeping: a single character turns an
|
||||
inclusive window into a half-open one, which would drop an actor at exactly the
|
||||
moment a scene ends — and nothing else in the suite would have noticed.
|
||||
|
||||
`JR` is flat rather than split by theme. The plugin is one deployable with one
|
||||
audience, and the thematic grouping lives in the section headings below, where it
|
||||
costs nothing and cannot go stale against a prefix.
|
||||
|
||||
Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
|
||||
|
||||
---
|
||||
|
||||
## Truth-file format (JR-001 … JR-007)
|
||||
|
||||
jRay **owns** this format ([system spec](../scripts/vendor/jray-project/SPEC.md) §1); extraction is the
|
||||
producer and the public server carries a derived envelope. Changes are
|
||||
coordinated `schema_version` bumps (SR-003).
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-001 | The truth-file format is normatively defined here; other repos reference it rather than restating it | SR-003 | High | In Progress |
|
||||
| JR-002 | `schema_version: 2` shape — `extraction.*` provenance block, `cut.*` block, `scenes` as objects carrying belief and route | SR-003 | High | **Done** (UT-029…032) — `TruthScene`, `TruthExtraction`, `TruthCut`; `anneal_sec` and top-level `sample_fps` deleted, not zeroed. `ManifestConverter` carries belief, route and both blocks through |
|
||||
| JR-003 | Reject an unknown `schema_version`, never guess. **Flag day: v2 only**, no dual-accept | SR-003 | High | **Done** (UT-033…036) — `TruthSchema.IsSupported` is the single gate, applied on all four paths: sidecar read, managed store load, managed `PUT`, converted manifest. Rejections name the file and the version found |
|
||||
| JR-004 | A window is a **scene-membership claim**, not a recognition event — never reinterpreted, merged, split or trimmed | **SR-002** | High | **Done** (UT-021, UT-022) |
|
||||
| JR-005 | Query semantics: actor present at `t` if any window contains `t`; presentation must not assert instantaneous visibility | **SR-002** | High | **Done** (UT-016…019) |
|
||||
| JR-006 | Read path holds up under **numerous** windows — no assumption of a handful of long ones | SR-002 | Medium | **Done** (UT-020, UT-023) |
|
||||
| JR-007 | Identity is public identifiers: prefer `jellyfin_id` locally, else resolve `imdb_id`/`tmdb_id` against the item's People `ProviderIds` | SR-001 | High | Done |
|
||||
|
||||
## Truth-data sources and precedence (JR-008 … JR-011)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-008 | Discover a sidecar truth file beside the media, by configurable suffix | PR-001 | High | Done |
|
||||
| JR-009 | Accept truth data pushed by a remote worker (`PUT`/`DELETE`), admin key | PR-004 | High | Done |
|
||||
| JR-010 | Precedence: managed truth (pushed **or** fetched) overrides a sidecar; provenance is recorded so the UI can distinguish the three sources | PR-001 | High | **Done** (UT-024…028) |
|
||||
| JR-011 | Loaded truth is cached; any write invalidates the item's cache entry immediately | PR-001 | Medium | Done |
|
||||
|
||||
## Read API (JR-012 … JR-014)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-012 | `GET .../Timeline` returns the full truth file for an item | PR-001 | High | Done |
|
||||
| JR-013 | `GET .../jray?t=` returns an **extensible** context envelope; consumers ignore unknown keys | PR-001 | High | Done |
|
||||
| JR-014 | Authorisation: reads need an authenticated user, admin routes need the Administrator role, only `ClientScript` is anonymous | PR-004 | High | Done |
|
||||
|
||||
## Work discovery, policy and coverage (JR-015 … JR-019)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-015 | `Tasks/Pending` serves a random sample of items with no truth data, so pollers spread across the backlog without server-side task state | PR-003 | High | Done |
|
||||
| JR-016 | Prioritise/ignore rules scoped `Genre` / `Series` / `Item`; **most specific wins**; scope+value is the unique key | PR-003 | Medium | **Done** (UT-007…011) |
|
||||
| JR-017 | Rules steer **work discovery only** — never the overlay or the read endpoints | PR-003 | Medium | Done |
|
||||
| JR-018 | Coverage report by media type and genre; ignored items leave the percent-done denominator rather than dragging it down | PR-003 | Medium | Done |
|
||||
| JR-019 | Picker endpoints (genres, series, item search) populate the rule editor | PR-003 | Low | Done |
|
||||
|
||||
## Player overlay (JR-020 … JR-024)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-020 | Pause overlay: injected client script queries `jray?t=` and renders the scene's cast | **PR-001** | High | Done |
|
||||
| JR-021 | **jRay never injects into `index.html` on disk.** File Transformation is a hard dependency; there is no on-disk fallback. The only permitted write is JR-022's removal | PR-004 | High | **Done** |
|
||||
| JR-022 | Migration: remove any on-disk patch left by an earlier jRay, identified by the `<!-- jray-overlay -->` marker | PR-004 | High | **Done** (UT-001…006) |
|
||||
| JR-023 | Absent the dependency, disable **only** the overlay and say so in the log and the config page; never bundle the assembly | PR-004 | Medium | **Done** (UT-012…015; config page is T4) |
|
||||
| JR-024 | Actor names and all server-supplied strings render as **text, never markup** | SR-004 | High | Done |
|
||||
|
||||
## Manifest exchange client (JR-025 … JR-037)
|
||||
|
||||
Plugin-side requirements for the exchange specified in
|
||||
[`../../JRay-public-server/SPEC.md`](../../JRay-public-server/SPEC.md) §9. The
|
||||
wire format is the server's; **the client's obligations are jRay's**, and belong
|
||||
in this register rather than in the server's spec.
|
||||
|
||||
The server's register already anticipates this: its `UR-007` is recorded as
|
||||
having "no server-side test and cannot have one — it is a requirement on the
|
||||
plugin", to be cross-referenced from the plugin's register once one exists. This
|
||||
is that register, and `JR-025` is that row. `UR-007` should now point here and
|
||||
stay `In Progress` until `JR-025` is `Done`.
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-025 | Query an **ordered list** of servers; first result clearing the configured tier wins — **satisfies `JRay-public-server` UR-007** | PR-006 | High | Done |
|
||||
| JR-026 | For a series, first-match applies per **episode** — later servers are queried only for the episodes earlier ones lacked | PR-006 | Medium | Planned |
|
||||
| JR-027 | Treat **every** server as untrusted, including the default: re-validate on receipt against the strict upload schema, bounds-check windows against the item's real runtime | SR-004 | High | Done |
|
||||
| JR-028 | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB bundle — aborting rather than buffering | SR-004 | High | Done |
|
||||
| JR-029 | HTTPS required for non-loopback servers; certificate validation must not be disabled | SR-004 | High | Done |
|
||||
| JR-030 | Apply an `audio`-tier `offset` to **every** window before storing — stored truth is always in the local file's timebase, so read paths need no offset awareness | SR-003 | High | Done |
|
||||
| JR-031 | Fetch endpoints: item fetch, series bundle fetch, per-server status, content identify | PR-006 | High | Planned |
|
||||
| JR-032 | Identify is **never automatic** — storing a candidate is a separate confirmation step | PR-006 | Medium | Planned |
|
||||
| JR-033 | Scheduled sweep over items lacking truth data, using the **batch** `exists` endpoint | PR-006 | Medium | Planned |
|
||||
| JR-034 | Contribution strips `movie` and `jellyfin_id`, attaches identity from `ProviderIds` plus measured runtime, and posts **only** to contribute-enabled servers — never fanned out | PR-005 | High | Planned |
|
||||
| JR-035 | Uploads set `Expect: 100-continue`, so a rejection lands before a bundle body is transmitted | PR-006 | Low | Planned |
|
||||
| JR-036 | Minimum accepted match tier is configurable; a `loose` match surfaces as a caveat rather than being applied silently | PR-006 | Medium | In Progress |
|
||||
| JR-037 | A server that is unreachable or failing is skipped on a short timeout with backoff; one dead server never stalls a sweep | PR-006 | Medium | Done |
|
||||
|
||||
## Egress and privacy (JR-038 … JR-041)
|
||||
|
||||
`PR-005` had **no software row in any repo** — it was held structurally, by
|
||||
SR-004 and GR-005 both being prohibitions. jRay is the component that actually
|
||||
performs egress, so these are the rows that make it verifiable rather than merely
|
||||
preserved.
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-038 | Every exchange feature is **opt-in and off by default**, including the pre-configured community server | **PR-005** | High | Done |
|
||||
| JR-039 | No library-wide inventory in one request: batch `exists` capped at 100 items, sweeps paced | **PR-005** | High | Planned |
|
||||
| JR-040 | The config page states plainly that **each configured server multiplies the exposure** | **PR-005** | Medium | Planned |
|
||||
| JR-041 | The plugin never fetches, stores, or transmits gallery data — reference faces or embeddings. It has no gallery code path at all | **SR-005** | High | Done |
|
||||
|
||||
## Audio signature (JR-042 … JR-045, JR-047)
|
||||
|
||||
Mirror-image of extraction `IR-004`/`IR-005`/`IR-007`/`IR-008`. Both producers
|
||||
must agree **bit-for-bit**, so each obligation is stated on both sides rather
|
||||
than assumed to be inherited.
|
||||
|
||||
JR-042 … JR-045 are the signature itself, produced and read. **JR-047 is what
|
||||
uses it**: without a consumer on the fetch path the other four are a fingerprint
|
||||
nothing ever fingerprints. See [`audio-alignment.md`](audio-alignment.md) for the
|
||||
end-to-end mechanism.
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-042 | Compute the signature **exactly** per server spec §3, using the FFmpeg binary Jellyfin already ships via `IMediaEncoder.EncoderPath` — no new dependency | SR-003 | Medium | **Done** (UT-039…044) — `AudioSignature` is the DSP, `AudioSignatureService` the decode; FFmpeg is invoked as a child process for decode, downmix and resample, and nothing was added to the project's dependencies |
|
||||
| JR-043 | Golden-vector fixture **shared with the extraction repo**, proving the two implementations are bit-exact | SR-003 | High | **Done** (UT-038, UT-039) — `fixtures/audio/` holds the extraction repo's three files byte-identically; the C# signature equals the recorded vector exactly |
|
||||
| JR-044 | Media shorter than 120 s: emit no signature and apply no sync offset — identical rule in both producers | SR-003 | Low | **Done** (UT-045, UT-046, UT-050…052) — the producer half was already in `AudioSignatureService`; the consumer half needed a reader, so `AudioSignatureMatcher` implements the §3 slide and declines an offset outright below the window. The boundary is asserted on one file at 119.999 s and 120.000 s |
|
||||
| JR-045 | Emit and honour the signature's own `v1:` prefix, so a DSP change is detectable rather than silently non-matching | SR-003 | Low | **Done** (UT-047…049) — `AudioSignatureMatcher.TryParseFrames` refuses any prefix but `v1:`, and refuses malformed or structurally invalid payloads, so a future producer's `v2:` drops the item to the runtime tier instead of scoring as if it were understood |
|
||||
| JR-047 | **A fetched manifest is aligned against the local file before its windows are stored**, and the alignment is recorded beside the truth data | SR-003 | High | **Done** (UT-053…057) — `ManifestAligner` runs on the fetch path. A local alignment supersedes the server's offset, since the server has never seen this file; every unavailable path degrades to the server's offset rather than refusing, and a genuine signature disagreement is recorded and surfaced as a caveat without failing the fetch |
|
||||
|
||||
> **Outstanding: §3's score gained ±1 frame of tolerance and this matcher has
|
||||
> not.** `AudioSignatureMatcher` implements the exact-frame rule §3 carried
|
||||
> until `JRay-public-server` UR-009 landed. The change was
|
||||
> `scene-actor-extraction` VR-014's measurement: over 40 correctly recovered
|
||||
> offsets on real film audio the exact rule scored 27 of them below 0.85 and
|
||||
> demoted them to `loose`, because the two windows are cut on their own file's
|
||||
> frame grid and those grids do not coincide. With ±1 frame all 40 reach
|
||||
> `audio` and the strongest false match is unmoved at 0.16.
|
||||
>
|
||||
> **Nothing is misaligned by the divergence** — the offset the matcher recovers
|
||||
> is unaffected, and JR-047 makes the local answer supersede the server's — but
|
||||
> the plugin will label as `loose` alignments the server calls `audio`, which is
|
||||
> a caveat shown to a user for a match that is not in doubt. JR-044's matcher
|
||||
> and [`audio-alignment.md`](audio-alignment.md)'s scoring table both need the
|
||||
> revised rule. Until then this repo implements a superseded version of §3.
|
||||
|
||||
## Human-in-the-loop association (JR-046)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-046 | Review UI for unidentified track clusters: show context crops, pick from the title's cast or search TMDB, record the association | [system §4](../scripts/vendor/jray-project/SPEC.md) | Medium | **TBD** |
|
||||
|
||||
Deliberately a single placeholder row rather than a decomposed set. It depends on
|
||||
extraction `AR-021`/`AR-022` landing, and on system open question 2 (whether
|
||||
unidentified presence is published at all) — decomposing it now would fix an
|
||||
interface against an undecided upstream.
|
||||
|
||||
**It carries tiers (T2 + T4) despite being undesigned, and stays in the coverage
|
||||
denominator.** Tiers say *how* it will be verified, which is knowable — an
|
||||
association endpoint is CI-testable, the UI is not — without asserting *what*
|
||||
the assertions are, which is not. Recording it as T4-only would have been the
|
||||
tempting move, because that drops it out of CI scope and lifts the CI
|
||||
percentage; it would also have been the 158%-coverage error in miniature, a
|
||||
number improved by reclassifying work rather than by doing it. An unbuilt
|
||||
requirement should count against coverage until it is built.
|
||||
|
||||
---
|
||||
|
||||
## Verification strategy
|
||||
|
||||
**CI is an Intel N100** ([system spec](../scripts/vendor/jray-project/SPEC.md) §6). Unlike the extraction
|
||||
pipeline this costs jRay almost nothing: the plugin is CPU-only managed code, and
|
||||
every requirement above except the live-integration ones is executable in CI.
|
||||
|
||||
| Tier | Runs in CI | What it covers |
|
||||
|---|---|---|
|
||||
| **T1 — Unit** | Yes | Parsing, precedence, policy resolution, coverage arithmetic, offset application, audio DSP, schema rejection |
|
||||
| **T2 — Host integration** | Yes | Controllers and authorisation against a test host with a faked `ILibraryManager` |
|
||||
| **T4 — Live** | **No** | Real Jellyfin + File Transformation + web client; real manifest server round-trip |
|
||||
| **static** | Yes | Grep/analyzer checks — e.g. no injection path into `index.html` (JR-021) |
|
||||
|
||||
**T3 is deliberately unused.** Executable tiers are declared per repo in
|
||||
[`../traceability.toml`](../traceability.toml), so the numbering is a local
|
||||
choice — but jRay keeps **T4** for "no CI host can run this" because that is
|
||||
what T4 means in `scene-actor-extraction`. A tier number should mean the same
|
||||
thing when read across repos; reusing T3 for a live tier here would make a
|
||||
cross-repo reader count live-only requirements as covered.
|
||||
|
||||
`Jellyfin.Plugin.JRay.Tests` (xUnit, in the solution) carries the T1 tier. It
|
||||
builds clean alongside the plugin and all 15 tests pass.
|
||||
|
||||
### Running the suite on a box without the web runtime
|
||||
|
||||
`dotnet test` from the repo root. Two properties on the test project make that
|
||||
work anywhere, and both are load-bearing rather than incidental:
|
||||
|
||||
- **`RollForward=LatestMajor`.** The plugin targets `net9.0` to match Jellyfin's
|
||||
ABI, but a machine that can *build* it need not have the 9.0 runtime. Rolling
|
||||
the test host forward keeps the suite runnable without pinning developers to a
|
||||
runtime the plugin does not otherwise need.
|
||||
- **`DisableTransitiveFrameworkReferences=true`.** The plugin
|
||||
framework-references `Microsoft.AspNetCore.App` through `Jellyfin.Controller`,
|
||||
and that flows into anything referencing it — so the test host would otherwise
|
||||
demand a web runtime that no version of exists in the Arch repositories for
|
||||
.NET 9 (8 and 10 only).
|
||||
- **Explicit `Jellyfin.Controller` / `Jellyfin.Model` references.** The plugin
|
||||
sets `ExcludeAssets=runtime` on both, because at run time the *server* supplies
|
||||
them and shipping copies would risk loading a second, different
|
||||
`MediaBrowser.Common`. The test host is not the server, so it must bring its
|
||||
own — hence the same packages without that exclusion, and only in the test
|
||||
project.
|
||||
|
||||
Those two together are what make it work: the first drops the demand for the web
|
||||
*framework*, the second supplies the Jellyfin *assemblies*. Cutting the framework
|
||||
reference alone is not enough — `ILogger` and `MediaBrowser.Common` live in the
|
||||
excluded assets, so anything beyond genuinely dependency-free logic fails to load
|
||||
with `FileNotFoundException` at run time rather than at build.
|
||||
|
||||
**T2 — controllers and authorisation — is still a separate matter**, since
|
||||
instantiating MVC types needs the ASP.NET Core runtime itself, not just its
|
||||
reference assemblies. Those tests belong in a second project that keeps the
|
||||
framework reference and leans on `RollForward` to reach the 10.0 runtime.
|
||||
|
||||
### Per-requirement verification plan
|
||||
|
||||
| ID | Tier | Test asserts | Edge cases to cover |
|
||||
|---|---|---|---|
|
||||
| JR-001 | static | Other repos' specs link here rather than restating the schema | A second copy of the schema anywhere is the failure |
|
||||
| JR-002 | T1 | A v2 file round-trips; `scenes` objects retain belief and route | All three route values; belief absent reads `null` not `0.0`; **the producer's real output**, which omits `cut` and two `extraction` fields — a test written only against the spec's populated example would have passed throughout the break |
|
||||
| JR-003 | **T1** | `schema_version` 1 and 3 are both **rejected**, not coerced | Missing field entirely; `null` document; **a v1 file must not half-parse into usable windows** |
|
||||
| JR-004 | T1 | Windows are stored and served byte-identical to input | Adjacent windows that "look" mergeable must **not** merge |
|
||||
| JR-005 | T1 | `t` exactly on `start` and on `end` are both present | Zero-length window; overlapping windows for one actor |
|
||||
| JR-006 | T1 | Response bounded by actor count, not window count; lookup not quadratic | 50 × 1000 windows; **unsorted input still resolves** — sortedness is a producer guarantee, never a correctness dependency |
|
||||
| JR-007 | T1 | `jellyfin_id` preferred; falls back to provider ids | All three ids empty → actor still displayable by name |
|
||||
| JR-008 | T1 | Sidecar path derived from the item path plus the configured suffix | Item with no path; suffix changed at runtime |
|
||||
| JR-009 | T2 | `PUT` stores, `DELETE` removes, both admin-only | `DELETE` on an item with no managed truth is still `204` |
|
||||
| JR-010 | T1 | Managed overrides sidecar; provenance survives a round trip and is deleted with its truth | Fetched vs pushed for the same item; **provenance never inside the truth file**; unknown item yields null |
|
||||
| JR-011 | T1 | A write invalidates the cached entry immediately | Read, push, read again within the cache window |
|
||||
| JR-012 | T2 | Returns the file, or `404` when no source has data | Sidecar present but unparseable |
|
||||
| JR-013 | T2 | Envelope shape is stable; extra keys are additive | Item with truth data but no actor present at `t` |
|
||||
| JR-014 | T2 | Anonymous request to each admin route is refused | Authenticated non-admin on an admin route |
|
||||
| JR-015 | T2 | Sample excludes covered items and clamps `limit` | `limit` of 0 and of 1000; library of missing-path ghosts |
|
||||
| JR-019 | T2 | Pickers return `{value,label}`; empty search returns `[]` | Two episodes named "Pilot" — labels must disambiguate |
|
||||
| JR-020 | **T4** | Overlay appears on pause and lists the scene cast | Live web client only |
|
||||
| JR-016 | T1 | Item beats Series beats Genre | Prioritised series inside an ignored genre — the case that motivated the rule |
|
||||
| JR-017 | **T1** | An ignored item still serves its overlay | Rule added after truth data exists |
|
||||
| JR-018 | T1 | `covered / (total - ignored)` | Item carrying two genres counts in both rows |
|
||||
| JR-021 | **static** | No code path *adds* the script tag to `index.html` | `scripts/checks/no-index-injection.sh`. Removal (JR-022) is the one permitted write, so the check is on injection, not on writing. Verified to **fail** on a reintroduced `Apply()` and on reintroduced `ReplaceLast` injection, not merely to pass today |
|
||||
| JR-022 | T1 | A marked legacy patch is removed; unmarked content untouched | Foreign plugin's injection left intact |
|
||||
| JR-023 | T1 + **T4** | Absent dependency disables only the overlay, and the log says so | Detection and log content covered by UT-012…015; the config-page banner is T4, verifiable only against a live server |
|
||||
| JR-024 | T1 | A name containing markup renders escaped | `<script>` in an actor name from a hostile server |
|
||||
| JR-025 | T1 | First result clearing the tier wins; disabled servers skipped | All servers fail; first server returns a below-tier match |
|
||||
| JR-026 | T1 | Server 2 queried only for episodes server 1 lacked | Bundle with a gap in the middle of a season |
|
||||
| JR-027 | T1 | Unknown field, oversized body, and out-of-range window each rejected | Window ending beyond the item's runtime |
|
||||
| JR-028 | T1 | Stream aborts past the cap rather than buffering | Server declaring a small length and sending more |
|
||||
| JR-029 | T1 | Plain `http` to a non-loopback host is refused | `http://localhost` allowed; `http://192.168.x` refused |
|
||||
| JR-030 | **T1** | Offset added to every window before storage | Negative offset; offset that would push a window below zero |
|
||||
| JR-031 | T2 | All four routes exist and are admin-only | — |
|
||||
| JR-032 | T1 | `Identify` returns candidates and stores nothing | A single high-confidence candidate still does not auto-store |
|
||||
| JR-033 | T1 | Sweep batches through `exists` and paces | Backlog smaller than one batch |
|
||||
| JR-034 | **T1** | `movie` and `jellyfin_id` absent from the upload body | Contribution attempted to a `FetchOnly` server must not send |
|
||||
| JR-035 | T1 | `Expect: 100-continue` set on uploads | — |
|
||||
| JR-036 | T1 | Below-tier match is not stored; `loose` is flagged | Tier configured to `audio` with only a `runtime` match available; **`MatchTier` has no `Exact` member** — the file-hash tier is withdrawn on legal grounds, so a test naming it would not compile |
|
||||
| JR-037 | T1 | Failing server skipped, backoff grows | Every server failing must not hang the sweep |
|
||||
| JR-038 | **T1** | Every exchange switch defaults off; community server disabled | Fresh config object, no user input |
|
||||
| JR-039 | T1 | Batch never exceeds 100 items | Library of 10⁴ items produces a paced sweep |
|
||||
| JR-040 | **T4** | Config page states the per-server exposure | Manual review of copy |
|
||||
| JR-041 | **static** | No embedding or image field is parsed or stored | Grep-based, mirroring the server's UR-012 |
|
||||
| JR-042 | T1 | DSP chain matches the specified parameters exactly | Window, hop, band, bin count each asserted individually; the decode itself is covered only where an FFmpeg binary exists, so it must not be the only cover for any claim |
|
||||
| JR-043 | **T1** | Signature matches the shared golden vector **bit-for-bit** | The fixture PCM is regenerated from `make_fixture.py` and checked against the recorded decode checksums first, so the check binds on a host with no codec and a decode divergence is distinguishable from a DSP one |
|
||||
| JR-044 | T1 | Media < 120 s yields no signature and no offset | Exactly 120 s — the boundary both repos must agree on |
|
||||
| JR-045 | T1 | `v1:` emitted; an unknown prefix is refused, not parsed | `v2:` signature from a future producer |
|
||||
| JR-047 | **T1** | A fetched manifest is aligned locally before storage, and the alignment is recorded | **Every way the local path can be unavailable must degrade to the server's offset, never refuse** — signatures off, no manifest signature, media under the window, a `v2:` producer. Distinguish those from a genuine mismatch: a 90 s extra is not content that disagrees with its manifest, and telling a user it is would be worse than saying nothing |
|
||||
| JR-046 | T2 + **T4** | *Assertions deferred* — recording an association and persisting it is T2; the review UI itself is T4 | Cannot be written until the truth-file interface for unidentified presence is settled (system open question 2) and AR-021/AR-022 land |
|
||||
|
||||
Three are worth singling out. **JR-021** and **JR-041** are static checks because
|
||||
both are requirements to *not do something*, and a prohibition is verified by
|
||||
absence, not by a passing test. **JR-043** is the cross-repo check: it is the only
|
||||
test in this repo whose fixture is shared with another, and it is CPU-only DSP,
|
||||
which is exactly why it can be the binding check rather than an aspiration.
|
||||
|
||||
---
|
||||
|
||||
## Running the gate
|
||||
|
||||
The extractor is shared and vendored, never forked — there must only ever be one
|
||||
implementation. Everything that varies per repo lives in
|
||||
[`../traceability.toml`](../traceability.toml), so the invocation carries no
|
||||
flags to drift out of sync between a developer's shell and CI:
|
||||
|
||||
```sh
|
||||
python3 scripts/vendor/jray-project/scripts/traceability/extract_traces.py \
|
||||
--root . --format coverage
|
||||
```
|
||||
|
||||
That config declares the `JR` prefix, the languages, the source roots, the
|
||||
CI-executable tiers, and the path to the vendored system spec.
|
||||
|
||||
Refresh the pinned tooling with
|
||||
`git submodule update --remote scripts/vendor/jray-project`.
|
||||
|
||||
Two choices in it are worth knowing about. `scripts/checks` is scanned so the
|
||||
**static checks carry their own TRACES tags** — an enforcement script is
|
||||
evidence for a requirement exactly as a unit test is. And the source roots are
|
||||
listed individually rather than as `scripts`, because the latter would walk
|
||||
`scripts/vendor/jray-project` and harvest the `AR-nnn` examples in the
|
||||
extractor's own docstrings as orphan tags.
|
||||
|
||||
`JR` is now what the shared tooling expects too — its example config names
|
||||
`jRay: ["JR"]` — so the prefix is settled across all three repos. It was chosen
|
||||
because `JRay-public-server` already ships `UR-001…018` and `DR-001…014`, and a
|
||||
second repo reusing those prefixes would make `UR-007` ambiguous across
|
||||
registers, which is precisely the ID the server's own register asks this one to
|
||||
cross-reference (see JR-025).
|
||||
@@ -0,0 +1,826 @@
|
||||
# Requirements traceability matrix
|
||||
|
||||
<!-- GENERATED FILE - do not edit by hand. -->
|
||||
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
|
||||
|
||||
**Generated:** 2026-07-31T15:05:02+00:00
|
||||
|
||||
Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, static`).
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 78 |
|
||||
| TRACES tags found | 48 |
|
||||
| EXCEPTION tags found | 0 |
|
||||
| Requirements defined | 47 |
|
||||
| Requirements covered | 38 |
|
||||
| **Coverage** | **80.9%** (38/47) |
|
||||
| Coverage of CI-executable scope | 84.4% (38/45) |
|
||||
| Tagged but unexecuted in CI | 1 |
|
||||
| Orphan tags | 0 |
|
||||
|
||||
### By type
|
||||
|
||||
| Type | Covered | Tagged but unexecuted | Defined |
|
||||
|---|---|---|---|
|
||||
| JR | 38 | 1 | 47 |
|
||||
|
||||
- **UT** tags present (separate taxonomy, not counted in coverage): UT-001, UT-002, UT-003, UT-004, UT-005, UT-007, UT-008, UT-009, UT-010, UT-011, UT-012, UT-013, UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-024, UT-025, UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037, UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044, UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052, UT-053, UT-054, UT-055, UT-056, UT-057
|
||||
- **PR** tags present (separate taxonomy, not counted in coverage): PR-001, PR-003, PR-004, PR-005, PR-006
|
||||
- **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-004, SR-005
|
||||
|
||||
## Not executable in CI
|
||||
|
||||
These requirements have no verification tier this repo's CI host can run, so a tag on them is evidence of *intent*, not of verification. They are never counted as covered.
|
||||
|
||||
| ID | Tiers | Tagged in source | Requirement |
|
||||
|---|---|---|---|
|
||||
| JR-020 | T4 | yes | Pause overlay: injected client script queries `jray?t=` and renders t… |
|
||||
| JR-040 | T4 | no | The config page states plainly that **each configured server multipli… |
|
||||
|
||||
**Tagged but unexecuted:** JR-020 — a test exists and is tagged, but this CI host cannot run it. Report those runs separately.
|
||||
|
||||
## Orphan tags
|
||||
|
||||
A tag naming an ID `requirements.md` does not define. This is what renumbering produces, and what a typo produces.
|
||||
|
||||
_None._
|
||||
|
||||
## Requirements tracing up to nothing
|
||||
|
||||
A register row whose `Traces to` cell names no parent. Work serving no stated goal is how scope creeps in, and it is invisible unless something looks.
|
||||
|
||||
_None._
|
||||
|
||||
## Recorded exceptions
|
||||
|
||||
Deliberate, documented departures from an invariant (`EXCEPTION: XX-nnn <reason>`). Reported separately and never counted as coverage — an exception is a decision to be reviewed, not evidence a requirement is met.
|
||||
|
||||
_None._
|
||||
|
||||
## Register
|
||||
|
||||
| ID | Status | Tier | Traces to | Trace state | Tagged in | Requirement |
|
||||
|---|---|---|---|---|---|---|
|
||||
| JR-001 | In Progress | static | SR-003 | covered | `Jellyfin.Plugin.JRay/Models/TruthFile.cs` | The truth-file format is normatively defined here; other repos refere… |
|
||||
| JR-002 | **Done** (UT-029…03… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs`, `Jellyfin.Plugin.JRay/Models/TruthCut.cs`, `Jellyfin.Plugin.JRay/Models/TruthExtraction.cs`, `Jellyfin.Plugin.JRay/Models/TruthFile.cs`, `Jellyfin.Plugin.JRay/Models/TruthScene.cs` | `schema_version: 2` shape — `extraction.*` provenance block, `cut.*` … |
|
||||
| JR-003 | **Done** (UT-033…03… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs`, `Jellyfin.Plugin.JRay/Controllers/TruthController.cs`, `Jellyfin.Plugin.JRay/Services/TruthSchema.cs` | Reject an unknown `schema_version`, never guess. **Flag day: v2 only*… |
|
||||
| JR-004 | **Done** (UT-021, U… | T1 | **SR-002** | covered | `Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs`, `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Models/TruthActor.cs`, `Jellyfin.Plugin.JRay/Models/TruthScene.cs`, `Jellyfin.Plugin.JRay/Services/PresenceLookup.cs` | A window is a **scene-membership claim**, not a recognition event — n… |
|
||||
| JR-005 | **Done** (UT-016…01… | T1 | **SR-002** | covered | `Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs`, `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Models/ActorInScene.cs`, `Jellyfin.Plugin.JRay/Services/PresenceLookup.cs`, `Jellyfin.Plugin.JRay/Web/jray-overlay.js` | Query semantics: actor present at `t` if any window contains `t`; pre… |
|
||||
| JR-006 | **Done** (UT-020, U… | T1 | SR-002 | covered | `Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs`, `Jellyfin.Plugin.JRay/Services/PresenceLookup.cs` | Read path holds up under **numerous** windows — no assumption of a ha… |
|
||||
| JR-007 | Done | T1 | SR-001 | covered | `Jellyfin.Plugin.JRay/Models/TruthActor.cs` | Identity is public identifiers: prefer `jellyfin_id` locally, else re… |
|
||||
| JR-008 | Done | T1 | PR-001 | covered | `Jellyfin.Plugin.JRay/Services/TruthDataService.cs` | Discover a sidecar truth file beside the media, by configurable suffix |
|
||||
| JR-009 | Done | T2 | PR-004 | covered | `Jellyfin.Plugin.JRay/Controllers/TruthController.cs`, `Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs` | Accept truth data pushed by a remote worker (`PUT`/`DELETE`), admin k… |
|
||||
| JR-010 | **Done** (UT-024…02… | T1 | PR-001 | covered | `Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs`, `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Models/TruthProvenance.cs`, `Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs`, `Jellyfin.Plugin.JRay/Services/TruthDataService.cs` | Precedence: managed truth (pushed **or** fetched) overrides a sidecar… |
|
||||
| JR-011 | Done | T1 | PR-001 | covered | `Jellyfin.Plugin.JRay/Services/TruthDataService.cs` | Loaded truth is cached; any write invalidates the item's cache entry … |
|
||||
| JR-012 | Done | T2 | PR-001 | covered | `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs` | `GET .../Timeline` returns the full truth file for an item |
|
||||
| JR-013 | Done | T2 | PR-001 | covered | `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs` | `GET .../jray?t=` returns an **extensible** context envelope; consume… |
|
||||
| JR-014 | Done | T2 | PR-004 | covered | `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Controllers/PolicyController.cs`, `Jellyfin.Plugin.JRay/Controllers/TruthController.cs`, `Jellyfin.Plugin.JRay/Controllers/WebController.cs` | Authorisation: reads need an authenticated user, admin routes need th… |
|
||||
| JR-015 | Done | T2 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/TasksController.cs` | `Tasks/Pending` serves a random sample of items with no truth data, s… |
|
||||
| JR-016 | **Done** (UT-007…01… | T1 | PR-003 | covered | `Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs`, `Jellyfin.Plugin.JRay/Controllers/PolicyController.cs`, `Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs`, `Jellyfin.Plugin.JRay/Services/PolicyResolver.cs` | Prioritise/ignore rules scoped `Genre` / `Series` / `Item`; **most sp… |
|
||||
| JR-017 | Done | T1 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/TasksController.cs` | Rules steer **work discovery only** — never the overlay or the read e… |
|
||||
| JR-018 | Done | T1 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/CoverageController.cs` | Coverage report by media type and genre; ignored items leave the perc… |
|
||||
| JR-019 | Done | T2 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/CoverageController.cs` | Picker endpoints (genres, series, item search) populate the rule edit… |
|
||||
| JR-020 | Done | T4 | **PR-001** | tagged, unexecuted | `Jellyfin.Plugin.JRay/Controllers/WebController.cs`, `Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs`, `Jellyfin.Plugin.JRay/Web/jray-overlay.js` | Pause overlay: injected client script queries `jray?t=` and renders t… |
|
||||
| JR-021 | **Done** | static | PR-004 | covered | `Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs`, `scripts/checks/no-index-injection.sh` | **jRay never injects into `index.html` on disk.** File Transformation… |
|
||||
| JR-022 | **Done** (UT-001…00… | T1 | PR-004 | covered | `Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs`, `Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs` | Migration: remove any on-disk patch left by an earlier jRay, identifi… |
|
||||
| JR-023 | **Done** (UT-012…01… | T1, T4 | PR-004 | covered | `Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs`, `Jellyfin.Plugin.JRay/Controllers/StatusController.cs`, `Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs` | Absent the dependency, disable **only** the overlay and say so in the… |
|
||||
| JR-024 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Web/jray-overlay.js` | Actor names and all server-supplied strings render as **text, never m… |
|
||||
| JR-025 | Done | T1 | PR-006 | covered | `Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs`, `Jellyfin.Plugin.JRay/Controllers/ManifestController.cs`, `Jellyfin.Plugin.JRay/Models/Jmanifest.cs`, `Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs`, `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | Query an **ordered list** of servers; first result clearing the confi… |
|
||||
| JR-026 | Planned | T1 | PR-006 | untagged | - | For a series, first-match applies per **episode** — later servers are… |
|
||||
| JR-027 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Models/Jmanifest.cs`, `Jellyfin.Plugin.JRay/Services/ManifestValidator.cs` | Treat **every** server as untrusted, including the default: re-valida… |
|
||||
| JR-028 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB… |
|
||||
| JR-029 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | HTTPS required for non-loopback servers; certificate validation must … |
|
||||
| JR-030 | Done | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay/Services/ManifestConverter.cs`, `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | Apply an `audio`-tier `offset` to **every** window before storing — s… |
|
||||
| JR-031 | Planned | T2 | PR-006 | covered | `Jellyfin.Plugin.JRay/Controllers/ManifestController.cs` | Fetch endpoints: item fetch, series bundle fetch, per-server status, … |
|
||||
| JR-032 | Planned | T1 | PR-006 | untagged | - | Identify is **never automatic** — storing a candidate is a separate c… |
|
||||
| JR-033 | Planned | T1 | PR-006 | untagged | - | Scheduled sweep over items lacking truth data, using the **batch** `e… |
|
||||
| JR-034 | Planned | T1 | PR-005 | untagged | - | Contribution strips `movie` and `jellyfin_id`, attaches identity from… |
|
||||
| JR-035 | Planned | T1 | PR-006 | untagged | - | Uploads set `Expect: 100-continue`, so a rejection lands before a bun… |
|
||||
| JR-036 | In Progress | T1 | PR-006 | covered | `Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs`, `Jellyfin.Plugin.JRay/Models/TruthProvenance.cs` | Minimum accepted match tier is configurable; a `loose` match surfaces… |
|
||||
| JR-037 | Done | T1 | PR-006 | covered | `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | A server that is unreachable or failing is skipped on a short timeout… |
|
||||
| JR-038 | Done | T1 | **PR-005** | covered | `Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs` | Every exchange feature is **opt-in and off by default**, including th… |
|
||||
| JR-039 | Planned | T1 | **PR-005** | untagged | - | No library-wide inventory in one request: batch `exists` capped at 10… |
|
||||
| JR-040 | Planned | T4 | **PR-005** | untagged | - | The config page states plainly that **each configured server multipli… |
|
||||
| JR-041 | Done | static | **SR-005** | covered | `scripts/checks/no-gallery-data.sh` | The plugin never fetches, stores, or transmits gallery data — referen… |
|
||||
| JR-042 | **Done** (UT-039…04… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignature.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs` | Compute the signature **exactly** per server spec §3, using the FFmpe… |
|
||||
| JR-043 | **Done** (UT-038, U… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs` | Golden-vector fixture **shared with the extraction repo**, proving th… |
|
||||
| JR-044 | **Done** (UT-045, U… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs` | Media shorter than 120 s: emit no signature and apply no sync offset … |
|
||||
| JR-045 | **Done** (UT-047…04… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs` | Emit and honour the signature's own `v1:` prefix, so a DSP change is … |
|
||||
| JR-046 | **TBD** | T2, T4 | [system §4](../scripts/vendor/jray-proj… | untagged | - | Review UI for unidentified track clusters: show context crops, pick f… |
|
||||
| JR-047 | **Done** (UT-053…05… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs`, `Jellyfin.Plugin.JRay/Models/TruthAlignment.cs`, `Jellyfin.Plugin.JRay/Services/ManifestAligner.cs` | **A fetched manifest is aligned against the local file before its win… |
|
||||
|
||||
## Detailed mapping
|
||||
|
||||
### JR-001
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile`
|
||||
|
||||
### JR-002
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthCut.cs:15`](../Jellyfin.Plugin.JRay/Models/TruthCut.cs#L15) — `public class TruthCut`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthExtraction.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthExtraction.cs#L21) — `public class TruthExtraction`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### JR-003
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/TruthSchema.cs:35`](../Jellyfin.Plugin.JRay/Services/TruthSchema.cs#L35) — `public static class TruthSchema`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### JR-004
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
|
||||
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### JR-005
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/ActorInScene.cs:15`](../Jellyfin.Plugin.JRay/Models/ActorInScene.cs#L15) — `public class ActorInScene`
|
||||
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
|
||||
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### JR-006
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### JR-007
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
|
||||
|
||||
### JR-008
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
|
||||
|
||||
### JR-009
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs:21`](../Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs#L21) — `public sealed class ManagedTruthStore : IManagedTruthStore`
|
||||
|
||||
### JR-010
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthProvenance.cs:38`](../Jellyfin.Plugin.JRay/Models/TruthProvenance.cs#L38) — `public class TruthProvenance`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs:21`](../Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs#L21) — `public sealed class ManagedTruthStore : IManagedTruthStore`
|
||||
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
|
||||
|
||||
### JR-011
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
|
||||
|
||||
### JR-012
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
|
||||
### JR-013
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
|
||||
### JR-014
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/PolicyController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/PolicyController.cs#L24) — `public class PolicyController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/WebController.cs:21`](../Jellyfin.Plugin.JRay/Controllers/WebController.cs#L21) — `public class WebController : ControllerBase`
|
||||
|
||||
### JR-015
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TasksController.cs:34`](../Jellyfin.Plugin.JRay/Controllers/TasksController.cs#L34) — `public class TasksController : ControllerBase`
|
||||
|
||||
### JR-016
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/PolicyController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/PolicyController.cs#L24) — `public class PolicyController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs:20`](../Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs#L20) — `public sealed class MediaPolicyStore : IMediaPolicyStore`
|
||||
- [`Jellyfin.Plugin.JRay/Services/PolicyResolver.cs:14`](../Jellyfin.Plugin.JRay/Services/PolicyResolver.cs#L14) — `public static class PolicyResolver`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
|
||||
|
||||
### JR-017
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TasksController.cs:34`](../Jellyfin.Plugin.JRay/Controllers/TasksController.cs#L34) — `public class TasksController : ControllerBase`
|
||||
|
||||
### JR-018
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/CoverageController.cs:30`](../Jellyfin.Plugin.JRay/Controllers/CoverageController.cs#L30) — `public class CoverageController : ControllerBase`
|
||||
|
||||
### JR-019
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/CoverageController.cs:30`](../Jellyfin.Plugin.JRay/Controllers/CoverageController.cs#L30) — `public class CoverageController : ControllerBase`
|
||||
|
||||
### JR-020
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/WebController.cs:21`](../Jellyfin.Plugin.JRay/Controllers/WebController.cs#L21) — `public class WebController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs:31`](../Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs#L31) — `public static class FileTransformationRegistration`
|
||||
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
|
||||
|
||||
### JR-021
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs:26`](../Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs#L26) — `public static class WebClientPatchService`
|
||||
- [`scripts/checks/no-index-injection.sh:11`](../scripts/checks/no-index-injection.sh#L11) — `Unknown`
|
||||
|
||||
### JR-022
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs:26`](../Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs#L26) — `public static class WebClientPatchService`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
|
||||
|
||||
### JR-023
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/StatusController.cs:20`](../Jellyfin.Plugin.JRay/Controllers/StatusController.cs#L20) — `public class StatusController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs:31`](../Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs#L31) — `public static class FileTransformationRegistration`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
|
||||
|
||||
### JR-024
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
|
||||
|
||||
### JR-025
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs:75`](../Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs#L75) — `public class ManifestServer`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ManifestController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/ManifestController.cs#L24) — `public class ManifestController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest`
|
||||
- [`Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs:16`](../Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs#L16) — `public interface IManifestExchangeClient`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
|
||||
|
||||
### JR-027
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestValidator.cs:26`](../Jellyfin.Plugin.JRay/Services/ManifestValidator.cs#L26) — `public static class ManifestValidator`
|
||||
|
||||
### JR-028
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:282`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L282) — `private static async Task<string?> ReadCappedAsync(`
|
||||
|
||||
### JR-029
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
|
||||
|
||||
### JR-030
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
|
||||
|
||||
### JR-031
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ManifestController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/ManifestController.cs#L24) — `public class ManifestController : ControllerBase`
|
||||
|
||||
### JR-036
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs:16`](../Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs#L16) — `public class PluginConfiguration : BasePluginConfiguration`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthProvenance.cs:38`](../Jellyfin.Plugin.JRay/Models/TruthProvenance.cs#L38) — `public class TruthProvenance`
|
||||
|
||||
### JR-037
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
|
||||
|
||||
### JR-038
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs:16`](../Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs#L16) — `public class PluginConfiguration : BasePluginConfiguration`
|
||||
|
||||
### JR-041
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`scripts/checks/no-gallery-data.sh:14`](../scripts/checks/no-gallery-data.sh#L14) — `Unknown`
|
||||
|
||||
### JR-042
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignature.cs:49`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs#L49) — `public static class AudioSignature`
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs:39`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs#L39) — `public class AudioSignatureService`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### JR-043
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### JR-044
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### JR-045
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### JR-047
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthAlignment.cs:17`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs#L17) — `public class TruthAlignment`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestAligner.cs:36`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs#L36) — `public class ManifestAligner`
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
|
||||
|
||||
### PR-001
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/WebController.cs:21`](../Jellyfin.Plugin.JRay/Controllers/WebController.cs#L21) — `public class WebController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthProvenance.cs:38`](../Jellyfin.Plugin.JRay/Models/TruthProvenance.cs#L38) — `public class TruthProvenance`
|
||||
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
|
||||
|
||||
### PR-003
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/CoverageController.cs:30`](../Jellyfin.Plugin.JRay/Controllers/CoverageController.cs#L30) — `public class CoverageController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/PolicyController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/PolicyController.cs#L24) — `public class PolicyController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TasksController.cs:34`](../Jellyfin.Plugin.JRay/Controllers/TasksController.cs#L34) — `public class TasksController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs:20`](../Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs#L20) — `public sealed class MediaPolicyStore : IMediaPolicyStore`
|
||||
- [`Jellyfin.Plugin.JRay/Services/PolicyResolver.cs:14`](../Jellyfin.Plugin.JRay/Services/PolicyResolver.cs#L14) — `public static class PolicyResolver`
|
||||
|
||||
### PR-004
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/StatusController.cs:20`](../Jellyfin.Plugin.JRay/Controllers/StatusController.cs#L20) — `public class StatusController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs:31`](../Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs#L31) — `public static class FileTransformationRegistration`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs:21`](../Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs#L21) — `public sealed class ManagedTruthStore : IManagedTruthStore`
|
||||
- [`Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs:26`](../Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs#L26) — `public static class WebClientPatchService`
|
||||
- [`scripts/checks/no-index-injection.sh:11`](../scripts/checks/no-index-injection.sh#L11) — `Unknown`
|
||||
|
||||
### PR-005
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs:75`](../Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs#L75) — `public class ManifestServer`
|
||||
- [`Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs:16`](../Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs#L16) — `public class PluginConfiguration : BasePluginConfiguration`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
|
||||
|
||||
### PR-006
|
||||
|
||||
**Locations:** 4
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs:75`](../Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs#L75) — `public class ManifestServer`
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ManifestController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/ManifestController.cs#L24) — `public class ManifestController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs:16`](../Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs#L16) — `public interface IManifestExchangeClient`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
|
||||
|
||||
### SR-001
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
|
||||
|
||||
### SR-002
|
||||
|
||||
**Locations:** 7
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/ActorInScene.cs:15`](../Jellyfin.Plugin.JRay/Models/ActorInScene.cs#L15) — `public class ActorInScene`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter`
|
||||
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
|
||||
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
|
||||
|
||||
### SR-003
|
||||
|
||||
**Locations:** 13
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
|
||||
- [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthAlignment.cs:17`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs#L17) — `public class TruthAlignment`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthCut.cs:15`](../Jellyfin.Plugin.JRay/Models/TruthCut.cs#L15) — `public class TruthCut`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthExtraction.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthExtraction.cs#L21) — `public class TruthExtraction`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile`
|
||||
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignature.cs:49`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs#L49) — `public static class AudioSignature`
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher`
|
||||
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs:39`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs#L39) — `public class AudioSignatureService`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestAligner.cs:36`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs#L36) — `public class ManifestAligner`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter`
|
||||
- [`Jellyfin.Plugin.JRay/Services/TruthSchema.cs:35`](../Jellyfin.Plugin.JRay/Services/TruthSchema.cs#L35) — `public static class TruthSchema`
|
||||
|
||||
### SR-004
|
||||
|
||||
**Locations:** 3
|
||||
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:282`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L282) — `private static async Task<string?> ReadCappedAsync(`
|
||||
- [`Jellyfin.Plugin.JRay/Services/ManifestValidator.cs:26`](../Jellyfin.Plugin.JRay/Services/ManifestValidator.cs#L26) — `public static class ManifestValidator`
|
||||
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
|
||||
|
||||
### SR-005
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`scripts/checks/no-gallery-data.sh:14`](../scripts/checks/no-gallery-data.sh#L14) — `Unknown`
|
||||
|
||||
### UT-001
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
|
||||
|
||||
### UT-002
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
|
||||
|
||||
### UT-003
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
|
||||
|
||||
### UT-004
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
|
||||
|
||||
### UT-005
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
|
||||
|
||||
### UT-007
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
|
||||
|
||||
### UT-008
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
|
||||
|
||||
### UT-009
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
|
||||
|
||||
### UT-010
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
|
||||
|
||||
### UT-011
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
|
||||
|
||||
### UT-012
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
|
||||
|
||||
### UT-013
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
|
||||
|
||||
### UT-014
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
|
||||
|
||||
### UT-015
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
|
||||
|
||||
### UT-016
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-017
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-018
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-019
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-020
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-021
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-022
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-023
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
|
||||
|
||||
### UT-024
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
|
||||
|
||||
### UT-025
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
|
||||
|
||||
### UT-026
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
|
||||
|
||||
### UT-027
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
|
||||
|
||||
### UT-028
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
|
||||
|
||||
### UT-029
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-030
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-031
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-032
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-033
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-034
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-035
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-036
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-037
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
|
||||
|
||||
### UT-038
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-039
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-040
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-041
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-042
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-043
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-044
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
|
||||
|
||||
### UT-045
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-046
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-047
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-048
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-049
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-050
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-051
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-052
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
|
||||
|
||||
### UT-053
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
|
||||
|
||||
### UT-054
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
|
||||
|
||||
### UT-055
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
|
||||
|
||||
### UT-056
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
|
||||
|
||||
### UT-057
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
|
||||
|
||||
@@ -7,6 +7,30 @@
|
||||
"owner": "dtourolle",
|
||||
"category": "General",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
|
||||
"checksum": "d3419f1867499fbc63fb485fb7c6c143",
|
||||
"timestamp": "2026-07-31T09:37:36Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.4",
|
||||
"changelog": "Release 0.0.4",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.4/jray_0.0.4.0.zip",
|
||||
"checksum": "cb9613660b3fe3b730f46c9c7f257333",
|
||||
"timestamp": "2026-07-04T19:47:05Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
|
||||
"checksum": "a76316397228c9d8b8e38fbccad50390",
|
||||
"timestamp": "2026-07-04T19:44:35Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.3",
|
||||
"changelog": "Release 0.0.3",
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# JR-041 — the plugin never fetches, stores, or transmits gallery data.
|
||||
#
|
||||
# Like JR-021 this is a requirement to *not do* something, so it is verified by
|
||||
# absence. It mirrors the server's UR-012, which is preserved the same way: there
|
||||
# is no field capable of carrying an embedding or a crop, and no code that would
|
||||
# read one.
|
||||
#
|
||||
# The plugin's whole contact with identity is public identifiers (JR-007) and
|
||||
# scene windows (JR-004). A reference face or a 512-d vector arriving here would
|
||||
# mean SR-005 had been breached upstream, so the check is for any *parse* of one
|
||||
# — a field name, a property, a type — not merely for network calls.
|
||||
#
|
||||
# TRACES: JR-041 | SR-005
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
roots=("Jellyfin.Plugin.JRay")
|
||||
status=0
|
||||
|
||||
# Field and property names that would carry gallery data. Matched as whole words
|
||||
# so that unrelated identifiers containing them are not false positives.
|
||||
banned='\b(embedding|embeddings|face_crop|faceCrop|FaceCrop|reference_face|referenceFace|ReferenceFace|mugshot|Mugshot|gallery_vector|galleryVector|descriptor512|Embedding)\b'
|
||||
|
||||
if grep -rn --include='*.cs' --include='*.js' -E "$banned" "${roots[@]}"; then
|
||||
echo "FAIL (JR-041): a gallery-data field or type is referenced in the plugin." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# A base64 blob is the other shape this could arrive in. The plugin has no
|
||||
# legitimate reason to decode one: every string it handles is a name, an id, or
|
||||
# a URL.
|
||||
if grep -rn --include='*.cs' -E '\bConvert\.FromBase64String\b' "${roots[@]}"; then
|
||||
echo "FAIL (JR-041): base64 decoding found — the plugin handles no binary payloads." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "OK (JR-041): no gallery-data code path."
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# JR-021 — jRay never injects into index.html on disk.
|
||||
#
|
||||
# This is a requirement to *not do* something, so it is verified by absence.
|
||||
# A unit test cannot show that no code path writes the tag; a grep can.
|
||||
#
|
||||
# The prohibition is on injection, not on writing: JR-022's migration must write
|
||||
# to index.html in order to remove a legacy patch. So the check is for code that
|
||||
# *adds* the script tag, not for File.Write* generally.
|
||||
#
|
||||
# TRACES: JR-021 | PR-004
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
src="Jellyfin.Plugin.JRay"
|
||||
status=0
|
||||
|
||||
# The injection is "script tag + marker" written back to the file. The removal
|
||||
# path also names both, so match on the concatenation that builds a patched
|
||||
# document rather than on the constants themselves.
|
||||
if grep -rn --include='*.cs' -E '(ScriptTag|Injected)[[:space:]]*\+.*BodyClose|ReplaceLast|"</body>"[[:space:]]*,' "$src" \
|
||||
| grep -v 'FileTransformationRegistration.cs'; then
|
||||
echo "FAIL (JR-021): index.html injection logic found outside the File Transformation callback." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# WebClientPatchService is removal-only. Any write there must be the cleaned
|
||||
# document; a write of a *patched* one is the regression this guards.
|
||||
if grep -n -E 'WriteAllText\((?!.*cleaned)' -P "$src/Services/WebClientPatchService.cs" >/dev/null 2>&1; then
|
||||
echo "FAIL (JR-021): WebClientPatchService writes something other than the cleaned document." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# The disk-patching entry point must not come back.
|
||||
if grep -rn --include='*.cs' -E '\bWebClientPatchService\.Apply\b' "$src"; then
|
||||
echo "FAIL (JR-021): the injecting Apply() entry point has been reintroduced." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "OK (JR-021): no on-disk injection path."
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
+1
Submodule scripts/vendor/jray-project added at 17106f3370
@@ -0,0 +1,31 @@
|
||||
# traceability.toml — per-repo configuration for the shared trace extractor.
|
||||
# The extractor itself is vendored at scripts/vendor/jray-project.
|
||||
|
||||
# Flat, rather than split by theme as scene-actor-extraction is. The plugin is
|
||||
# one deployable with one audience, and JRay-public-server already ships UR/DR
|
||||
# — a second repo using those prefixes would make UR-007 ambiguous across
|
||||
# registers, and UR-007 is precisely the ID the server asks this register to
|
||||
# cross-reference (see JR-025).
|
||||
requirement_types = ["JR"]
|
||||
|
||||
languages = ["csharp", "javascript"]
|
||||
|
||||
# The static checks carry TRACES tags of their own. An enforcement script is
|
||||
# evidence for a requirement exactly as a unit test is — JR-021 is a
|
||||
# prohibition, and a prohibition can only be verified by absence.
|
||||
source_suffixes = [".sh"]
|
||||
|
||||
# Explicit roots rather than "scripts", which would walk scripts/vendor and
|
||||
# harvest the AR-nnn examples in the extractor's own docstrings as orphan tags.
|
||||
source_roots = [
|
||||
"Jellyfin.Plugin.JRay",
|
||||
"Jellyfin.Plugin.JRay.Tests",
|
||||
"scripts/checks",
|
||||
]
|
||||
|
||||
# jRay has two CI tiers and one live tier. T3 is deliberately unused: T4 keeps
|
||||
# the meaning it has in scene-actor-extraction — "no CI host can run this" —
|
||||
# so a tier number means the same thing when read across repos.
|
||||
ci_executable_tiers = ["T1", "T2", "static"]
|
||||
|
||||
system_spec = "scripts/vendor/jray-project/SPEC.md"
|
||||
Reference in New Issue
Block a user