Compare commits
23
Commits
f11e5508ad
...
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5152f6f129 | ||
|
|
c04d5a3dcc | ||
|
|
305b898b15 | ||
|
|
12f076cf85 | ||
|
|
3d210b5bd3 | ||
|
|
64f549ab35 | ||
|
|
2926740d03 | ||
|
|
0fafa84158 | ||
|
|
cc973fd7c5 | ||
|
|
e16f903469 | ||
|
|
f4e8fb5dea | ||
|
|
2d043aeee3 | ||
|
|
5159692364 | ||
|
|
d786d56368 | ||
|
|
d9a38bb7fb | ||
|
|
3b24fe1b3c | ||
|
|
32f976e79f | ||
|
|
8e1679faed | ||
|
|
13471e21fb | ||
|
|
2ffbd2f50e | ||
|
|
edb93e1028 | ||
|
|
f6762fcf29 | ||
|
|
bb02c0f9b9 |
@@ -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,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,56 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
</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 = 1, Movie = "/m.mkv" };
|
||||||
|
var actor = new TruthActor { Name = "A", TmdbId = "884" };
|
||||||
|
actor.Scenes.Add([1.0, 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,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(new[] { 140.0, 160.0 }, truth.Actors[0].Scenes[0]);
|
||||||
|
Assert.Equal(new[] { 240.0, 260.0 }, truth.Actors[0].Scenes[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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][0]);
|
||||||
|
Assert.True(truth.Actors[0].Scenes[0][1] >= truth.Actors[0].Scenes[0][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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(new[] { 10.0, 20.0 }, truth.Actors[0].Scenes[0]);
|
||||||
|
Assert.Equal(new[] { 20.0, 30.0 }, truth.Actors[0].Scenes[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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,150 @@
|
|||||||
|
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(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
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":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2,
|
||||||
|
"actors":[{"name":"A","imdb_id":"","tmdb_id":"884","jellyfin_id":"",
|
||||||
|
"scenes":[[0.0,10.0],[10.0,20.0],[30.0,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]);
|
||||||
|
Assert.Equal([10.0, 20.0], windows[1]);
|
||||||
|
Assert.Equal([30.0, 30.0], windows[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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([w * 10.0, (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,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -12,7 +14,7 @@ Global
|
|||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
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|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.ActiveCfg = Debug|Any CPU
|
||||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = 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|x64.Build.0 = Release|Any CPU
|
||||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
EndGlobal
|
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;
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.JRay.Configuration;
|
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||||
@@ -5,8 +6,28 @@ namespace Jellyfin.Plugin.JRay.Configuration;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plugin configuration.
|
/// Plugin configuration.
|
||||||
/// </summary>
|
/// </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
|
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>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -15,6 +36,24 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
TruthFileSuffix = ".jray.json";
|
TruthFileSuffix = ".jray.json";
|
||||||
CacheDurationMinutes = 60;
|
CacheDurationMinutes = 60;
|
||||||
EnableOverlay = true;
|
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>
|
/// <summary>
|
||||||
@@ -37,4 +76,62 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// any previously injected script is removed.
|
/// any previously injected script is removed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool EnableOverlay { get; set; }
|
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,16 +29,82 @@
|
|||||||
<span>Enable pause overlay</span>
|
<span>Enable pause overlay</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
Injects a small script into the web client that shows on-screen actors
|
Shows the cast of the current scene when playback is paused. This
|
||||||
when playback is paused. Disabling this removes the injected script.
|
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>
|
</div>
|
||||||
|
<div id="JRayDependencyStatus" class="fieldDescription" style="margin:0 0 1.5em;padding:0.75em 1em;border-radius:0.25em;display:none;"></div>
|
||||||
<div>
|
<div>
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<h2 style="margin-top:2em;">Coverage</h2>
|
||||||
|
<p class="fieldDescription">
|
||||||
|
How much of your library has scene-actor truth data. Ignored items are
|
||||||
|
excluded from the "to do" total, so blacklisting a genre or series
|
||||||
|
won't drag your progress down.
|
||||||
|
</p>
|
||||||
|
<div id="JRayCoverageSummary" style="margin-bottom:1em;">Loading…</div>
|
||||||
|
<div id="JRayCoverageBar" style="height:1.25em;border-radius:0.25em;overflow:hidden;background:rgba(127,127,127,0.25);display:flex;margin-bottom:0.5em;"></div>
|
||||||
|
<div id="JRayCoverageLegend" class="fieldDescription" style="margin-bottom:1.5em;"></div>
|
||||||
|
|
||||||
|
<details style="margin-bottom:1.5em;">
|
||||||
|
<summary style="cursor:pointer;">By media type</summary>
|
||||||
|
<div id="JRayCoverageByType" style="margin-top:0.5em;"></div>
|
||||||
|
</details>
|
||||||
|
<details style="margin-bottom:1.5em;">
|
||||||
|
<summary style="cursor:pointer;">By genre</summary>
|
||||||
|
<div id="JRayCoverageByGenre" style="margin-top:0.5em;"></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2 style="margin-top:2em;">Prioritise / ignore rules</h2>
|
||||||
|
<p class="fieldDescription">
|
||||||
|
Shape the work-discovery API (<code>/Plugins/JRay/Tasks/Pending</code>).
|
||||||
|
<strong>Ignore</strong> hides matching items from extraction workers;
|
||||||
|
<strong>Prioritise</strong> pushes them to the front of the queue. A more
|
||||||
|
specific rule wins (item > series > genre). Setting a
|
||||||
|
rule for a target that already has one replaces it, so nothing can be both
|
||||||
|
prioritised and ignored.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5em;align-items:flex-end;margin-bottom:1em;">
|
||||||
|
<div class="selectContainer" style="margin:0;">
|
||||||
|
<label class="selectLabel" for="JRayRuleScope">Scope</label>
|
||||||
|
<select id="JRayRuleScope" is="emby-select">
|
||||||
|
<option value="Genre">Genre</option>
|
||||||
|
<option value="Series">Series</option>
|
||||||
|
<option value="Item">Item</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="JRayItemSearchContainer" class="inputContainer" style="margin:0;flex:1;min-width:14em;display:none;">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="JRayItemSearch">Search title</label>
|
||||||
|
<input id="JRayItemSearch" type="text" is="emby-input" placeholder="Type a movie or episode name…" />
|
||||||
|
</div>
|
||||||
|
<div class="selectContainer" style="margin:0;flex:1;min-width:14em;">
|
||||||
|
<label class="selectLabel" for="JRayRuleValue">Target</label>
|
||||||
|
<select id="JRayRuleValue" is="emby-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="selectContainer" style="margin:0;">
|
||||||
|
<label class="selectLabel" for="JRayRuleAction">Action</label>
|
||||||
|
<select id="JRayRuleAction" is="emby-select">
|
||||||
|
<option value="Ignore">Ignore</option>
|
||||||
|
<option value="Prioritise">Prioritise</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button id="JRayAddRule" is="emby-button" type="button" class="raised emby-button">
|
||||||
|
<span>Add rule</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="JRayRulesList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -46,6 +112,256 @@
|
|||||||
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
|
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function jrayUrl(path) {
|
||||||
|
return ApiClient.getUrl('Plugins/JRay/' + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jrayApi(path, method, body) {
|
||||||
|
var opts = {
|
||||||
|
url: jrayUrl(path),
|
||||||
|
type: method || 'GET'
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.data = JSON.stringify(body);
|
||||||
|
opts.contentType = 'application/json';
|
||||||
|
}
|
||||||
|
if (!method || method === 'GET') {
|
||||||
|
opts.dataType = 'json';
|
||||||
|
}
|
||||||
|
return ApiClient.ajax(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.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 = {
|
||||||
|
covered: '#4caf50',
|
||||||
|
prioritised: '#ff9800',
|
||||||
|
pending: '#9e9e9e',
|
||||||
|
ignored: 'rgba(127,127,127,0.35)'
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderBar(el, counts) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
var order = [
|
||||||
|
['covered', counts.covered],
|
||||||
|
['prioritised', counts.prioritised],
|
||||||
|
['pending', counts.pending - counts.prioritised],
|
||||||
|
['ignored', counts.ignored]
|
||||||
|
];
|
||||||
|
order.forEach(function (seg) {
|
||||||
|
if (counts.total <= 0 || seg[1] <= 0) { return; }
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.style.background = JRayCoverColors[seg[0]];
|
||||||
|
d.style.width = (100 * seg[1] / counts.total) + '%';
|
||||||
|
el.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(counts) {
|
||||||
|
var denom = counts.total - counts.ignored;
|
||||||
|
if (denom <= 0) { return '—'; }
|
||||||
|
return Math.round(100 * counts.covered / denom) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBreakdown(el, rows) {
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
el.textContent = 'Nothing to show.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table style="width:100%;border-collapse:collapse;">' +
|
||||||
|
'<thead><tr style="text-align:left;">' +
|
||||||
|
'<th>Name</th><th>Covered</th><th>Pending</th><th>Ignored</th><th>Total</th><th>%</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = r.counts;
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + escapeHtml(r.label) + '</td>' +
|
||||||
|
'<td>' + c.covered + '</td>' +
|
||||||
|
'<td>' + c.pending + (c.prioritised ? ' (' + c.prioritised + ' ★)' : '') + '</td>' +
|
||||||
|
'<td>' + c.ignored + '</td>' +
|
||||||
|
'<td>' + c.total + '</td>' +
|
||||||
|
'<td>' + pct(c) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCoverage() {
|
||||||
|
document.querySelector('#JRayCoverageSummary').textContent = 'Loading…';
|
||||||
|
jrayApi('Coverage').then(function (report) {
|
||||||
|
var t = report.total;
|
||||||
|
document.querySelector('#JRayCoverageSummary').innerHTML =
|
||||||
|
'<strong>' + pct(t) + '</strong> covered — ' +
|
||||||
|
t.covered + ' of ' + (t.total - t.ignored) + ' items processed' +
|
||||||
|
(t.ignored ? ' (' + t.ignored + ' ignored)' : '') + '.';
|
||||||
|
renderBar(document.querySelector('#JRayCoverageBar'), t);
|
||||||
|
document.querySelector('#JRayCoverageLegend').innerHTML =
|
||||||
|
'<span style="color:' + JRayCoverColors.covered + '">■</span> covered ' +
|
||||||
|
'<span style="color:' + JRayCoverColors.prioritised + '">■</span> prioritised ' +
|
||||||
|
'<span style="color:' + JRayCoverColors.pending + '">■</span> pending ' +
|
||||||
|
'<span>▨</span> ignored';
|
||||||
|
renderBreakdown(document.querySelector('#JRayCoverageByType'), report.by_media_type);
|
||||||
|
renderBreakdown(document.querySelector('#JRayCoverageByGenre'), report.by_genre);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rules ----
|
||||||
|
|
||||||
|
var JRayOptionCache = { Genre: null, Series: null };
|
||||||
|
|
||||||
|
function loadOptionsFor(scope) {
|
||||||
|
if (JRayOptionCache[scope]) {
|
||||||
|
return Promise.resolve(JRayOptionCache[scope]);
|
||||||
|
}
|
||||||
|
var path = scope === 'Series' ? 'Coverage/Series' : 'Coverage/Genres';
|
||||||
|
return jrayApi(path).then(function (opts) {
|
||||||
|
JRayOptionCache[scope] = opts;
|
||||||
|
return opts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(select, opts, emptyText) {
|
||||||
|
if (!opts.length) {
|
||||||
|
select.innerHTML = '<option value="">' + emptyText + '</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.innerHTML = opts.map(function (o) {
|
||||||
|
return '<option value="' + escapeHtml(o.value) + '">' + escapeHtml(o.label) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateValueSelect() {
|
||||||
|
var scope = document.querySelector('#JRayRuleScope').value;
|
||||||
|
var select = document.querySelector('#JRayRuleValue');
|
||||||
|
var isItem = scope === 'Item';
|
||||||
|
document.querySelector('#JRayItemSearchContainer').style.display = isItem ? '' : 'none';
|
||||||
|
|
||||||
|
if (isItem) {
|
||||||
|
// The target dropdown is filled from a live search instead of a full list.
|
||||||
|
select.innerHTML = '<option value="">Type a title to search…</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
loadOptionsFor(scope).then(function (opts) {
|
||||||
|
fillSelect(select, opts, '(none found)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var JRayItemSearchTimer = null;
|
||||||
|
function onItemSearch() {
|
||||||
|
var term = document.querySelector('#JRayItemSearch').value.trim();
|
||||||
|
var select = document.querySelector('#JRayRuleValue');
|
||||||
|
clearTimeout(JRayItemSearchTimer);
|
||||||
|
if (!term) {
|
||||||
|
select.innerHTML = '<option value="">Type a title to search…</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.innerHTML = '<option value="">Searching…</option>';
|
||||||
|
JRayItemSearchTimer = setTimeout(function () {
|
||||||
|
jrayApi('Coverage/Items?search=' + encodeURIComponent(term)).then(function (opts) {
|
||||||
|
fillSelect(select, opts, '(no matches)');
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForRule(rule) {
|
||||||
|
if (rule.label) { return rule.label; }
|
||||||
|
var cache = JRayOptionCache[rule.scope];
|
||||||
|
if (cache) {
|
||||||
|
var hit = cache.filter(function (o) { return o.value === rule.value; })[0];
|
||||||
|
if (hit) { return hit.label; }
|
||||||
|
}
|
||||||
|
return rule.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRules() {
|
||||||
|
jrayApi('Policy/Rules').then(function (rules) {
|
||||||
|
var el = document.querySelector('#JRayRulesList');
|
||||||
|
if (!rules.length) {
|
||||||
|
el.innerHTML = '<p class="fieldDescription">No rules yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table style="width:100%;border-collapse:collapse;">' +
|
||||||
|
'<thead><tr style="text-align:left;"><th>Action</th><th>Scope</th><th>Target</th><th></th></tr></thead><tbody>';
|
||||||
|
rules.forEach(function (r) {
|
||||||
|
var badge = r.action === 'Prioritise'
|
||||||
|
? '<span style="color:' + JRayCoverColors.prioritised + '">★ Prioritise</span>'
|
||||||
|
: '<span>⦸ Ignore</span>';
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + badge + '</td>' +
|
||||||
|
'<td>' + escapeHtml(r.scope) + '</td>' +
|
||||||
|
'<td>' + escapeHtml(labelForRule(r)) + '</td>' +
|
||||||
|
'<td><button is="emby-button" type="button" class="emby-button jray-del" ' +
|
||||||
|
'data-scope="' + escapeHtml(r.scope) + '" data-value="' + escapeHtml(r.value) + '">Remove</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(el.querySelectorAll('.jray-del'), function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var q = 'Policy/Rules?scope=' + encodeURIComponent(btn.getAttribute('data-scope')) +
|
||||||
|
'&value=' + encodeURIComponent(btn.getAttribute('data-value'));
|
||||||
|
jrayApi(q, 'DELETE').then(function () {
|
||||||
|
loadRules();
|
||||||
|
loadCoverage();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRule() {
|
||||||
|
var scope = document.querySelector('#JRayRuleScope').value;
|
||||||
|
var valueSelect = document.querySelector('#JRayRuleValue');
|
||||||
|
var value = valueSelect.value;
|
||||||
|
if (!value) { return; }
|
||||||
|
var rule = {
|
||||||
|
scope: scope,
|
||||||
|
value: value,
|
||||||
|
action: document.querySelector('#JRayRuleAction').value,
|
||||||
|
label: valueSelect.options[valueSelect.selectedIndex].text
|
||||||
|
};
|
||||||
|
jrayApi('Policy/Rules', 'PUT', rule).then(function () {
|
||||||
|
loadRules();
|
||||||
|
loadCoverage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector('#JRayConfigPage')
|
document.querySelector('#JRayConfigPage')
|
||||||
.addEventListener('pageshow', function() {
|
.addEventListener('pageshow', function() {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
@@ -55,8 +371,17 @@
|
|||||||
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
|
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadDependencyStatus();
|
||||||
|
populateValueSelect();
|
||||||
|
loadRules();
|
||||||
|
loadCoverage();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector('#JRayRuleScope').addEventListener('change', populateValueSelect);
|
||||||
|
document.querySelector('#JRayItemSearch').addEventListener('input', onItemSearch);
|
||||||
|
document.querySelector('#JRayAddRule').addEventListener('click', addRule);
|
||||||
|
|
||||||
document.querySelector('#JRayConfigForm')
|
document.querySelector('#JRayConfigForm')
|
||||||
.addEventListener('submit', function(e) {
|
.addEventListener('submit', function(e) {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.JRay.Models;
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -11,12 +11,19 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace Jellyfin.Plugin.JRay.Controllers;
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
|
/// Exposes scene-actor-extraction "truth" data: which actors are present in
|
||||||
/// at a given timestamp in a movie.
|
/// the scene at a given timestamp.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Items/{itemId}")]
|
[Route("Plugins/JRay/Items/{itemId}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
// TRACES: JR-004, JR-005, JR-010, JR-012, JR-013, JR-014 | SR-002
|
||||||
public class ActorsController : ControllerBase
|
public class ActorsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITruthDataService _truthDataService;
|
private readonly ITruthDataService _truthDataService;
|
||||||
@@ -31,7 +38,7 @@ public class ActorsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="itemId">The Jellyfin item id.</param>
|
/// <param name="itemId">The Jellyfin item id.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
@@ -51,7 +58,26 @@ public class ActorsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.)
|
/// This is an extensible envelope — future fields (locations, trivia, etc.)
|
||||||
/// will be added here without changing the route.
|
/// will be added here without changing the route.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -71,9 +97,9 @@ public class ActorsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var context = new JRayContext();
|
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,
|
Name = actor.Name,
|
||||||
ImdbId = actor.ImdbId,
|
ImdbId = actor.ImdbId,
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
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;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports how much of the library has truth data (overall, by media type, and
|
||||||
|
/// 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;
|
||||||
|
private readonly ITruthDataService _truthDataService;
|
||||||
|
private readonly IMediaPolicyStore _policyStore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CoverageController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The Jellyfin library manager.</param>
|
||||||
|
/// <param name="truthDataService">The truth data service.</param>
|
||||||
|
/// <param name="policyStore">The media policy store.</param>
|
||||||
|
public CoverageController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_truthDataService = truthDataService;
|
||||||
|
_policyStore = policyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the coverage report: totals plus breakdowns by media type and by genre.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The coverage report.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<CoverageReport> GetCoverage()
|
||||||
|
{
|
||||||
|
var rules = _policyStore.GetRules();
|
||||||
|
|
||||||
|
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
|
||||||
|
IsVirtualItem = false,
|
||||||
|
Recursive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = new CoverageReport();
|
||||||
|
var byMediaType = new Dictionary<string, CoverageCounts>(StringComparer.Ordinal);
|
||||||
|
var byGenre = new Dictionary<string, CoverageCounts>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (!TasksController.MediaFileExists(item))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var covered = _truthDataService.HasTruth(item.Id, item.Path);
|
||||||
|
var action = PolicyResolver.Resolve(rules, item.Id, TasksController.GetSeriesId(item), item.Genres);
|
||||||
|
|
||||||
|
Accumulate(report.Total, covered, action);
|
||||||
|
Accumulate(GetBucket(byMediaType, MediaTypeLabel(item)), covered, action);
|
||||||
|
|
||||||
|
foreach (var genre in item.Genres)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
|
{
|
||||||
|
Accumulate(GetBucket(byGenre, genre), covered, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in byMediaType.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
report.ByMediaType.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value });
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in byGenre.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
report.ByGenre.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the distinct genres present on movies/episodes, for the rule editor.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Genre options, sorted by name.</returns>
|
||||||
|
[HttpGet("Genres")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<IEnumerable<PickerOption>> GetGenres()
|
||||||
|
{
|
||||||
|
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
|
||||||
|
IsVirtualItem = false,
|
||||||
|
Recursive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var genres = items
|
||||||
|
.SelectMany(item => item.Genres)
|
||||||
|
.Where(g => !string.IsNullOrWhiteSpace(g))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(g => g, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new PickerOption { Value = g, Label = g });
|
||||||
|
|
||||||
|
return Ok(genres);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the series in the library, for the rule editor's series picker.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Series options (id + title), sorted by title.</returns>
|
||||||
|
[HttpGet("Series")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<IEnumerable<PickerOption>> GetSeries()
|
||||||
|
{
|
||||||
|
var series = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { BaseItemKind.Series },
|
||||||
|
IsVirtualItem = false,
|
||||||
|
Recursive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = series
|
||||||
|
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(s => new PickerOption { Value = s.Id.ToString("D"), Label = s.Name });
|
||||||
|
|
||||||
|
return Ok(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches movies/episodes by name, for the rule editor's item picker.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="search">A name fragment to match (case-insensitive). Required.</param>
|
||||||
|
/// <param name="limit">The maximum number of results (default 25, max 100).</param>
|
||||||
|
/// <returns>Matching item options (id + name), sorted by name.</returns>
|
||||||
|
[HttpGet("Items")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<IEnumerable<PickerOption>> SearchItems([FromQuery] string? search, [FromQuery] int limit = 25)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
return Ok(Array.Empty<PickerOption>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
|
||||||
|
IsVirtualItem = false,
|
||||||
|
Recursive = true,
|
||||||
|
SearchTerm = search,
|
||||||
|
Limit = Math.Clamp(limit, 1, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = items
|
||||||
|
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(i => new PickerOption { Value = i.Id.ToString("D"), Label = ItemLabel(i) });
|
||||||
|
|
||||||
|
return Ok(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ItemLabel(BaseItem item)
|
||||||
|
{
|
||||||
|
// Give episodes a series-qualified label so identically-named episodes
|
||||||
|
// (e.g. "Pilot") are distinguishable in the picker.
|
||||||
|
if (item is Episode episode && !string.IsNullOrEmpty(episode.SeriesName))
|
||||||
|
{
|
||||||
|
return episode.SeriesName + " — " + item.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Accumulate(CoverageCounts counts, bool covered, PolicyAction? action)
|
||||||
|
{
|
||||||
|
counts.Total++;
|
||||||
|
|
||||||
|
if (action == PolicyAction.Ignore)
|
||||||
|
{
|
||||||
|
counts.Ignored++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (covered)
|
||||||
|
{
|
||||||
|
counts.Covered++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.Pending++;
|
||||||
|
if (action == PolicyAction.Prioritise)
|
||||||
|
{
|
||||||
|
counts.Prioritised++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CoverageCounts GetBucket(Dictionary<string, CoverageCounts> buckets, string key)
|
||||||
|
{
|
||||||
|
if (!buckets.TryGetValue(key, out var counts))
|
||||||
|
{
|
||||||
|
counts = new CoverageCounts();
|
||||||
|
buckets[key] = counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MediaTypeLabel(BaseItem item)
|
||||||
|
{
|
||||||
|
return item.GetBaseItemKind() == BaseItemKind.Episode ? "TV" : "Film";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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 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="logger">Logger.</param>
|
||||||
|
public ManifestController(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IManifestExchangeClient exchange,
|
||||||
|
IManagedTruthStore truthStore,
|
||||||
|
ILogger<ManifestController> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_exchange = exchange;
|
||||||
|
_truthStore = truthStore;
|
||||||
|
_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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, outcome.OffsetSec, item.Path ?? string.Empty);
|
||||||
|
var provenance = new TruthProvenance
|
||||||
|
{
|
||||||
|
Source = TruthSource.Fetched,
|
||||||
|
ServerUrl = outcome.ServerUrl,
|
||||||
|
MatchTier = outcome.Tier,
|
||||||
|
OffsetSec = outcome.OffsetSec,
|
||||||
|
Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
|
||||||
|
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)",
|
||||||
|
itemId,
|
||||||
|
outcome.ServerUrl,
|
||||||
|
outcome.Tier,
|
||||||
|
outcome.OffsetSec);
|
||||||
|
|
||||||
|
return Ok(new ManifestFetchResult
|
||||||
|
{
|
||||||
|
ServerUrl = outcome.ServerUrl,
|
||||||
|
Match = outcome.Tier.ToString().ToLowerInvariant(),
|
||||||
|
OffsetSec = outcome.OffsetSec,
|
||||||
|
ActorCount = truth.Actors.Count,
|
||||||
|
Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the prioritise/ignore rules that shape the work-discovery API
|
||||||
|
/// (<c>GET /Plugins/JRay/Tasks/Pending</c>). Rules can target a genre, a
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PolicyController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="policyStore">The media policy store.</param>
|
||||||
|
public PolicyController(IMediaPolicyStore policyStore)
|
||||||
|
{
|
||||||
|
_policyStore = policyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all configured prioritise/ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The current rules.</returns>
|
||||||
|
[HttpGet("Rules")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<IReadOnlyList<MediaPolicyRule>> GetRules()
|
||||||
|
{
|
||||||
|
return Ok(_policyStore.GetRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds or replaces a rule. If a rule already exists for the same scope
|
||||||
|
/// and value, its action is updated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rule">The rule to set.</param>
|
||||||
|
/// <returns>204 on success, or 400 if the rule has no value.</returns>
|
||||||
|
[HttpPut("Rules")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult SetRule([FromBody] MediaPolicyRule rule)
|
||||||
|
{
|
||||||
|
if (rule is null || string.IsNullOrWhiteSpace(rule.Value))
|
||||||
|
{
|
||||||
|
return BadRequest("A rule must have a non-empty value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_policyStore.SetRule(rule);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the rule matching the given scope and value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">The scope of the rule to remove.</param>
|
||||||
|
/// <param name="value">The value of the rule to remove.</param>
|
||||||
|
/// <returns>204 whether or not a matching rule existed.</returns>
|
||||||
|
[HttpDelete("Rules")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public IActionResult RemoveRule([FromQuery] PolicyScope scope, [FromQuery] string value)
|
||||||
|
{
|
||||||
|
_policyStore.RemoveRule(scope, value ?? string.Empty);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Plugin.JRay.Models;
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -16,9 +19,19 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
|||||||
/// Lets a remote extraction worker discover which library items still need
|
/// Lets a remote extraction worker discover which library items still need
|
||||||
/// to be processed.
|
/// to be processed.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Tasks")]
|
[Route("Plugins/JRay/Tasks")]
|
||||||
[Authorize(Roles = "Administrator")]
|
[Authorize(Roles = "Administrator")]
|
||||||
|
// TRACES: JR-015, JR-017 | PR-003
|
||||||
public class TasksController : ControllerBase
|
public class TasksController : ControllerBase
|
||||||
{
|
{
|
||||||
private const int DefaultLimit = 10;
|
private const int DefaultLimit = 10;
|
||||||
@@ -26,16 +39,19 @@ public class TasksController : ControllerBase
|
|||||||
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly ITruthDataService _truthDataService;
|
private readonly ITruthDataService _truthDataService;
|
||||||
|
private readonly IMediaPolicyStore _policyStore;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="TasksController"/> class.
|
/// Initializes a new instance of the <see cref="TasksController"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryManager">The Jellyfin library manager.</param>
|
/// <param name="libraryManager">The Jellyfin library manager.</param>
|
||||||
/// <param name="truthDataService">The truth data service.</param>
|
/// <param name="truthDataService">The truth data service.</param>
|
||||||
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService)
|
/// <param name="policyStore">The prioritise/ignore policy store.</param>
|
||||||
|
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_truthDataService = truthDataService;
|
_truthDataService = truthDataService;
|
||||||
|
_policyStore = policyStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -48,6 +64,7 @@ public class TasksController : ControllerBase
|
|||||||
public ActionResult<IEnumerable<PendingExtractionItem>> GetPending([FromQuery] int limit = DefaultLimit)
|
public ActionResult<IEnumerable<PendingExtractionItem>> GetPending([FromQuery] int limit = DefaultLimit)
|
||||||
{
|
{
|
||||||
var effectiveLimit = Math.Clamp(limit, 1, MaxLimit);
|
var effectiveLimit = Math.Clamp(limit, 1, MaxLimit);
|
||||||
|
var rules = _policyStore.GetRules();
|
||||||
|
|
||||||
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
@@ -56,17 +73,69 @@ public class TasksController : ControllerBase
|
|||||||
Recursive = true,
|
Recursive = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep only items that still need truth data, then apply the policy:
|
||||||
|
// drop anything ignored, and order prioritised items ahead of the rest.
|
||||||
|
// Randomise within each tier so the backlog still spreads across workers.
|
||||||
var pending = items
|
var pending = items
|
||||||
.Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path))
|
.Where(item => MediaFileExists(item) && !_truthDataService.HasTruth(item.Id, item.Path))
|
||||||
.OrderBy(_ => Random.Shared.Next())
|
.Select(item => new
|
||||||
.Take(effectiveLimit)
|
|
||||||
.Select(item => new PendingExtractionItem
|
|
||||||
{
|
{
|
||||||
ItemId = item.Id,
|
Item = item,
|
||||||
Path = item.Path,
|
Action = PolicyResolver.Resolve(rules, item.Id, GetSeriesId(item), item.Genres)
|
||||||
Name = item.Name
|
})
|
||||||
|
.Where(x => x.Action != PolicyAction.Ignore)
|
||||||
|
.OrderByDescending(x => x.Action == PolicyAction.Prioritise)
|
||||||
|
.ThenBy(_ => Random.Shared.Next())
|
||||||
|
.Take(effectiveLimit)
|
||||||
|
.Select(x => new PendingExtractionItem
|
||||||
|
{
|
||||||
|
ItemId = x.Item.Id,
|
||||||
|
Path = x.Item.Path,
|
||||||
|
Name = x.Item.Name
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(pending);
|
return Ok(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the series id for an episode, or <see cref="Guid.Empty"/> for any
|
||||||
|
/// other item type (so series rules only ever match episodes).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The library item.</param>
|
||||||
|
/// <returns>The owning series id, or empty.</returns>
|
||||||
|
internal static Guid GetSeriesId(BaseItem item)
|
||||||
|
{
|
||||||
|
return item is Episode episode ? episode.SeriesId : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an item's backing media still exists on disk.
|
||||||
|
/// <para>
|
||||||
|
/// A library item can outlive its file: deleting media from disk does not
|
||||||
|
/// always purge the Jellyfin database entry, and such ghosts keep
|
||||||
|
/// <c>IsVirtualItem == false</c>, so filtering on that flag alone is not
|
||||||
|
/// enough. Without this check the pending endpoint hands stale paths/URLs to
|
||||||
|
/// extraction workers, and — because a missing file can never gain truth
|
||||||
|
/// data — those items stay pending forever.
|
||||||
|
/// </para>
|
||||||
|
/// Only local (file-protocol) items are stat-checked; remote/streamed items
|
||||||
|
/// are assumed present so we never stat something off-box.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The library item.</param>
|
||||||
|
/// <returns><c>true</c> if the item has usable backing media; otherwise <c>false</c>.</returns>
|
||||||
|
internal static bool MediaFileExists(BaseItem item)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(item.Path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.IsFileProtocol)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path may point at a file or, for folder-based media, a directory.
|
||||||
|
return System.IO.File.Exists(item.Path) || Directory.Exists(item.Path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
|
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
|
||||||
/// extraction worker, for servers that cannot run the extraction pipeline
|
/// extraction worker, for servers that cannot run the extraction pipeline
|
||||||
/// locally. See SPEC.md.
|
/// locally. See SPEC.md §2.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <c>schema_version</c> check here refuses an unrecognised version rather
|
||||||
|
/// than guessing at its shape. It is currently the only source that checks —
|
||||||
|
/// sidecar reads do not — which JR-003 requires be fixed by moving the check
|
||||||
|
/// into the shared read path.
|
||||||
|
/// </remarks>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
||||||
[Authorize(Roles = "Administrator")]
|
[Authorize(Roles = "Administrator")]
|
||||||
|
// TRACES: JR-003, JR-009, JR-014 | SR-003
|
||||||
public class TruthController : ControllerBase
|
public class TruthController : ControllerBase
|
||||||
{
|
{
|
||||||
private const int SupportedSchemaVersion = 1;
|
private const int SupportedSchemaVersion = 1;
|
||||||
@@ -52,7 +59,8 @@ public class TruthController : ControllerBase
|
|||||||
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
|
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
_truthDataService.Invalidate(itemId);
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
|||||||
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
|
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
|
||||||
/// injected into the web client.
|
/// injected into the web client.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay")]
|
[Route("Plugins/JRay")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
// TRACES: JR-014, JR-020 | PR-001
|
||||||
public class WebController : ControllerBase
|
public class WebController : ControllerBase
|
||||||
{
|
{
|
||||||
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
|
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" />
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||||
</ItemGroup>
|
</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>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
|||||||
+10
-2
@@ -3,9 +3,17 @@ using System.Text.Json.Serialization;
|
|||||||
namespace Jellyfin.Plugin.JRay.Models;
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An actor visible on screen at a queried timestamp.
|
/// An actor present in the scene at a queried timestamp.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Gets or sets the actor's display name.
|
/// Gets or sets the actor's display name.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One labelled row of a coverage breakdown (e.g. a single genre, or a single
|
||||||
|
/// media type such as "Film" or "TV").
|
||||||
|
/// </summary>
|
||||||
|
public class CoverageBreakdownRow
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the label for this row (genre name or media type name).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("label")]
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the coverage counts for this row.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("counts")]
|
||||||
|
public CoverageCounts Counts { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A count of library items bucketed by JRay coverage status. Used both for
|
||||||
|
/// the overall totals and for each per-genre / per-media-type breakdown row.
|
||||||
|
/// </summary>
|
||||||
|
public class CoverageCounts
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of items in this bucket.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("total")]
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of items that already have truth data.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("covered")]
|
||||||
|
public int Covered { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of items awaiting processing (no truth data,
|
||||||
|
/// not ignored). Includes prioritised-but-not-yet-covered items.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("pending")]
|
||||||
|
public int Pending { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of pending items that are prioritised.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("prioritised")]
|
||||||
|
public int Prioritised { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of items excluded from processing by an ignore rule.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("ignored")]
|
||||||
|
public int Ignored { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A snapshot of how much of the library has truth data, with overall totals
|
||||||
|
/// and breakdowns by media type and by genre.
|
||||||
|
/// </summary>
|
||||||
|
public class CoverageReport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the coverage counts across the whole library.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("total")]
|
||||||
|
public CoverageCounts Total { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the per-media-type breakdown (Film vs TV).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("by_media_type")]
|
||||||
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
public Collection<CoverageBreakdownRow> ByMediaType { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the per-genre breakdown, one row per genre present in the library.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("by_genre")]
|
||||||
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
public Collection<CoverageBreakdownRow> ByGenre { get; } = new();
|
||||||
|
}
|
||||||
@@ -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
|
public class JRayContext
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[JsonPropertyName("actors")]
|
[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,38 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single prioritise/ignore rule. A rule is uniquely identified by its
|
||||||
|
/// <see cref="Scope"/> and <see cref="Value"/>; setting a new action for an
|
||||||
|
/// existing scope+value replaces the previous one.
|
||||||
|
/// </summary>
|
||||||
|
public class MediaPolicyRule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the scope this rule targets (genre, series, or item).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public PolicyScope Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the target value: a genre name for <see cref="PolicyScope.Genre"/>,
|
||||||
|
/// a series id for <see cref="PolicyScope.Series"/>, or an item id for
|
||||||
|
/// <see cref="PolicyScope.Item"/>.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("value")]
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the action to apply (prioritise or ignore).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("action")]
|
||||||
|
public PolicyAction Action { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets an optional human-readable label for the target (e.g. the
|
||||||
|
/// series or item display name), shown in the rules list. Informational only.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("label")]
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A selectable option for the config page's rule editor (a genre or a series),
|
||||||
|
/// pairing the value stored in a rule with a human-readable label.
|
||||||
|
/// </summary>
|
||||||
|
public class PickerOption
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the value stored in a <see cref="MediaPolicyRule"/>
|
||||||
|
/// (a genre name, or a series id).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("value")]
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the label shown to the user (genre name, or series title).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("label")]
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The action a <see cref="MediaPolicyRule"/> applies to matching items.
|
||||||
|
/// A given scope+value can hold at most one action at a time, so an item
|
||||||
|
/// can never be both prioritised and ignored by the same target.
|
||||||
|
/// </summary>
|
||||||
|
public enum PolicyAction
|
||||||
|
{
|
||||||
|
/// <summary>Push matching items to the front of the work queue.</summary>
|
||||||
|
Prioritise,
|
||||||
|
|
||||||
|
/// <summary>Exclude matching items from the work queue entirely.</summary>
|
||||||
|
Ignore
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scope a <see cref="MediaPolicyRule"/> targets. More specific scopes
|
||||||
|
/// override broader ones when resolving an item's effective policy
|
||||||
|
/// (Item > Series > Genre).
|
||||||
|
/// </summary>
|
||||||
|
public enum PolicyScope
|
||||||
|
{
|
||||||
|
/// <summary>Matches every item that carries a given genre.</summary>
|
||||||
|
Genre,
|
||||||
|
|
||||||
|
/// <summary>Matches every episode of a given series (by series id).</summary>
|
||||||
|
Series,
|
||||||
|
|
||||||
|
/// <summary>Matches a single library item (by item id).</summary>
|
||||||
|
Item
|
||||||
|
}
|
||||||
@@ -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>
|
/// <summary>
|
||||||
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
|
/// 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>
|
/// </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
|
public class TruthActor
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,7 +45,7 @@ public class TruthActor
|
|||||||
public string JellyfinId { get; set; } = string.Empty;
|
public string JellyfinId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
|
/// Gets the list of [start_sec, end_sec] windows during which the actor is in the scene.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("scenes")]
|
[JsonPropertyName("scenes")]
|
||||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Root object of a scene-actor-extraction "truth" file
|
/// Root object of a scene-actor-extraction "truth" file
|
||||||
/// (schema_version 1, minimal verbosity). See SPEC.md.
|
/// (schema_version 1, minimal verbosity). See SPEC.md §1.
|
||||||
/// </summary>
|
/// </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.
|
||||||
|
///
|
||||||
|
/// This type is still the v1 shape. JR-002 replaces it: <c>anneal_sec</c> out,
|
||||||
|
/// an <c>extraction</c> provenance block and a <c>cut</c> block in, and
|
||||||
|
/// <c>scenes</c> becoming objects that carry belief and identification route.
|
||||||
|
/// </remarks>
|
||||||
|
// TRACES: JR-001, JR-002 | SR-003
|
||||||
public class TruthFile
|
public class TruthFile
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,7 +45,7 @@ public class TruthFile
|
|||||||
public double AnnealSec { get; set; }
|
public double AnnealSec { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[JsonPropertyName("actors")]
|
[JsonPropertyName("actors")]
|
||||||
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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 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 };
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay;
|
|||||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
{
|
{
|
||||||
private readonly ILogger<Plugin> _logger;
|
private readonly ILogger<Plugin> _logger;
|
||||||
|
private readonly bool _usingFileTransformation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
@@ -30,8 +31,25 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
Instance = this;
|
Instance = this;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
// The File Transformation plugin rewrites index.html as it is served.
|
||||||
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
// 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 />
|
/// <inheritdoc />
|
||||||
@@ -45,6 +63,20 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static Plugin? Instance { get; private set; }
|
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 />
|
/// <inheritdoc />
|
||||||
public IEnumerable<PluginPageInfo> GetPages()
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,5 +16,11 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
|||||||
{
|
{
|
||||||
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
|
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
|
||||||
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
|
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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/// </summary>
|
||||||
/// <param name="itemId">The Jellyfin library item id.</param>
|
/// <param name="itemId">The Jellyfin library item id.</param>
|
||||||
/// <param name="truth">The truth file contents to persist.</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>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that completes when the file has been written.</returns>
|
/// <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>
|
/// <summary>
|
||||||
/// Deletes the managed truth file for the given item, if one exists.
|
/// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the prioritise/ignore rules that shape the work-discovery API
|
||||||
|
/// (<c>GET /Plugins/JRay/Tasks/Pending</c>). Rules can target a genre, a
|
||||||
|
/// series, or a single item; more specific scopes override broader ones.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMediaPolicyStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all currently configured rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The list of rules (a copy; safe to enumerate).</returns>
|
||||||
|
IReadOnlyList<MediaPolicyRule> GetRules();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds or replaces the rule for a given scope+value. If a rule already
|
||||||
|
/// exists for the same scope and value, its action and label are updated,
|
||||||
|
/// so a target can never hold two conflicting actions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rule">The rule to set.</param>
|
||||||
|
void SetRule(MediaPolicyRule rule);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the rule matching the given scope and value, if present.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">The scope of the rule to remove.</param>
|
||||||
|
/// <param name="value">The value of the rule to remove.</param>
|
||||||
|
/// <returns><c>true</c> if a rule was removed.</returns>
|
||||||
|
bool RemoveRule(PolicyScope scope, string value);
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ public interface ITruthDataService
|
|||||||
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
|
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
|
||||||
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Removes any cached truth file for the given item, so the next
|
/// 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.
|
/// <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
|
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
|
||||||
/// worker) under the plugin's configuration directory, keyed by item id.
|
/// worker) under the plugin's configuration directory, keyed by item id.
|
||||||
/// </summary>
|
/// </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
|
public sealed class ManagedTruthStore : IManagedTruthStore
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -55,7 +60,7 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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 path = GetPath(itemId);
|
||||||
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
|
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
|
||||||
@@ -68,11 +73,52 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
File.Move(tempPath, path, overwrite: true);
|
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 />
|
/// <inheritdoc />
|
||||||
public bool Delete(Guid itemId)
|
public bool Delete(Guid itemId)
|
||||||
{
|
{
|
||||||
|
var provenancePath = GetProvenancePath(itemId);
|
||||||
|
if (File.Exists(provenancePath))
|
||||||
|
{
|
||||||
|
File.Delete(provenancePath);
|
||||||
|
}
|
||||||
|
|
||||||
var path = GetPath(itemId);
|
var path = GetPath(itemId);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
@@ -93,4 +139,9 @@ public sealed class ManagedTruthStore : IManagedTruthStore
|
|||||||
{
|
{
|
||||||
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");
|
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,100 @@
|
|||||||
|
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 = 1,
|
||||||
|
Movie = mediaPath ?? string.Empty,
|
||||||
|
SampleFps = manifest.Extraction?.SampleFps ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
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[] { start, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
/// <returns>A caveat string, or null when the match needs no explanation.</returns>
|
||||||
|
public static string? DescribeCaveat(MatchTier tier, double offsetSec)
|
||||||
|
{
|
||||||
|
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,447 @@
|
|||||||
|
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>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services.Interfaces;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the prioritise/ignore rules to a single JSON file under the
|
||||||
|
/// plugin's configuration directory, and keeps an in-memory copy for fast
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
|
private readonly ILogger<MediaPolicyStore> _logger;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private List<MediaPolicyRule>? _rules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MediaPolicyStore"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">The Jellyfin application paths.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public MediaPolicyStore(IApplicationPaths applicationPaths, ILogger<MediaPolicyStore> logger)
|
||||||
|
{
|
||||||
|
_applicationPaths = applicationPaths;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<MediaPolicyRule> GetRules()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return EnsureLoaded().ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetRule(MediaPolicyRule rule)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rule);
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var rules = EnsureLoaded();
|
||||||
|
rules.RemoveAll(r => r.Scope == rule.Scope && ValueEquals(r.Value, rule.Value));
|
||||||
|
rules.Add(rule);
|
||||||
|
Save(rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RemoveRule(PolicyScope scope, string value)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var rules = EnsureLoaded();
|
||||||
|
var removed = rules.RemoveAll(r => r.Scope == scope && ValueEquals(r.Value, value));
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
Save(rules);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValueEquals(string a, string b)
|
||||||
|
{
|
||||||
|
// Genre names are matched case-insensitively; ids happen to be
|
||||||
|
// case-insensitive too (GUID strings), so a single comparison suffices.
|
||||||
|
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MediaPolicyRule> EnsureLoaded()
|
||||||
|
{
|
||||||
|
if (_rules is not null)
|
||||||
|
{
|
||||||
|
return _rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = GetPath();
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
_rules = new List<MediaPolicyRule>();
|
||||||
|
return _rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
_rules = JsonSerializer.Deserialize<List<MediaPolicyRule>>(stream, JsonOptions)
|
||||||
|
?? new List<MediaPolicyRule>();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or JsonException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "JRay: failed to read media policy file {Path}; starting empty", path);
|
||||||
|
_rules = new List<MediaPolicyRule>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(List<MediaPolicyRule> rules)
|
||||||
|
{
|
||||||
|
var path = GetPath();
|
||||||
|
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Media policy path has no directory.");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
var tempPath = path + ".tmp";
|
||||||
|
using (var stream = File.Create(tempPath))
|
||||||
|
{
|
||||||
|
JsonSerializer.Serialize(stream, rules, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, path, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "policy.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective prioritise/ignore action for an item from the
|
||||||
|
/// configured rules. More specific scopes win: an Item rule overrides a
|
||||||
|
/// Series rule, which overrides a Genre rule. Because a given scope+value can
|
||||||
|
/// 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>
|
||||||
|
/// Computes the effective action for an item, or <c>null</c> if no rule matches.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rules">The configured rules.</param>
|
||||||
|
/// <param name="itemId">The item's id.</param>
|
||||||
|
/// <param name="seriesId">The item's series id, or <see cref="Guid.Empty"/> if it is not an episode.</param>
|
||||||
|
/// <param name="genres">The item's genres.</param>
|
||||||
|
/// <returns>The effective <see cref="PolicyAction"/>, or <c>null</c> when unruled.</returns>
|
||||||
|
public static PolicyAction? Resolve(
|
||||||
|
IReadOnlyList<MediaPolicyRule> rules,
|
||||||
|
Guid itemId,
|
||||||
|
Guid seriesId,
|
||||||
|
IReadOnlyList<string> genres)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rules);
|
||||||
|
|
||||||
|
PolicyAction? itemAction = null;
|
||||||
|
PolicyAction? seriesAction = null;
|
||||||
|
PolicyAction? genreAction = null;
|
||||||
|
|
||||||
|
var itemIdStr = itemId.ToString("D");
|
||||||
|
var seriesIdStr = seriesId == Guid.Empty ? null : seriesId.ToString("D");
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
switch (rule.Scope)
|
||||||
|
{
|
||||||
|
case PolicyScope.Item:
|
||||||
|
if (string.Equals(rule.Value, itemIdStr, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
itemAction = rule.Action;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PolicyScope.Series:
|
||||||
|
if (seriesIdStr is not null && string.Equals(rule.Value, seriesIdStr, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
seriesAction = rule.Action;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PolicyScope.Genre:
|
||||||
|
if (genres is not null && MatchesGenre(genres, rule.Value))
|
||||||
|
{
|
||||||
|
genreAction = rule.Action;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemAction ?? seriesAction ?? genreAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesGenre(IReadOnlyList<string> genres, string value)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < genres.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(genres[i], value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Length == 2 && window[0] <= t && t <= window[1])
|
||||||
|
{
|
||||||
|
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.Length != 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window[0] < previousStart)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousStart = window[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/// item's source file, and caches the parsed result for a configurable
|
||||||
/// duration.
|
/// duration.
|
||||||
/// </summary>
|
/// </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
|
public sealed class TruthDataService : ITruthDataService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -86,6 +93,34 @@ public sealed class TruthDataService : ITruthDataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public TruthProvenance? GetProvenance(Guid itemId)
|
||||||
|
{
|
||||||
|
// Managed truth wins, exactly as in GetTruthAsync -- resolving
|
||||||
|
// precedence twice by different rules is how the two would drift.
|
||||||
|
var managed = _managedTruthStore.LoadProvenance(itemId);
|
||||||
|
if (managed is not null)
|
||||||
|
{
|
||||||
|
return managed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
if (item is null || string.IsNullOrEmpty(item.Path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sidecarPath = GetSidecarPath(item.Path);
|
||||||
|
if (!File.Exists(sidecarPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sidecar records nothing about itself, so its provenance is derived:
|
||||||
|
// it is local, and its timestamp is the file's own.
|
||||||
|
return TruthProvenance.Local(TruthSource.Sidecar, File.GetLastWriteTimeUtc(sidecarPath));
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Invalidate(Guid itemId)
|
public void Invalidate(Guid itemId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,26 +6,40 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Jellyfin.Plugin.JRay.Services;
|
namespace Jellyfin.Plugin.JRay.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
|
/// Removes the pause-overlay script tag that an earlier version of JRay
|
||||||
/// that loads JRay's pause-overlay script. This follows the pattern used by
|
/// injected into the web client's <c>index.html</c> on disk.
|
||||||
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
|
|
||||||
/// plugin hook for player-overlay UI.
|
|
||||||
/// </summary>
|
/// </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
|
public static class WebClientPatchService
|
||||||
{
|
{
|
||||||
private const string Marker = "<!-- jray-overlay -->";
|
private const string Marker = "<!-- jray-overlay -->";
|
||||||
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
||||||
private const string Injected = ScriptTag + Marker + "\n</body>";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensures the web client's index.html either has or does not have the
|
/// Removes a legacy on-disk overlay injection, if one is present. Safe to
|
||||||
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
|
/// call on every startup: it is a no-op once the marker is gone.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="applicationPaths">The Jellyfin application paths.</param>
|
/// <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>
|
/// <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");
|
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -37,36 +51,38 @@ public static class WebClientPatchService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var html = File.ReadAllText(indexPath);
|
var html = File.ReadAllText(indexPath);
|
||||||
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
|
if (!html.Contains(Marker, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (enableOverlay && !hasMarker)
|
var cleaned = RemoveInjection(html);
|
||||||
{
|
File.WriteAllText(indexPath, cleaned);
|
||||||
var patched = ReplaceLast(html, "</body>", Injected);
|
logger.LogInformation(
|
||||||
File.WriteAllText(indexPath, patched);
|
"JRay: removed a pause-overlay script left in {Path} by an earlier version. "
|
||||||
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
|
+ "JRay no longer modifies this file; the overlay is served through File Transformation.",
|
||||||
}
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
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);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
if (index < 0)
|
|
||||||
{
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,9 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var POLL_INTERVAL_MS = 1000;
|
var POLL_INTERVAL_MS = 1000;
|
||||||
var OVERVIEW_MAX_LENGTH = 160;
|
var OVERVIEW_MAX_LENGTH = 160;
|
||||||
var overlayEl = null;
|
var overlayEl = null;
|
||||||
|
var detailEl = null;
|
||||||
var personCache = {};
|
var personCache = {};
|
||||||
|
|
||||||
function truncate(text, maxLength) {
|
function truncate(text, maxLength) {
|
||||||
@@ -47,7 +63,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeDetail() {
|
||||||
|
if (detailEl && detailEl.parentNode) {
|
||||||
|
detailEl.parentNode.removeChild(detailEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
detailEl = null;
|
||||||
|
document.removeEventListener('keydown', onDetailKeydown, true);
|
||||||
|
}
|
||||||
|
|
||||||
function removeOverlay() {
|
function removeOverlay() {
|
||||||
|
removeDetail();
|
||||||
|
|
||||||
if (overlayEl && overlayEl.parentNode) {
|
if (overlayEl && overlayEl.parentNode) {
|
||||||
overlayEl.parentNode.removeChild(overlayEl);
|
overlayEl.parentNode.removeChild(overlayEl);
|
||||||
}
|
}
|
||||||
@@ -55,6 +82,119 @@
|
|||||||
overlayEl = null;
|
overlayEl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDetailKeydown(event) {
|
||||||
|
// Back button on TV remotes / keyboards maps to Escape / Backspace.
|
||||||
|
if (event.key === 'Escape' || event.key === 'Backspace' || event.keyCode === 27 || event.keyCode === 8) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
removeDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A larger, closeable "pop-up" card shown over the video when an actor is
|
||||||
|
// clicked. It stays inside the player so playback position is never lost.
|
||||||
|
function showDetail(container, actor, person) {
|
||||||
|
removeDetail();
|
||||||
|
|
||||||
|
detailEl = document.createElement('div');
|
||||||
|
detailEl.className = 'jrayActorDetail';
|
||||||
|
detailEl.style.position = 'absolute';
|
||||||
|
detailEl.style.top = '0';
|
||||||
|
detailEl.style.left = '0';
|
||||||
|
detailEl.style.right = '0';
|
||||||
|
detailEl.style.bottom = '0';
|
||||||
|
detailEl.style.zIndex = '10000';
|
||||||
|
detailEl.style.display = 'flex';
|
||||||
|
detailEl.style.alignItems = 'center';
|
||||||
|
detailEl.style.justifyContent = 'center';
|
||||||
|
detailEl.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||||
|
detailEl.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
// Click on the dimmed backdrop closes the pop-up.
|
||||||
|
detailEl.addEventListener('click', function (event) {
|
||||||
|
if (event.target === detailEl) {
|
||||||
|
removeDetail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.style.position = 'relative';
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
panel.style.gap = '24px';
|
||||||
|
panel.style.maxWidth = '720px';
|
||||||
|
panel.style.width = '80%';
|
||||||
|
panel.style.maxHeight = '80%';
|
||||||
|
panel.style.overflowY = 'auto';
|
||||||
|
panel.style.background = 'rgba(20, 20, 20, 0.96)';
|
||||||
|
panel.style.color = '#fff';
|
||||||
|
panel.style.padding = '24px';
|
||||||
|
panel.style.borderRadius = '10px';
|
||||||
|
panel.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.6)';
|
||||||
|
|
||||||
|
if (person && person.ImageTags && person.ImageTags.Primary) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
|
||||||
|
type: 'Primary',
|
||||||
|
maxHeight: 400,
|
||||||
|
tag: person.ImageTags.Primary
|
||||||
|
});
|
||||||
|
img.style.height = '300px';
|
||||||
|
img.style.width = 'auto';
|
||||||
|
img.style.borderRadius = '8px';
|
||||||
|
img.style.objectFit = 'cover';
|
||||||
|
img.style.flexShrink = '0';
|
||||||
|
panel.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = document.createElement('div');
|
||||||
|
text.style.flex = '1';
|
||||||
|
|
||||||
|
var name = document.createElement('div');
|
||||||
|
name.style.fontWeight = 'bold';
|
||||||
|
name.style.fontSize = '24px';
|
||||||
|
name.style.marginBottom = '12px';
|
||||||
|
name.textContent = actor.name;
|
||||||
|
text.appendChild(name);
|
||||||
|
|
||||||
|
if (person && person.Overview) {
|
||||||
|
var overview = document.createElement('div');
|
||||||
|
overview.style.fontSize = '15px';
|
||||||
|
overview.style.lineHeight = '1.5';
|
||||||
|
overview.style.opacity = '0.9';
|
||||||
|
overview.textContent = person.Overview;
|
||||||
|
text.appendChild(overview);
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.appendChild(text);
|
||||||
|
|
||||||
|
var closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close');
|
||||||
|
closeBtn.textContent = '✕';
|
||||||
|
closeBtn.style.position = 'absolute';
|
||||||
|
closeBtn.style.top = '8px';
|
||||||
|
closeBtn.style.right = '8px';
|
||||||
|
closeBtn.style.width = '32px';
|
||||||
|
closeBtn.style.height = '32px';
|
||||||
|
closeBtn.style.border = 'none';
|
||||||
|
closeBtn.style.borderRadius = '50%';
|
||||||
|
closeBtn.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
closeBtn.style.color = '#fff';
|
||||||
|
closeBtn.style.fontSize = '16px';
|
||||||
|
closeBtn.style.cursor = 'pointer';
|
||||||
|
closeBtn.addEventListener('click', function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
removeDetail();
|
||||||
|
});
|
||||||
|
panel.appendChild(closeBtn);
|
||||||
|
|
||||||
|
detailEl.appendChild(panel);
|
||||||
|
container.appendChild(detailEl);
|
||||||
|
|
||||||
|
// Back button (Escape/Backspace) closes the pop-up first.
|
||||||
|
document.addEventListener('keydown', onDetailKeydown, true);
|
||||||
|
}
|
||||||
|
|
||||||
function showOverlay(video, actors) {
|
function showOverlay(video, actors) {
|
||||||
removeOverlay();
|
removeOverlay();
|
||||||
|
|
||||||
@@ -78,6 +218,22 @@
|
|||||||
overlayEl.style.gap = '12px';
|
overlayEl.style.gap = '12px';
|
||||||
overlayEl.style.pointerEvents = 'none';
|
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) {
|
actors.forEach(function (actor) {
|
||||||
var card = document.createElement('div');
|
var card = document.createElement('div');
|
||||||
card.className = 'jrayActorCard';
|
card.className = 'jrayActorCard';
|
||||||
@@ -88,13 +244,13 @@
|
|||||||
card.style.display = 'flex';
|
card.style.display = 'flex';
|
||||||
card.style.alignItems = 'center';
|
card.style.alignItems = 'center';
|
||||||
card.style.gap = '10px';
|
card.style.gap = '10px';
|
||||||
card.style.maxWidth = '320px';
|
card.style.maxWidth = '360px';
|
||||||
|
|
||||||
var textBlock = document.createElement('div');
|
var textBlock = document.createElement('div');
|
||||||
|
|
||||||
var name = document.createElement('div');
|
var name = document.createElement('div');
|
||||||
name.style.fontWeight = 'bold';
|
name.style.fontWeight = 'bold';
|
||||||
name.style.fontSize = '14px';
|
name.style.fontSize = '16px';
|
||||||
name.textContent = actor.name;
|
name.textContent = actor.name;
|
||||||
textBlock.appendChild(name);
|
textBlock.appendChild(name);
|
||||||
|
|
||||||
@@ -107,24 +263,27 @@
|
|||||||
|
|
||||||
card.style.pointerEvents = 'auto';
|
card.style.pointerEvents = 'auto';
|
||||||
card.style.cursor = 'pointer';
|
card.style.cursor = 'pointer';
|
||||||
card.addEventListener('click', function (event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
window.location.hash = '#/details?id=' + actor.jellyfin_id;
|
|
||||||
});
|
|
||||||
|
|
||||||
getPerson(actor.jellyfin_id).then(function (person) {
|
getPerson(actor.jellyfin_id).then(function (person) {
|
||||||
if (!person || !overlayEl || !overlayEl.contains(card)) {
|
if (!person || !overlayEl || !overlayEl.contains(card)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clicking the card opens the in-player detail pop-up rather than
|
||||||
|
// navigating away — this keeps the current playback position.
|
||||||
|
card.addEventListener('click', function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
showDetail(container, actor, person);
|
||||||
|
});
|
||||||
|
|
||||||
if (person.ImageTags && person.ImageTags.Primary) {
|
if (person.ImageTags && person.ImageTags.Primary) {
|
||||||
var img = document.createElement('img');
|
var img = document.createElement('img');
|
||||||
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
|
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
|
||||||
type: 'Primary',
|
type: 'Primary',
|
||||||
maxHeight: 80,
|
maxHeight: 120,
|
||||||
tag: person.ImageTags.Primary
|
tag: person.ImageTags.Primary
|
||||||
});
|
});
|
||||||
img.style.height = '80px';
|
img.style.height = '120px';
|
||||||
img.style.width = 'auto';
|
img.style.width = 'auto';
|
||||||
img.style.borderRadius = '4px';
|
img.style.borderRadius = '4px';
|
||||||
img.style.objectFit = 'cover';
|
img.style.objectFit = 'cover';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# JRay
|
# 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
|
JRay reads "truth" files produced offline by the
|
||||||
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
||||||
pipeline (face detection + recognition) and exposes an API to query which actors
|
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.
|
client, displays the result when you pause playback.
|
||||||
|
|
||||||
## Status
|
## 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.
|
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
|
## Features
|
||||||
|
|
||||||
- **Pause overlay** — pause a movie or episode in the web client and see the
|
- **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
|
- **Sidecar truth files** — drop a `Movie.jray.json` next to `Movie.mkv` and JRay
|
||||||
picks it up automatically (suffix configurable).
|
picks it up automatically (suffix configurable).
|
||||||
- **Remote truth push** — for servers that can't run the extraction pipeline
|
- **Remote truth push** — for servers that can't run the extraction pipeline
|
||||||
@@ -37,6 +56,11 @@ Then install "JRay" from the plugin catalog and restart Jellyfin.
|
|||||||
- **Work discovery API** — a remote worker can poll for a random batch of library
|
- **Work discovery API** — a remote worker can poll for a random batch of library
|
||||||
items that still need processing, so the backlog spreads naturally across
|
items that still need processing, so the backlog spreads naturally across
|
||||||
workers without server-side task tracking.
|
workers without server-side task tracking.
|
||||||
|
- **Prioritise / ignore rules** — steer that backlog by genre, series, or
|
||||||
|
individual item: ignore what you never want extracted (e.g. anime), or push a
|
||||||
|
series to the front of the queue.
|
||||||
|
- **Coverage overview** — see at a glance how much of your library has actor
|
||||||
|
data, broken down by genre and by media type (film vs TV).
|
||||||
- **Extensible "context at time t" envelope** — the per-timestamp response is
|
- **Extensible "context at time t" envelope** — the per-timestamp response is
|
||||||
designed to grow (locations, trivia, …) without breaking existing clients.
|
designed to grow (locations, trivia, …) without breaking existing clients.
|
||||||
- **In-memory caching** — loaded truth files are cached with a configurable TTL.
|
- **In-memory caching** — loaded truth files are cached with a configurable TTL.
|
||||||
@@ -58,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**
|
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
|
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.
|
(`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`.
|
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
|
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
|
## Truth File Format
|
||||||
|
|
||||||
@@ -88,8 +112,11 @@ configurable). Schema (`schema_version: 1`, minimal verbosity):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
An actor is considered visible at timestamp `t` (seconds) if any of their
|
An actor is present at timestamp `t` (seconds) if any of their `scenes` windows
|
||||||
`scenes` windows satisfies `start <= t <= end`. JRay prefers `jellyfin_id` (a
|
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`
|
Jellyfin Person GUID) when present, otherwise resolves `imdb_id`/`tmdb_id`
|
||||||
against the item's People `ProviderIds`.
|
against the item's People `ProviderIds`.
|
||||||
|
|
||||||
@@ -103,11 +130,16 @@ the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token
|
|||||||
|
|
||||||
| Method & Route | Auth | Description |
|
| 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. |
|
| `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. |
|
| `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. |
|
| `DELETE /Items/{itemId}/Truth` | admin | Remove managed truth data (idempotent, `204`). Falls back to sidecar. |
|
||||||
| `GET /Tasks/Pending?limit=10` | admin | Random sample of items still needing truth data (default 10, max 100). |
|
| `GET /Tasks/Pending?limit=10` | admin | Random sample of items still needing truth data (default 10, max 100); honours prioritise/ignore rules. |
|
||||||
|
| `GET /Policy/Rules` | admin | List prioritise/ignore rules. |
|
||||||
|
| `PUT /Policy/Rules` | admin | Add or replace a rule. `204`, or `400` if `value` is empty. |
|
||||||
|
| `DELETE /Policy/Rules?scope=&value=` | admin | Remove a rule (idempotent, `204`). |
|
||||||
|
| `GET /Coverage` | admin | Library coverage totals, plus breakdowns by media type and genre. |
|
||||||
|
| `GET /Coverage/Genres` \| `/Series` \| `/Items?search=` | admin | Option lists for the config page's rule editor. |
|
||||||
| `GET /ClientScript` | anon | The pause-overlay script injected into the web client. |
|
| `GET /ClientScript` | anon | The pause-overlay script injected into the web client. |
|
||||||
|
|
||||||
- **user** — any authenticated Jellyfin user token.
|
- **user** — any authenticated Jellyfin user token.
|
||||||
@@ -119,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)
|
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
|
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
|
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**
|
**Request**
|
||||||
|
|
||||||
@@ -148,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`.
|
`200`, not a `404`.
|
||||||
- `404 Not Found` means the item has **no truth data at all** (no managed upload
|
- `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
|
and no sidecar file). Treat this as "JRay isn't available for this item" and
|
||||||
@@ -164,7 +196,7 @@ X-Emby-Token: <user-or-api-token>
|
|||||||
|
|
||||||
Returns the complete truth file (the [schema above](#truth-file-format)) — every
|
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
|
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.
|
instead of polling `jray?t=` on each pause. `404` if no truth data exists.
|
||||||
|
|
||||||
### Reference implementation (web client)
|
### Reference implementation (web client)
|
||||||
@@ -183,7 +215,7 @@ var s = sessions[0];
|
|||||||
var itemId = s.NowPlayingItem.Id;
|
var itemId = s.NowPlayingItem.Id;
|
||||||
var t = (s.PlayState.PositionTicks || 0) / 10000000; // ticks → seconds
|
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({
|
var ctx = await ApiClient.ajax({
|
||||||
url: ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t }),
|
url: ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t }),
|
||||||
type: 'GET', dataType: 'json'
|
type: 'GET', dataType: 'json'
|
||||||
@@ -225,6 +257,21 @@ Configure JRay in Jellyfin Dashboard → Plugins → JRay:
|
|||||||
client's `index.html`. Disabling it removes any previously injected script
|
client's `index.html`. Disabling it removes any previously injected script
|
||||||
(default `on`).
|
(default `on`).
|
||||||
|
|
||||||
|
The config page also shows a **coverage overview** (how much of your library has
|
||||||
|
actor data, by genre and media type) and a **prioritise / ignore rules** editor.
|
||||||
|
Rules steer the work-discovery API (`/Tasks/Pending`) only — they don't affect
|
||||||
|
the overlay or read endpoints:
|
||||||
|
|
||||||
|
- **Ignore** — matching items are never offered to extraction workers. Use it to
|
||||||
|
skip content you don't want processed (e.g. a whole genre like anime, a
|
||||||
|
specific series, or one movie). Ignored items are excluded from the coverage
|
||||||
|
"percent done" so they don't count against you.
|
||||||
|
- **Prioritise** — matching items jump to the front of the work queue.
|
||||||
|
|
||||||
|
A rule targets a **genre**, a **series**, or a single **item**; the most specific
|
||||||
|
matching rule wins (item > series > genre). Setting a rule for a target that
|
||||||
|
already has one replaces it, so nothing can be both prioritised and ignored.
|
||||||
|
|
||||||
## Building the Plugin
|
## Building the Plugin
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -268,20 +315,28 @@ Jellyfin.Plugin.JRay/
|
|||||||
├── Controllers/
|
├── Controllers/
|
||||||
│ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints
|
│ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints
|
||||||
│ ├── TruthController.cs # PUT/DELETE managed truth data
|
│ ├── TruthController.cs # PUT/DELETE managed truth data
|
||||||
│ ├── TasksController.cs # /Tasks/Pending work discovery
|
│ ├── TasksController.cs # /Tasks/Pending work discovery (policy-aware)
|
||||||
|
│ ├── PolicyController.cs # /Policy/Rules prioritise/ignore CRUD
|
||||||
|
│ ├── CoverageController.cs # /Coverage overview + genre/series/item pickers
|
||||||
│ └── WebController.cs # /ClientScript overlay script
|
│ └── WebController.cs # /ClientScript overlay script
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── TruthFile.cs # Root truth-file schema (schema_version 1)
|
│ ├── TruthFile.cs # Root truth-file schema (schema_version 1)
|
||||||
│ ├── TruthActor.cs # Per-actor entry with scene windows
|
│ ├── TruthActor.cs # Per-actor entry with scene windows
|
||||||
│ ├── ActorAtTime.cs # Actor entry in the "context at t" envelope
|
│ ├── ActorAtTime.cs # Actor entry in the "context at t" envelope
|
||||||
│ ├── JRayContext.cs # Extensible "context at time t" envelope
|
│ ├── JRayContext.cs # Extensible "context at time t" envelope
|
||||||
│ └── PendingExtractionItem.cs # Item descriptor for the work-discovery API
|
│ ├── PendingExtractionItem.cs # Item descriptor for the work-discovery API
|
||||||
|
│ ├── MediaPolicyRule.cs # A prioritise/ignore rule (+ PolicyScope/PolicyAction)
|
||||||
|
│ ├── CoverageReport.cs # Coverage overview (+ CoverageCounts / breakdown rows)
|
||||||
|
│ └── PickerOption.cs # value/label option for the config-page pickers
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── Interfaces/
|
│ ├── Interfaces/
|
||||||
│ │ ├── ITruthDataService.cs
|
│ │ ├── ITruthDataService.cs
|
||||||
│ │ └── IManagedTruthStore.cs
|
│ │ ├── IManagedTruthStore.cs
|
||||||
|
│ │ └── IMediaPolicyStore.cs
|
||||||
│ ├── TruthDataService.cs # Resolves + caches truth (managed > sidecar)
|
│ ├── TruthDataService.cs # Resolves + caches truth (managed > sidecar)
|
||||||
│ ├── ManagedTruthStore.cs # Storage for pushed/managed truth data
|
│ ├── ManagedTruthStore.cs # Storage for pushed/managed truth data
|
||||||
|
│ ├── MediaPolicyStore.cs # Persists prioritise/ignore rules (policy.json)
|
||||||
|
│ ├── PolicyResolver.cs # Resolves an item's effective rule (item>series>genre)
|
||||||
│ └── WebClientPatchService.cs # Injects/removes overlay script in index.html
|
│ └── WebClientPatchService.cs # Injects/removes overlay script in index.html
|
||||||
├── Web/
|
├── Web/
|
||||||
│ └── jray-overlay.js # Pause-overlay client script (embedded resource)
|
│ └── jray-overlay.js # Pause-overlay client script (embedded resource)
|
||||||
@@ -303,7 +358,7 @@ Jellyfin.Plugin.JRay/
|
|||||||
`<script>` tag in the web client's `index.html`, marked with `<!-- jray-overlay -->`
|
`<script>` tag in the web client's `index.html`, marked with `<!-- jray-overlay -->`
|
||||||
so it's idempotent. Re-applied whenever configuration changes.
|
so it's idempotent. Re-applied whenever configuration changes.
|
||||||
5. **Overlay Script** (`jray-overlay.js`): listens for the player's pause event,
|
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
|
## Important Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,201 +1,846 @@
|
|||||||
# JRay truth file format
|
# jRay — software specification
|
||||||
|
|
||||||
JRay reads "truth" files produced offline by the
|
Status: **alpha.** Core read path ships; the schema bump, the exchange client and
|
||||||
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
the audio signature do not.
|
||||||
pipeline (`result_sink_node`, `Verbosity::minimal`, `schema_version: 1`).
|
|
||||||
|
|
||||||
## File location
|
This is a *software* spec: its job is to implement the
|
||||||
|
[system spec](scripts/vendor/jray-project/SPEC.md), which owns everything spanning more than one repo.
|
||||||
|
Requirements here trace up to an `SR-nnn` or a `PR-nnn`; the prose below is the
|
||||||
|
detail. The authoritative ID list with status lives in
|
||||||
|
[`docs/requirements.md`](docs/requirements.md).
|
||||||
|
|
||||||
For a media file `Movie.mkv`, the pipeline writes a sibling file
|
jRay is the Jellyfin plugin: it **consumes** presence data, **displays** it in
|
||||||
`Movie.jray.json` (suffix configurable in the plugin settings, default
|
the player, and **owns the truth-file format** that the other two components
|
||||||
`.jray.json`). The plugin resolves this path from the Jellyfin item's media
|
produce and exchange.
|
||||||
source path by stripping the extension and appending the suffix.
|
|
||||||
|
|
||||||
## JSON schema (schema_version 1, minimal verbosity)
|
---
|
||||||
|
|
||||||
|
## 0. Requirements
|
||||||
|
|
||||||
|
IDs are `JR-nnn`, zero-padded and **permanent** — a withdrawn requirement keeps
|
||||||
|
its number, because renumbering is what produces orphan TRACES tags
|
||||||
|
([system spec](scripts/vendor/jray-project/SPEC.md) §6).
|
||||||
|
|
||||||
|
| Group | IDs | Where addressed |
|
||||||
|
|---|---|---|
|
||||||
|
| Truth-file format | JR-001 … JR-007 | §1 |
|
||||||
|
| Sources and precedence | JR-008 … JR-011 | §2 |
|
||||||
|
| Read API | JR-012 … JR-014 | §3 |
|
||||||
|
| Work discovery, policy, coverage | JR-015 … JR-019 | §4 |
|
||||||
|
| Player overlay | JR-020 … JR-024 | §5 |
|
||||||
|
| Manifest exchange client | JR-025 … JR-037 | §6 |
|
||||||
|
| Egress and privacy | JR-038 … JR-041 | §7 |
|
||||||
|
| Audio signature | JR-042 … JR-045 | §8 |
|
||||||
|
| Human-in-the-loop association | JR-046 | §9 |
|
||||||
|
|
||||||
|
**JR-038 … JR-041 exist because `PR-005` had no software row anywhere.** The
|
||||||
|
system spec notes that "leak nothing about what the user owns" is preserved
|
||||||
|
structurally — by SR-004 and GR-005 both being prohibitions — and that a goal
|
||||||
|
held only by prohibitions needs watching. jRay is the component that actually
|
||||||
|
opens a socket, so it is the right place for that goal to become checkable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Truth-file format — JR-001 … JR-007
|
||||||
|
|
||||||
|
### JR-001 — This document is normative for the format
|
||||||
|
|
||||||
|
The truth file is produced by `scene-actor-extraction`, read by this plugin, and
|
||||||
|
transformed into a Jmanifest by the exchange client. Three repos touch it, so
|
||||||
|
exactly one must define it, and the [system spec](scripts/vendor/jray-project/SPEC.md) §1 assigns that to
|
||||||
|
jRay. Other repos reference this section rather than restating the schema.
|
||||||
|
|
||||||
|
The truth file is **not** the Jmanifest. It carries installation-local fields
|
||||||
|
(`movie`, `jellyfin_id`) that the exchange strips, and lacks the portable
|
||||||
|
identity block the exchange adds. See §6.
|
||||||
|
|
||||||
|
**Current:** [`Models/TruthFile.cs`](Jellyfin.Plugin.JRay/Models/TruthFile.cs),
|
||||||
|
`schema_version: 1`. **Gap:** the schema below is not implemented, and the other
|
||||||
|
two specs describe the pending bump in more detail than this one does — the
|
||||||
|
ownership is stated but not yet exercised.
|
||||||
|
|
||||||
|
### JR-002 — `schema_version: 2`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"movie": "/path/to/Movie.mkv",
|
"movie": "/data/movies/Movie.mkv",
|
||||||
"sample_fps": 1,
|
"extraction": {
|
||||||
"anneal_sec": 2,
|
"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": [
|
"actors": [
|
||||||
{
|
{
|
||||||
"name": "Tom Hanks",
|
"name": "Steve Buscemi",
|
||||||
"imdb_id": "nm0000158",
|
"imdb_id": "nm0000114",
|
||||||
"tmdb_id": "31",
|
"tmdb_id": "884",
|
||||||
"jellyfin_id": "abc123-guid",
|
"jellyfin_id": "abc123-guid",
|
||||||
"scenes": [[12.0, 45.0], [102.5, 150.0]]
|
"scenes": [
|
||||||
|
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
|
||||||
|
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `schema_version`: integer, bump on breaking changes. JRay should refuse (or
|
Field notes:
|
||||||
warn) on a version it doesn't understand.
|
|
||||||
- `movie`: absolute path to the source media file at extraction time (informational only).
|
|
||||||
- `sample_fps`: frames-per-second the pipeline sampled at.
|
|
||||||
- `anneal_sec`: gap (in seconds) below which consecutive detections of the
|
|
||||||
same actor were merged into a single scene window.
|
|
||||||
- `actors[]`: one entry per actor detected anywhere in the film.
|
|
||||||
- `name`: display name from the gallery.
|
|
||||||
- `imdb_id` / `tmdb_id` / `jellyfin_id`: identity keys, each `""` if not
|
|
||||||
resolved. JRay should prefer `jellyfin_id` (a Jellyfin Person item GUID)
|
|
||||||
when non-empty, and otherwise resolve `imdb_id`/`tmdb_id` against the
|
|
||||||
item's People `ProviderIds`.
|
|
||||||
- `scenes`: list of `[start_sec, end_sec]` windows (inclusive) during which
|
|
||||||
the actor is on screen.
|
|
||||||
|
|
||||||
## Querying "who's on screen at time t"
|
- `schema_version` — **system-level** (SR-003), incremented once per breaking
|
||||||
|
change and referenced by the same number in all three repos.
|
||||||
|
- `movie` — absolute path at extraction time, informational only. Stripped on
|
||||||
|
contribution (JR-034).
|
||||||
|
- `extraction.*` — provenance. `sample_fps`, `gallery_size` and
|
||||||
|
`pipeline_version` move here from the top level so the truth file and the
|
||||||
|
Jmanifest's `extraction` block have the same shape, rather than differing for
|
||||||
|
no reason.
|
||||||
|
- `extraction.extinction_sec` — **replaces `anneal_sec`**, which is deleted, not
|
||||||
|
retained as a vestigial `0`. It is the re-acquisition timeout that shapes
|
||||||
|
window extent, so it is what a consumer needs in order to interpret a window.
|
||||||
|
- `extraction.gallery_scope` — `"global"` or `"limited"`. The strongest single
|
||||||
|
quality signal when two manifests compete for one cut.
|
||||||
|
- `cut.runtime_sec` — the decoded duration the timings came from. Required for
|
||||||
|
contribution; the primary alignment guard.
|
||||||
|
- `cut.audio_signature` — optional, `v1:`-prefixed. See §8.
|
||||||
|
- `actors[].scenes[]` — objects, not float pairs. `start`/`end` in seconds,
|
||||||
|
inclusive, sorted. `belief` is the accumulated posterior that justified the
|
||||||
|
claim; `route` is `"live"`, `"deferred"` or `"pooled"` (extraction AR-017).
|
||||||
|
|
||||||
For a given timestamp `t` (seconds), an actor is visible if any of their
|
**Belief is an attribute, not part of identity.** Two servers that validated the
|
||||||
`scenes` windows satisfies `start <= t <= end`.
|
same upload must agree on its `content_id`, and belief is a producer-side
|
||||||
|
estimate that may legitimately differ between pipeline versions for identical
|
||||||
|
timings. It replicates the way `audio_signature` does — see the server spec §9a.
|
||||||
|
|
||||||
## API
|
**Gap:** entire requirement. The changes are all breaking and ship as **one**
|
||||||
|
bump (SR-003), together with extraction `IR-002` and the server's acceptance of
|
||||||
|
the new shape.
|
||||||
|
|
||||||
All read endpoints below require an authenticated Jellyfin user token (passed as
|
### JR-003 — Unknown `schema_version` is refused, never guessed
|
||||||
`X-Emby-Token` or `Authorization: MediaBrowser Token="..."`); the admin endpoints
|
|
||||||
additionally require the **Administrator** role. Only `GET /Plugins/JRay/ClientScript`
|
|
||||||
is anonymous.
|
|
||||||
|
|
||||||
### `GET /Plugins/JRay/Items/{itemId}/Timeline`
|
**Decision: flag day.** The plugin accepts `schema_version: 2` and rejects
|
||||||
|
everything else, on every path — sidecar read, managed `PUT`, and fetched
|
||||||
|
manifest. There is no transitional dual-accept.
|
||||||
|
|
||||||
Returns the full truth file (schema above) for an item, or `404` if no truth
|
All three components are pre-release and move together, and the alternative
|
||||||
data exists (neither a managed upload nor a sidecar file). Requires an
|
carries a cost that outlasts the transition: a v1 read path is the one nobody
|
||||||
authenticated user token.
|
exercises, so it is the one that rots, and it would have to be carried through
|
||||||
|
every subsequent change to the reader.
|
||||||
|
|
||||||
### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
|
**The consequence must be stated plainly rather than discovered:** existing v1
|
||||||
|
sidecar files on disk **stop being read** at the bump, and stay dark until the
|
||||||
|
library is re-extracted. The plugin logs this per item, naming the file and the
|
||||||
|
version found, rather than silently reporting no coverage — an item that looks
|
||||||
|
un-extracted when it was merely stale is the failure mode that wastes a user's
|
||||||
|
compute.
|
||||||
|
|
||||||
Returns an extensible "context at time t" envelope, or `404` if no truth
|
**Current:** `PUT` rejects `schema_version != 1` with `400`; sidecar reads do not
|
||||||
data exists for the item. Requires an authenticated user token:
|
check the version at all. **Gap:** the version check must move into the shared
|
||||||
|
read path so all three sources are covered, and the target becomes `2`.
|
||||||
|
|
||||||
|
### JR-004 — A window is a scene-membership claim
|
||||||
|
|
||||||
|
**This is SR-002, and it binds this plugin harder than it binds anything else,**
|
||||||
|
because jRay is where the claim reaches a human.
|
||||||
|
|
||||||
|
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. Gaps shorter than
|
||||||
|
`extinction_sec` were absorbed upstream and are claimed as presence.
|
||||||
|
|
||||||
|
The plugin therefore **never reinterprets, merges, splits, or trims windows.**
|
||||||
|
It stores and serves what it was given. The one permitted transformation is the
|
||||||
|
timebase offset of JR-030, which shifts every window uniformly and so preserves
|
||||||
|
the claim.
|
||||||
|
|
||||||
|
**Current:** satisfied.
|
||||||
|
[`PresenceLookup`](Jellyfin.Plugin.JRay/Services/PresenceLookup.cs) is now the
|
||||||
|
unit that decides presence, so the semantics live in one tagged place instead of
|
||||||
|
being implied by a LINQ predicate in the controller. UT-021 pins that adjacent
|
||||||
|
windows such as `[0,10]` and `[10,20]` are *not* merged, and UT-022 that a truth
|
||||||
|
file round-trips byte-identical. **Gap:** none.
|
||||||
|
|
||||||
|
### JR-005 — Query semantics, and how presence is presented
|
||||||
|
|
||||||
|
An actor is present at `t` if any window satisfies `start <= t <= end`.
|
||||||
|
|
||||||
|
**The presentation must not assert instantaneous visibility.** SR-002 is explicit
|
||||||
|
that a consumer must never interpret window boundaries as "the face was detected
|
||||||
|
here", and the overlay is the exact place that misreading would be made
|
||||||
|
user-visible. "On screen now" is a claim the data does not support; "in this
|
||||||
|
scene" is the claim it does.
|
||||||
|
|
||||||
|
This is a wording requirement, not a hedge — it is the difference between the
|
||||||
|
product being right and being a worse version of a frame-by-frame detector.
|
||||||
|
|
||||||
|
**Current:** satisfied, in logic and in wording. Bounds are inclusive at both
|
||||||
|
ends (UT-016/017), a zero-length window is a real sighting rather than a
|
||||||
|
degenerate one to discard (UT-018), and overlapping windows resolve (UT-019).
|
||||||
|
|
||||||
|
The wording was the larger half. The overlay now carries an **"In this scene"**
|
||||||
|
heading — previously it rendered a bare list, which asserted nothing but also
|
||||||
|
told the viewer nothing, and a viewer's default reading of a paused frame is
|
||||||
|
"these people are on screen". The model type `ActorAtTime` became `ActorInScene`,
|
||||||
|
and [`README.md`](README.md) no longer contains the word "on screen" anywhere;
|
||||||
|
it stated the forbidden reading outright in seven places, including the opening
|
||||||
|
sentence. **Gap:** none.
|
||||||
|
|
||||||
|
### JR-006 — Numerous windows
|
||||||
|
|
||||||
|
SR-002 warns that windows may be numerous and consumers must not assume a handful
|
||||||
|
of long ones. Track-extent presence with a short `extinction_sec` produces many
|
||||||
|
short windows per actor, and the previous design's few long ones were an artefact
|
||||||
|
of the over-claiming that was removed.
|
||||||
|
|
||||||
|
The read path must therefore treat per-actor windows as a sorted sequence to be
|
||||||
|
searched, not a short list to be scanned, and the `jray?t=` response must stay
|
||||||
|
small regardless of how many windows an actor has.
|
||||||
|
|
||||||
|
**Current:** satisfied, and now measured rather than assumed. UT-023 builds 50
|
||||||
|
actors × 1000 windows and asserts the `jray?t=` result is bounded by **actor**
|
||||||
|
count, never window count — which is what keeps the response small however finely
|
||||||
|
presence is sliced.
|
||||||
|
|
||||||
|
**The lookup is a full scan, deliberately.** An early exit on `start > t` would
|
||||||
|
exploit the sortedness the format requires, but it would silently under-report
|
||||||
|
the moment one producer emitted windows out of order — a correctness risk traded
|
||||||
|
for a saving that does not register at this scale. UT-020 pins that unsorted
|
||||||
|
input still resolves. `WindowsAreSorted` exists as a diagnostic for surfacing
|
||||||
|
such a producer bug, not as something correctness depends on.
|
||||||
|
|
||||||
|
**Gap:** none for lookup. The whole truth file is still held in memory per cached
|
||||||
|
item, which is a memory question rather than a query-cost one and is untouched
|
||||||
|
here.
|
||||||
|
|
||||||
|
### JR-007 — Identity is public identifiers
|
||||||
|
|
||||||
|
Each actor carries `imdb_id`, `tmdb_id` and `jellyfin_id`, any of which may be
|
||||||
|
`""`. Resolution prefers `jellyfin_id` (a Jellyfin Person GUID) when non-empty,
|
||||||
|
and otherwise matches `imdb_id`/`tmdb_id` against the item's People
|
||||||
|
`ProviderIds`. Never a name alone — names are ambiguous and unstable (SR-001).
|
||||||
|
|
||||||
|
`jellyfin_id` is the exception that proves the rule: it is meaningful only on the
|
||||||
|
instance that produced it, which is exactly why the exchange strips it (JR-034).
|
||||||
|
|
||||||
|
**Current:** implemented. **Gap:** none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Truth-data sources and precedence — JR-008 … JR-011
|
||||||
|
|
||||||
|
### JR-008 — Sidecar discovery
|
||||||
|
|
||||||
|
For `Movie.mkv`, the plugin looks for `Movie.jray.json` beside it — suffix
|
||||||
|
configurable, default `.jray.json`, resolved from the item's media source path.
|
||||||
|
|
||||||
|
**Current:** implemented. **Gap:** none.
|
||||||
|
|
||||||
|
### JR-009 — Managed truth push
|
||||||
|
|
||||||
|
`PUT`/`DELETE .../Truth` let a worker that cannot write beside the media file
|
||||||
|
deliver results over HTTP. Stored under the plugin's configuration directory,
|
||||||
|
keyed by item id, independent of the library filesystem.
|
||||||
|
|
||||||
|
**Current:** implemented. **Gap:** none.
|
||||||
|
|
||||||
|
### JR-010 — Precedence and provenance
|
||||||
|
|
||||||
|
There are now **three** sources: a sidecar file, a push from a local worker, and
|
||||||
|
a manifest fetched from a server. Managed truth — pushed *or* fetched — takes
|
||||||
|
precedence over a sidecar.
|
||||||
|
|
||||||
|
**Fetched manifests are stored through the managed store**, so precedence stays a
|
||||||
|
two-way rule rather than a three-way one, and the read path does not learn about
|
||||||
|
the exchange at all.
|
||||||
|
|
||||||
|
But the three are no longer interchangeable, so **provenance is recorded with the
|
||||||
|
stored truth**: which source it came from, and for a fetched one, which server
|
||||||
|
and at what match tier. A `loose`-tier fetch from a third-party server and a
|
||||||
|
locally-computed sidecar are not the same claim, and JR-036 requires the
|
||||||
|
difference be surfaceable.
|
||||||
|
|
||||||
|
**Current:** satisfied. Two-way precedence in
|
||||||
|
[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs), and
|
||||||
|
[`TruthProvenance`](Jellyfin.Plugin.JRay/Models/TruthProvenance.cs) records
|
||||||
|
source, server, tier, applied offset and caveat. `GET .../Provenance` serves it.
|
||||||
|
|
||||||
|
Two decisions worth keeping. **Provenance is stored beside the truth file, never
|
||||||
|
inside it** — injecting fields would mean the bytes served back are not the bytes
|
||||||
|
the producer wrote, which is the property JR-004 turns on (UT-026 pins this).
|
||||||
|
And **the applied offset is recorded** because it is otherwise unrecoverable:
|
||||||
|
once JR-030 shifts the windows they look native, and nothing would say they had
|
||||||
|
been shifted.
|
||||||
|
|
||||||
|
A sidecar's provenance is derived rather than stored — it is local, and its
|
||||||
|
timestamp is the file's own. **Gap:** none.
|
||||||
|
|
||||||
|
### JR-011 — Caching
|
||||||
|
|
||||||
|
Loaded truth is cached in memory for a configurable duration. Any write —
|
||||||
|
managed `PUT`, `DELETE`, or a stored fetch — invalidates that item's entry
|
||||||
|
immediately, so a push takes effect without waiting for expiry.
|
||||||
|
|
||||||
|
**Current:** implemented. **Gap:** none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Read API — JR-012 … JR-014
|
||||||
|
|
||||||
|
### JR-012 — `GET /Plugins/JRay/Items/{itemId}/Timeline`
|
||||||
|
|
||||||
|
Returns the full truth file (§1), or `404` if no truth data exists from any
|
||||||
|
source.
|
||||||
|
|
||||||
|
### JR-013 — `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
|
||||||
|
|
||||||
|
Returns an extensible "context at time `t`" envelope, or `404`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"actors": [
|
"actors": [
|
||||||
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
|
{ "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", "jellyfin_id": "abc123-guid" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Future fields (e.g. `locations`, `trivia`) will be added to this object
|
Future fields (`locations`, `trivia`, and per JR-005 a presence caveat) are added
|
||||||
without changing the route, so clients should ignore unknown keys.
|
to this object without changing the route, so **clients must ignore unknown
|
||||||
|
keys**.
|
||||||
|
|
||||||
### `PUT /Plugins/JRay/Items/{itemId}/Truth`
|
### JR-014 — Authorisation
|
||||||
|
|
||||||
For servers that cannot run the extraction pipeline locally, a remote worker
|
| Route | Requires |
|
||||||
may push truth data directly. Requires an administrator API key. Body is a
|
|---|---|
|
||||||
truth file (schema above). Returns `204` on success, or `400` if
|
| `Timeline`, `jray?t=` | Authenticated Jellyfin user token |
|
||||||
`schema_version` is not `1`.
|
| `Truth`, `Tasks/*`, `Policy/*`, `Coverage/*`, and §6's fetch routes | **Administrator** role |
|
||||||
|
| `ClientScript` | Anonymous — it is injected into a page served before login |
|
||||||
|
|
||||||
This "managed" truth data takes precedence over any sidecar
|
**Current:** all three implemented as stated. **Gap:** none.
|
||||||
`Movie.jray.json` file for the same item, and is stored independently of the
|
|
||||||
media library filesystem.
|
|
||||||
|
|
||||||
### `DELETE /Plugins/JRay/Items/{itemId}/Truth`
|
---
|
||||||
|
|
||||||
Removes managed truth data for an item (idempotent, always returns `204`).
|
## 4. Work discovery, policy and coverage — JR-015 … JR-019
|
||||||
The item falls back to its sidecar truth file, if any, on subsequent reads.
|
|
||||||
Requires an administrator API key.
|
|
||||||
|
|
||||||
### `GET /Plugins/JRay/ClientScript`
|
### JR-015 — `GET /Plugins/JRay/Tasks/Pending?limit=10`
|
||||||
|
|
||||||
Serves the pause-overlay script that JRay injects into the web client's
|
A **random** sample (default 10, max 100) of movies and episodes with no truth
|
||||||
`index.html` (see below). Anonymous access.
|
data:
|
||||||
|
|
||||||
### `GET /Plugins/JRay/Tasks/Pending?limit=10`
|
|
||||||
|
|
||||||
Lets a remote extraction worker discover what to work on next. Returns a
|
|
||||||
random sample (default 10, max 100) of movies/episodes in the library that
|
|
||||||
have no truth data yet (neither a managed upload nor a sidecar file):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[ { "item_id": "abc123-guid", "path": "/data/movies/Movie.mkv", "name": "Movie" } ]
|
||||||
{ "item_id": "abc123-guid", "path": "/data/movies/Movie.mkv", "name": "Movie" }
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires an administrator API key. The sample is random and unordered, so
|
Randomness is the design: repeated polling spreads work across the backlog
|
||||||
repeated polling naturally spreads work across the backlog without needing
|
without the server tracking who is working on what, and two workers polling
|
||||||
server-side task tracking; an empty array means there's nothing left to do
|
concurrently mostly do not collide. An empty array means nothing is left, or that
|
||||||
(or every remaining item is a virtual/missing-path item that JRay can't
|
everything remaining is a virtual/missing-path item.
|
||||||
process).
|
|
||||||
|
|
||||||
## Client: pushing results from a remote extraction worker
|
### JR-016, JR-017 — Prioritise / ignore rules
|
||||||
|
|
||||||
A worker that runs the extraction pipeline on a different machine than
|
Rules steer the queue: each targets a **genre**, a **series**, or an **item**,
|
||||||
Jellyfin (i.e. it cannot write a `Movie.jray.json` sidecar next to the media
|
and either prioritises (front of the queue) or ignores (hidden entirely). This is
|
||||||
file) can push results directly over HTTP.
|
how an admin says "never extract anime", "this series first", or "skip this one".
|
||||||
|
|
||||||
To find work, poll `GET /Plugins/JRay/Tasks/Pending?limit=10` (see above) for
|
Resolution picks the **most specific** match: `Item` > `Series` > `Genre`. A rule
|
||||||
a random batch of items that still need processing, instead of walking the
|
is keyed by scope + value, and setting one replaces any existing rule for the
|
||||||
whole library and checking each item's `Timeline`/sidecar yourself.
|
same key — so a single target can never be simultaneously prioritised and
|
||||||
|
ignored. Cross-scope conflicts (a prioritised series inside an ignored genre) are
|
||||||
|
resolved by specificity: the series wins.
|
||||||
|
|
||||||
### 1. Authenticate
|
```json
|
||||||
|
{ "scope": "Genre", "value": "Anime", "action": "Ignore", "label": "Anime" }
|
||||||
Create an **Administrator** API key in Jellyfin (Dashboard → API Keys), and
|
|
||||||
send it on every request as either:
|
|
||||||
|
|
||||||
```
|
|
||||||
X-Emby-Token: <api-key>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
or:
|
`value` is a genre name, a series id GUID, or an item id GUID; genre matching is
|
||||||
|
case-insensitive. Persisted to `policy.json` in the plugin's configuration
|
||||||
|
directory.
|
||||||
|
|
||||||
```
|
**JR-017 is the constraint worth stating separately: rules affect work discovery
|
||||||
Authorization: MediaBrowser Token="<api-key>"
|
only.** They never change the overlay or the read endpoints. An item you ignore
|
||||||
|
for extraction still shows its overlay if truth data happens to exist — because
|
||||||
|
the rule expresses "don't spend compute here", not "pretend this doesn't exist".
|
||||||
|
|
||||||
|
Endpoints: `GET`/`PUT /Plugins/JRay/Policy/Rules`, and
|
||||||
|
`DELETE /Plugins/JRay/Policy/Rules?scope=&value=` (idempotent).
|
||||||
|
|
||||||
|
### JR-018 — `GET /Plugins/JRay/Coverage`
|
||||||
|
|
||||||
|
How much of the library has truth data, overall and by media type and genre:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": { "total": 1200, "covered": 300, "pending": 850, "prioritised": 40, "ignored": 50 },
|
||||||
|
"by_media_type": [ { "label": "Film", "counts": { "…": 0 } } ],
|
||||||
|
"by_genre": [ { "label": "Anime", "counts": { "…": 0 } } ]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Resolve the Jellyfin item id
|
`prioritised` is a subset of `pending`. Percent done is
|
||||||
|
`covered / (total - ignored)` — **ignoring a genre does not drag the percentage
|
||||||
|
down**, because ignored items are intentionally out of scope, not outstanding
|
||||||
|
work. An item counts toward every genre it carries, so genre rows overlap and
|
||||||
|
need not sum to the library total.
|
||||||
|
|
||||||
The push endpoint is keyed by the Jellyfin item GUID, not by file path. To
|
### JR-019 — Pickers
|
||||||
find it for `Movie.mkv`:
|
|
||||||
|
`Coverage/Genres`, `Coverage/Series`, and `Coverage/Items?search=&limit=`
|
||||||
|
populate the rule editor's dropdowns, each returning
|
||||||
|
`[{ "value": …, "label": … }]`. An absent `search` returns `[]`.
|
||||||
|
|
||||||
|
**Current (JR-015 … JR-019):** all implemented. **Gap:** none functionally —
|
||||||
|
these traced to no requirement until this register existed, which by the gate's
|
||||||
|
own definition read as scope creep. They serve PR-003 (fully automatic, no
|
||||||
|
per-title manual work): steering a queue is how automation is directed without
|
||||||
|
becoming per-title labour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Player overlay — JR-020 … JR-024
|
||||||
|
|
||||||
|
### JR-020 — The overlay
|
||||||
|
|
||||||
|
Jellyfin has no plugin hook for player UI, so jRay adds
|
||||||
|
`<script defer src="/Plugins/JRay/ClientScript"></script>` to the web client's
|
||||||
|
`index.html`. The script listens for the player's pause event, calls `jray?t=`
|
||||||
|
for the current item and timestamp, and renders the scene's cast.
|
||||||
|
|
||||||
|
Per JR-005 it presents scene membership, not instantaneous visibility.
|
||||||
|
|
||||||
|
### JR-021 — jRay never injects into `index.html` on disk
|
||||||
|
|
||||||
|
**File Transformation is a hard requirement, not a preference. There is no
|
||||||
|
on-disk patching fallback.**
|
||||||
|
|
||||||
|
The prohibition is on **injection**, not on writing: JR-022's migration must
|
||||||
|
write to the file in order to remove a legacy patch. Stating it as "never
|
||||||
|
writes" would put the two requirements in contradiction, and the static check
|
||||||
|
would have to be disabled to let the migration through — so the check is that no
|
||||||
|
code path *adds* the script tag.
|
||||||
|
|
||||||
|
At startup jRay looks for the
|
||||||
|
[File Transformation](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation)
|
||||||
|
assembly via `AssemblyLoadContext` and, if present, calls
|
||||||
|
`Jellyfin.Plugin.FileTransformation.PluginInterface.RegisterTransformation` by
|
||||||
|
reflection — no compile-time dependency, so jRay loads normally when it is
|
||||||
|
absent. The payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90",
|
||||||
|
"fileNamePattern": "index.html",
|
||||||
|
"callbackAssembly": "<jRay assembly full name>",
|
||||||
|
"callbackClass": "Jellyfin.Plugin.JRay.Services.FileTransformationRegistration",
|
||||||
|
"callbackMethod": "TransformIndexHtml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
File Transformation matches `callbackAssembly` against `Assembly.FullName`
|
||||||
|
exactly, so the full display name is sent. The callback is a public static method
|
||||||
|
taking a payload with a `contents` string and returning the transformed string;
|
||||||
|
the payload binds with Newtonsoft, which matches property names
|
||||||
|
case-insensitively. Registration is unconditional at startup — the callback
|
||||||
|
itself checks the "enable overlay" setting per request, so toggling takes effect
|
||||||
|
without re-registering.
|
||||||
|
|
||||||
|
Writing to `index.html` is rejected because it is destructive in ways a plugin
|
||||||
|
cannot clean up after:
|
||||||
|
|
||||||
|
- **It outlives the plugin.** Uninstalling jRay leaves the patch in a file jRay
|
||||||
|
no longer owns.
|
||||||
|
- **It breaks on upgrade.** A web-client update replaces the file, discarding the
|
||||||
|
patch — or preserves one pointing at an endpoint that has since changed.
|
||||||
|
- **It collides.** Another plugin patching the same file races with jRay, and the
|
||||||
|
loser's edit is lost with no diagnostic.
|
||||||
|
- **It is a second code path**, and it is the one nobody runs, so it is the one
|
||||||
|
that rots.
|
||||||
|
|
||||||
|
**Current:** satisfied.
|
||||||
|
[`WebClientPatchService`](Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs)
|
||||||
|
is removal-only — the injection capability is *deleted*, not switched off, since
|
||||||
|
dead code with a live signature is what a later refactor re-enables by accident.
|
||||||
|
Enforced by
|
||||||
|
[`scripts/checks/no-index-injection.sh`](scripts/checks/no-index-injection.sh),
|
||||||
|
which was verified to fail on a reintroduced injection rather than merely to pass
|
||||||
|
today. [`README.md`](README.md) no longer advertises a fallback. **Gap:** none.
|
||||||
|
|
||||||
|
### JR-022 — Migrate away from earlier on-disk patches
|
||||||
|
|
||||||
|
Users upgrading from a version that patched the file must not be left with a
|
||||||
|
stale injection. On startup jRay removes any on-disk patch bearing its own
|
||||||
|
`<!-- jray-overlay -->` marker — unambiguous, and touching nothing another plugin
|
||||||
|
added.
|
||||||
|
|
||||||
|
**Current:** implemented — `WebClientPatchService.RemoveLegacyPatch` runs at
|
||||||
|
every startup and is a no-op once the marker is gone. The strip itself is
|
||||||
|
factored out as `RemoveInjection` so it is unit-testable without a filesystem.
|
||||||
|
**Gap:** no test executes it yet, so this stays `In Progress` rather than
|
||||||
|
`Done` — there is no test project in this repo.
|
||||||
|
|
||||||
|
### JR-023 — A hard dependency Jellyfin cannot resolve
|
||||||
|
|
||||||
|
**Jellyfin has no plugin dependency mechanism.** A manifest cannot declare that
|
||||||
|
another plugin is required, and nothing will install one. File Transformation
|
||||||
|
documents only an end-user repository URL and a reflection integration for plugin
|
||||||
|
authors; there is no NuGet-style dependency to take.
|
||||||
|
|
||||||
|
**jRay must not bundle the assembly.** A bundled copy would sit in a different
|
||||||
|
`AssemblyLoadContext` from the real one — precisely the failure the reflection
|
||||||
|
integration exists to avoid — on top of licensing and version skew. The
|
||||||
|
dependency is satisfied by the user installing the real plugin.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
1. **Detect at startup and say so** — log a warning naming the plugin and its
|
||||||
|
install URL, and disable only the overlay. Every other feature works.
|
||||||
|
2. **Surface it where it can be acted on** — the configuration page shows
|
||||||
|
dependency status: satisfied, or missing with the manifest URL
|
||||||
|
`https://www.iamparadox.dev/jellyfin/plugins/manifest.json` and a one-line
|
||||||
|
instruction. A warning only in the server log is one nobody reads.
|
||||||
|
3. **State it as a prerequisite in install docs**, before the jRay install step.
|
||||||
|
|
||||||
|
Optionally, publish jRay through a repository manifest that also lists File
|
||||||
|
Transformation, so one repository URL surfaces both. This is not a dependency
|
||||||
|
mechanism; it removes a step and the chance of installing the wrong thing.
|
||||||
|
|
||||||
|
**Current:** all three implemented, and the detection half is tested
|
||||||
|
(UT-012…015). Startup detection and registration, a warning naming the plugin
|
||||||
|
and its install URL, and a status banner on the configuration page fed by
|
||||||
|
`GET /Plugins/JRay/Status/Dependencies`. The README states the dependency before
|
||||||
|
the install step rather than after it.
|
||||||
|
|
||||||
|
Both failure-path log messages previously said the overlay was "falling back to
|
||||||
|
patching index.html on disk" — a claim JR-021 made false, and the worst place to
|
||||||
|
leave one: an admin reading it while debugging a missing overlay would go hunting
|
||||||
|
for a patch that no longer exists. UT-013 asserts the message names the install
|
||||||
|
URL and does *not* claim a fallback.
|
||||||
|
|
||||||
|
**Gap:** the config-page banner is T4, verifiable only against a live server.
|
||||||
|
|
||||||
|
### JR-024 — Names render as text, never markup
|
||||||
|
|
||||||
|
Every string that reaches the overlay — actor names above all — is rendered as
|
||||||
|
text. With §6 the source of those strings may be a third-party server, and the
|
||||||
|
server spec §5a names this the single most important client-side control,
|
||||||
|
because it holds even when every other check is bypassed.
|
||||||
|
|
||||||
|
It is stated here as a plugin requirement because the server cannot enforce it
|
||||||
|
and the DOM is jRay's.
|
||||||
|
|
||||||
|
**Current:** satisfied. [`Web/jray-overlay.js`](Jellyfin.Plugin.JRay/Web/jray-overlay.js)
|
||||||
|
uses `textContent` throughout — no `innerHTML`, no `insertAdjacentHTML`. **Gap:**
|
||||||
|
nothing behavioural. It holds today by construction rather than by rule, which
|
||||||
|
is what the static check exists to keep true once §6 makes remote strings
|
||||||
|
reachable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Manifest exchange client — JR-025 … JR-037
|
||||||
|
|
||||||
|
The wire format, tiers and server behaviour are specified in
|
||||||
|
[`../JRay-public-server/SPEC.md`](../JRay-public-server/SPEC.md). **This section
|
||||||
|
owns the client half**, which previously lived in that document's §9 — an
|
||||||
|
inversion, since those are obligations on this repo.
|
||||||
|
|
||||||
|
A Jmanifest is this truth file plus a portable identity block and a cut
|
||||||
|
fingerprint, minus the installation-local fields.
|
||||||
|
|
||||||
|
### JR-025, JR-026, JR-037 — Server list and resolution
|
||||||
|
|
||||||
|
The plugin queries a configured **ordered list**, not a single URL. Per server:
|
||||||
|
`Url`, `Name`, `Token` (contribution only), `Enabled`, `AllowContribute`,
|
||||||
|
`TrustLevel` (`Full` / `FetchOnly`). A community entry ships pre-configured but
|
||||||
|
**disabled**.
|
||||||
|
|
||||||
|
**First acceptable wins** — servers are tried in order, and the first result
|
||||||
|
clearing the configured tier is taken. Order *is* the user's trust ranking, made
|
||||||
|
explicit. Best-match-across-all would multiply egress and leak the library to
|
||||||
|
more parties for a gain the ordering already expresses.
|
||||||
|
|
||||||
|
**For a series, first-match applies per episode** (JR-026): fetch the bundle from
|
||||||
|
server 1, then query server 2 only for what is still missing. Series are commonly
|
||||||
|
split across sources, and this is where multiple servers earn their keep.
|
||||||
|
|
||||||
|
**Failure isolation** (JR-037): an unreachable or failing server is skipped after
|
||||||
|
a short timeout (5 s connect, 30 s read) and marked failed with exponential
|
||||||
|
backoff. One dead server must never stall a library sweep; failures surface
|
||||||
|
per-server in the config page.
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
[`ManifestServer`](Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs) and
|
||||||
|
[`PluginConfiguration`](Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs)
|
||||||
|
model all of this. **Gap:** nothing consumes them — there is no HTTP client.
|
||||||
|
|
||||||
|
### JR-027 … JR-029 — Every server is untrusted
|
||||||
|
|
||||||
|
Everything in the server spec §5a is a property of a *correctly operated* server.
|
||||||
|
Pointing the plugin at an arbitrary URL inherits none of it. jRay therefore
|
||||||
|
re-applies client-side what a server applies on upload, **including for the
|
||||||
|
default server**:
|
||||||
|
|
||||||
|
- **JR-027 — validate on receipt.** Downloaded manifests go through the same
|
||||||
|
strict schema as uploads: unknown fields rejected, sizes capped, and windows
|
||||||
|
bounds-checked against the item's real runtime. A manifest is never trusted
|
||||||
|
because a server served it.
|
||||||
|
- **JR-028 — size caps enforced while streaming**, so an unbounded body is
|
||||||
|
aborted rather than buffered. 2 MiB single manifest, 25 MiB bundle.
|
||||||
|
- **JR-029 — HTTPS required** for non-loopback servers, with certificate
|
||||||
|
validation never disabled. A plaintext server would let any intermediary
|
||||||
|
rewrite actor overlays.
|
||||||
|
- **`TrustLevel: FetchOnly`** — the default for user-added servers — accepts
|
||||||
|
manifests but never contributes and never sends inventory beyond the single
|
||||||
|
item queried.
|
||||||
|
|
||||||
|
The honest framing for the config page: *adding a third-party server means
|
||||||
|
trusting its operator not to serve you deliberately wrong actor data.* The
|
||||||
|
controls above bound the damage to bad overlay content; they cannot make wrong
|
||||||
|
data right.
|
||||||
|
|
||||||
|
### JR-030 — Offsets are applied before storage
|
||||||
|
|
||||||
|
When a match carries a non-zero `offset` (the `audio` tier — §8), the plugin
|
||||||
|
**must** add it to every scene window before storing.
|
||||||
|
|
||||||
|
**The stored truth file is always in the local file's own timebase.** This is
|
||||||
|
what keeps the offset out of the read path entirely: `Timeline`, `jray?t=` and
|
||||||
|
the overlay never learn that an offset existed. An offset applied at read time
|
||||||
|
would have to be applied identically in three places and would be wrong in the
|
||||||
|
fourth.
|
||||||
|
|
||||||
|
### JR-031, JR-032 — Endpoints
|
||||||
|
|
||||||
|
Mirroring the existing Truth and Tasks controllers:
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `POST /Plugins/JRay/Items/{itemId}/Fetch` | Resolve across servers in order; on a match at or above the configured tier, apply JR-030 and store via the managed store |
|
||||||
|
| `POST /Plugins/JRay/Series/{seriesId}/Fetch` | Bundle fetch with per-episode gap-filling |
|
||||||
|
| `GET /Plugins/JRay/Servers/Status` | Per-server reachability and last error, for the config page |
|
||||||
|
| `POST /Plugins/JRay/Items/{itemId}/Identify` | Compute the audio signature and search by content, for items of unknown providence |
|
||||||
|
|
||||||
|
**JR-032: `Identify` never stores automatically.** It returns candidate titles
|
||||||
|
with scores and offsets; storing one is a separate confirmation step. Content
|
||||||
|
identification is a guess about what a file *is*, and a wrong guess silently
|
||||||
|
attaches another film's cast to it.
|
||||||
|
|
||||||
|
### JR-033 — Scheduled sweep
|
||||||
|
|
||||||
|
A scheduled task walks items with no truth data and attempts a fetch, reusing the
|
||||||
|
`Tasks/Pending` backlog logic — including its policy rules — and the **batch**
|
||||||
|
`exists` endpoint, so a sweep is a handful of requests per server rather than one
|
||||||
|
per item.
|
||||||
|
|
||||||
|
### JR-034, JR-035 — Contribution
|
||||||
|
|
||||||
|
On a `PUT .../Truth` from a local worker, if contribution is enabled: strip
|
||||||
|
`movie` and `jellyfin_id`, attach identity from the item's `ProviderIds` and its
|
||||||
|
measured runtime, and `POST` to each contribute-enabled server. For a series,
|
||||||
|
batch into one bundle upload rather than per-episode posts.
|
||||||
|
|
||||||
|
**Stripping is a requirement, not hygiene.** `movie` leaks the contributor's
|
||||||
|
directory layout and `jellyfin_id` is a GUID from their database — meaningless
|
||||||
|
elsewhere and mildly identifying. The server rejects both, but the plugin must
|
||||||
|
not send them in the first place.
|
||||||
|
|
||||||
|
**Contribution is never fanned out.** A manifest goes only to servers with
|
||||||
|
`AllowContribute` set, each an explicit choice.
|
||||||
|
|
||||||
|
**JR-035:** uploads set `Expect: 100-continue`, so a server rejecting on size or
|
||||||
|
auth does so before the body is transmitted. This matters most for bundles, where
|
||||||
|
a rejected upload would otherwise push tens of MiB pointlessly.
|
||||||
|
|
||||||
|
### JR-036 — Match tier is the user's dial
|
||||||
|
|
||||||
|
The configured minimum tier (`audio` / `runtime` / `loose`) gates what may be
|
||||||
|
stored. A `loose` match — runtimes within ±30 s — is plausibly a different trim
|
||||||
|
of the same cut, so it is **surfaced as a caveat in the UI**, not applied
|
||||||
|
silently. Per JR-010 the tier is recorded with the stored truth, which is what
|
||||||
|
makes surfacing it possible after the fetch has finished.
|
||||||
|
|
||||||
|
**There is no `exact` tier here, and the plugin sends no `video_hash`.** The
|
||||||
|
server spec §3 defines `exact` as an equal OpenSubtitles file hash, and it is the
|
||||||
|
strongest *technical* signal available — it identifies a specific file, so it
|
||||||
|
cannot produce a false positive. That is exactly why it is withdrawn.
|
||||||
|
|
||||||
|
A TMDB id discloses "some copy of this film", which is what a library catalogue
|
||||||
|
discloses. A file hash discloses **this exact release**, which turns a catalogue
|
||||||
|
lookup into a release-identification service and turns a 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 asked to hold.
|
||||||
|
|
||||||
|
The audio signature is the deliberate replacement: derived from *content*, it
|
||||||
|
identifies the **cut** rather than the copy, 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. `audio` is therefore the
|
||||||
|
top tier.
|
||||||
|
|
||||||
|
A server may still hold hashes contributed by other clients; this plugin simply
|
||||||
|
never participates, and `MatchTier` has no `Exact` member so no code path can
|
||||||
|
come to depend on one.
|
||||||
|
|
||||||
|
**Current:** `MinimumMatchTier` exists in configuration, defaulting to `runtime`,
|
||||||
|
and `ManifestExchangeClient` rejects a below-tier match. **Gap:** the caveat is
|
||||||
|
returned by the fetch endpoint but not yet displayed in the overlay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Egress and privacy — JR-038 … JR-041
|
||||||
|
|
||||||
|
Contribution reveals to a server operator that some instance holds a given title.
|
||||||
|
Fetching reveals the same. That is inherent to the exchange — which is why the
|
||||||
|
requirements here bound it rather than claim to remove it.
|
||||||
|
|
||||||
|
- **JR-038 — opt-in, off by default.** Manifest sharing, contribution and audio
|
||||||
|
signatures are three separate switches, all default off, and the pre-configured
|
||||||
|
community server ships **disabled**. No traffic leaves an installation until an
|
||||||
|
admin acts.
|
||||||
|
- **JR-039 — no library-wide inventory in one request.** The batch `exists`
|
||||||
|
endpoint is capped at 100 items and sweeps are paced. A single request
|
||||||
|
enumerating a library is a fingerprint of it, which is the thing PR-005 exists
|
||||||
|
to prevent.
|
||||||
|
- **JR-040 — the config page says plainly that each configured server multiplies
|
||||||
|
the exposure.** First-match resolution limits it — later servers are queried
|
||||||
|
only for what earlier ones lacked — and that is worth stating too.
|
||||||
|
- **JR-041 — the plugin never touches gallery data.** No reference faces, no
|
||||||
|
embeddings, fetched or stored or transmitted. There is no such code path and
|
||||||
|
there must not be one (SR-005). Verified by static check, mirroring the
|
||||||
|
server's UR-012.
|
||||||
|
|
||||||
|
**Current:** JR-038 holds — every switch defaults off. JR-041 holds vacuously,
|
||||||
|
there being no gallery code. **Gap:** JR-039 and JR-040 are unimplemented,
|
||||||
|
alongside the exchange client itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Audio signature — JR-042 … JR-045
|
||||||
|
|
||||||
|
A content-derived fingerprint from the centre of a media file, used to identify a
|
||||||
|
file of unknown providence and to recover the time offset between differently
|
||||||
|
trimmed releases of the same cut. Construction is specified in
|
||||||
|
[`../JRay-public-server/SPEC.md` §3](../JRay-public-server/SPEC.md) and must be
|
||||||
|
implemented **exactly**:
|
||||||
|
|
||||||
|
1. Decode a 120 s window centred on the midpoint (`runtime/2 ± 60 s`) — avoiding
|
||||||
|
logos and cold opens at the head, credits at the tail.
|
||||||
|
2. Downmix to mono, resample to 11025 Hz.
|
||||||
|
3. STFT: 4096-sample frame, 1024-sample hop (~93 ms, ~1290 frames), Hann window.
|
||||||
|
4. Log-magnitude spectrum over 300–3000 Hz.
|
||||||
|
5. 32 logarithmically spaced bins; record peak-bin index plus a 2-bit energy
|
||||||
|
class.
|
||||||
|
6. One byte per frame → ~1290-byte array, base64-encoded.
|
||||||
|
|
||||||
|
**JR-042 — no new dependency.** FFmpeg performs decode, downmix and resample,
|
||||||
|
using the binary Jellyfin already ships, reached via `IMediaEncoder.EncoderPath`
|
||||||
|
from `MediaBrowser.Controller.MediaEncoding`. The plugin implements only a small
|
||||||
|
fixed FFT and bin-peak extraction.
|
||||||
|
|
||||||
|
**JR-043 — bit-exactness is verified, not assumed.** The pipeline computes this
|
||||||
|
signature too (extraction `IR-004`), deliberately: files never processed locally
|
||||||
|
still get one from the plugin. Two independent implementations of one fingerprint
|
||||||
|
are only useful if they agree exactly, so a **golden-vector fixture is shared
|
||||||
|
between the two repos** — a short WAV and its expected signature, committed in
|
||||||
|
both. It is CPU-only DSP, which is why this cross-repo check can be a binding CI
|
||||||
|
test rather than an aspiration. Extraction's counterpart is `IR-005`.
|
||||||
|
|
||||||
|
**JR-044 — media shorter than 120 s.** The window underflows, so **no signature
|
||||||
|
is emitted and no sync offset is applied**. Such items fall back to the runtime
|
||||||
|
tier, which is adequate: a 90-second extra is not content whose cut alignment
|
||||||
|
matters. Both producers must apply the identical rule, or they diverge
|
||||||
|
on exactly the short items most likely to be misidentified. Extraction's
|
||||||
|
counterpart is `IR-007`.
|
||||||
|
|
||||||
|
**JR-045 — the signature carries its own `v1:` prefix**, separate from
|
||||||
|
`schema_version`. Emit and honour it, so a future change to the DSP chain is
|
||||||
|
*detectable* rather than silently producing non-matching signatures. Extraction's
|
||||||
|
counterpart is `IR-008`.
|
||||||
|
|
||||||
|
Matching — sliding ±600 frames (≈±56 s), scoring the fraction of overlapping
|
||||||
|
frames whose peak bin matches — is a **consumer** concern and belongs to this
|
||||||
|
plugin. Offsets are applied client-side per JR-030; manifests are never
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
**Current:** `ComputeAudioSignatures` exists as a configuration switch. **Gap:**
|
||||||
|
entire requirement, both computation and matching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Human-in-the-loop association — JR-046
|
||||||
|
|
||||||
|
*Proposed. See [system spec](scripts/vendor/jray-project/SPEC.md) §4, which owns the design.*
|
||||||
|
|
||||||
|
The pipeline produces **unidentified tracks** — a face that is genuinely someone,
|
||||||
|
sustained across many frames, that the gallery cannot name. A user watching the
|
||||||
|
film usually knows exactly who it is. jRay's contribution is the review UI: show
|
||||||
|
a cluster's context crops, let the user pick from the title's cast or search
|
||||||
|
TMDB, and record the association for extraction to ingest into the local gallery.
|
||||||
|
|
||||||
|
**The unit of review is a person, not a track.** Unknown tracks are clustered
|
||||||
|
upstream (`AR-021`), so the question is "who is this person, who appears in these
|
||||||
|
twelve places?" rather than twelve disconnected questions. One answer resolves
|
||||||
|
the cluster.
|
||||||
|
|
||||||
|
Deliberately left as a single `TBD` row rather than decomposed. It depends on
|
||||||
|
extraction `AR-021`/`AR-022` landing, and on **system open question 2** — whether
|
||||||
|
unidentified presence is published in the truth file at all, which determines
|
||||||
|
whether this UI's work queue arrives with the truth data or needs a separate
|
||||||
|
channel. Decomposing now would fix an interface against an undecided upstream.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Client: pushing results from a remote worker
|
||||||
|
|
||||||
|
Reference material for worker authors; the requirements are JR-009 and JR-015.
|
||||||
|
|
||||||
|
**1. Authenticate.** Create an Administrator API key (Dashboard → API Keys) and
|
||||||
|
send it as `X-Emby-Token: <key>` or
|
||||||
|
`Authorization: MediaBrowser Token="<key>"`.
|
||||||
|
|
||||||
|
**2. Find work.** Poll `GET /Plugins/JRay/Tasks/Pending?limit=10` rather than
|
||||||
|
walking the library and checking each item.
|
||||||
|
|
||||||
|
**3. Resolve the item id.** The push endpoint is keyed by Jellyfin item GUID, not
|
||||||
|
path:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /Items?Recursive=true&Fields=Path&IncludeItemTypes=Movie,Episode
|
GET /Items?Recursive=true&Fields=Path&IncludeItemTypes=Movie,Episode
|
||||||
```
|
```
|
||||||
|
|
||||||
(use `&ParentId=<library-id>` to narrow the search if the library is large).
|
Match `Path` against the file you processed — which requires the worker to see
|
||||||
Each returned item DTO has `Id` (the GUID) and `Path`. Match `Path` against
|
the file at the *same path* Jellyfin does; translate first if it mounts the
|
||||||
the absolute path of the file you just processed — note this requires the
|
library elsewhere. The mapping is stable until the file moves, so cache
|
||||||
worker to see the file at the *same path* Jellyfin does (same mount/share);
|
`path -> itemId` and re-resolve only on a miss.
|
||||||
translate paths first if the worker mounts the library elsewhere.
|
|
||||||
|
|
||||||
This mapping is stable until the file is moved/re-scanned, so the worker
|
**4. Push.** `PUT /Plugins/JRay/Items/{itemId}/Truth` with the truth file body.
|
||||||
should cache `path -> itemId` and only re-resolve on a cache miss.
|
`204` stored (cache invalidated immediately), `400` unsupported
|
||||||
|
`schema_version`, `401`/`403` key missing or not an administrator. The `PUT` is
|
||||||
|
idempotent, so retrying on a network error is safe.
|
||||||
|
|
||||||
### 3. Push the truth file
|
**5. Optionally remove.** `DELETE /Plugins/JRay/Items/{itemId}/Truth` always
|
||||||
|
returns `204`; the item falls back to its sidecar on the next read.
|
||||||
|
|
||||||
```
|
---
|
||||||
PUT /Plugins/JRay/Items/{itemId}/Truth
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
<truth file JSON, schema_version 1, as produced by result_sink_node>
|
## 11. Open questions
|
||||||
```
|
|
||||||
|
|
||||||
- `204 No Content` — stored. Takes effect immediately (any cached read for
|
1. **`sample_fps`, `gallery_size` and `pipeline_version` move under
|
||||||
this item is invalidated server-side).
|
`extraction.*` in JR-002.** This aligns the truth file with the Jmanifest's
|
||||||
- `400 Bad Request` — `schema_version` is not `1`.
|
block of the same name, and the bump is breaking regardless. It is a change
|
||||||
- `401`/`403` — API key missing or not an administrator.
|
this spec proposes rather than one inherited from SR-003's list — confirm, or
|
||||||
|
keep them top-level.
|
||||||
The `PUT` is idempotent (replaces any existing managed truth for the item),
|
2. **Does `route` belong in the `jray?t=` envelope?** JR-013 says the response is
|
||||||
so the worker can safely retry on network errors.
|
extensible and JR-005 says presentation must not over-claim. Exposing belief
|
||||||
|
and route would let the overlay caveat a weak claim, but invites a UI that
|
||||||
### 4. (Optional) Remove pushed data
|
shows a number to a viewer who cannot act on it.
|
||||||
|
3. **System open question 2 — unidentified presence.** If published, the overlay
|
||||||
```
|
could show "unidentified person" and JR-046 gets its queue from the truth file
|
||||||
DELETE /Plugins/JRay/Items/{itemId}/Truth
|
directly. jRay is the consumer that would have to display it, so this repo has
|
||||||
```
|
a position to state.
|
||||||
|
4. **Test-ID namespacing.** `UT-nnn`/`IT-nnn` are per-component registers, so
|
||||||
Always returns `204`. The item falls back to a sidecar `Movie.jray.json` (if
|
`UT-001` will exist in both this repo and `scene-actor-extraction`. Fine while
|
||||||
any) on the next read.
|
the gate runs per repo; ambiguous the moment a rollup spans them.
|
||||||
|
|
||||||
## Web client pause overlay
|
|
||||||
|
|
||||||
Since Jellyfin has no plugin hook for player UI, JRay injects
|
|
||||||
`<script defer src="/Plugins/JRay/ClientScript"></script>` into the web
|
|
||||||
client's `index.html` on startup (idempotent, marked with
|
|
||||||
`<!-- jray-overlay -->`). The injected script listens for the video player's
|
|
||||||
pause event, calls `jray?t=` for the current item and timestamp, and renders
|
|
||||||
a small overlay listing on-screen actors. This can be disabled via the
|
|
||||||
plugin's "Enable pause overlay" setting, which also removes the injected
|
|
||||||
script.
|
|
||||||
|
|||||||
+6
-1
@@ -19,4 +19,9 @@ dotnet_configuration: "Release"
|
|||||||
dotnet_framework: "net9.0"
|
dotnet_framework: "net9.0"
|
||||||
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
|
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
|
||||||
changelog: >
|
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,350 @@
|
|||||||
|
# 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** |
|
||||||
|
|
||||||
|
All execute and pass. 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. Three so far, each 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 |
|
||||||
|
|
||||||
|
The third 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 | Planned |
|
||||||
|
| JR-003 | Reject an unknown `schema_version`, never guess. **Flag day: v2 only**, no dual-accept | SR-003 | High | Planned |
|
||||||
|
| 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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
| 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 | Planned |
|
||||||
|
| JR-043 | Golden-vector fixture **shared with the extraction repo**, proving the two implementations are bit-exact | SR-003 | High | Planned |
|
||||||
|
| JR-044 | Media shorter than 120 s: emit no signature and apply no sync offset — identical rule in both producers | SR-003 | Low | Planned |
|
||||||
|
| 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 | Planned |
|
||||||
|
|
||||||
|
## 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 | Window with belief exactly at the ownership threshold; all three route values |
|
||||||
|
| JR-003 | **T1** | `schema_version` 1 and 3 are both **rejected**, not coerced | Missing field entirely; non-integer value |
|
||||||
|
| 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 |
|
||||||
|
| JR-043 | **T1** | Signature matches the shared golden vector **bit-for-bit** | Media < 120 s → no signature; identical result in both repos |
|
||||||
|
| 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-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).
|
||||||
@@ -7,6 +7,46 @@
|
|||||||
"owner": "dtourolle",
|
"owner": "dtourolle",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"versions": [
|
"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": "52faba139f29c6a5b0a418845328071a",
|
||||||
|
"timestamp": "2026-07-31T08:05:39Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"targetAbi": "10.9.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.3/jray_0.0.3.0.zip",
|
||||||
|
"checksum": "da5ded2ee720f7a7f7e15ac092871a91",
|
||||||
|
"timestamp": "2026-07-04T19:11:39Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "0d1cc8954a1b0557eb5ec6c368bce486",
|
||||||
|
"timestamp": "2026-07-04T19:10:48Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"changelog": "Release 0.0.2",
|
"changelog": "Release 0.0.2",
|
||||||
|
|||||||
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