diff --git a/Jellyfin.Plugin.JRay/Controllers/TasksController.cs b/Jellyfin.Plugin.JRay/Controllers/TasksController.cs new file mode 100644 index 0000000..76da3c0 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Controllers/TasksController.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.JRay.Models; +using Jellyfin.Plugin.JRay.Services.Interfaces; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Plugin.JRay.Controllers; + +/// +/// Lets a remote extraction worker discover which library items still need +/// to be processed. +/// +[ApiController] +[Route("Plugins/JRay/Tasks")] +[Authorize(Roles = "Administrator")] +public class TasksController : ControllerBase +{ + private const int DefaultLimit = 10; + private const int MaxLimit = 100; + + private readonly ILibraryManager _libraryManager; + private readonly ITruthDataService _truthDataService; + + /// + /// Initializes a new instance of the class. + /// + /// The Jellyfin library manager. + /// The truth data service. + public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService) + { + _libraryManager = libraryManager; + _truthDataService = truthDataService; + } + + /// + /// Gets a random sample of movies/episodes that have no truth data yet. + /// + /// The maximum number of items to return (default 10, max 100). + /// Up to items with no truth data, in random order. + [HttpGet("Pending")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetPending([FromQuery] int limit = DefaultLimit) + { + var effectiveLimit = Math.Clamp(limit, 1, MaxLimit); + + var items = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode }, + IsVirtualItem = false, + Recursive = true, + }); + + var pending = items + .Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path)) + .OrderBy(_ => Random.Shared.Next()) + .Take(effectiveLimit) + .Select(item => new PendingExtractionItem + { + ItemId = item.Id, + Path = item.Path, + Name = item.Name + }); + + return Ok(pending); + } +} diff --git a/Jellyfin.Plugin.JRay/Models/PendingExtractionItem.cs b/Jellyfin.Plugin.JRay/Models/PendingExtractionItem.cs new file mode 100644 index 0000000..6320180 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/PendingExtractionItem.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// A library item that has no truth data yet (neither a managed upload nor +/// a sidecar file), offered to an extraction worker as a candidate to process. +/// +public class PendingExtractionItem +{ + /// + /// Gets or sets the Jellyfin item id, used to push results back via + /// PUT /Plugins/JRay/Items/{itemId}/Truth. + /// + [JsonPropertyName("item_id")] + public Guid ItemId { get; set; } + + /// + /// Gets or sets the absolute path to the media file, as seen by Jellyfin. + /// + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// + /// Gets or sets the item's display name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs index 1bd303d..f5ca0a9 100644 --- a/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs +++ b/Jellyfin.Plugin.JRay/Services/Interfaces/IManagedTruthStore.cs @@ -35,4 +35,11 @@ public interface IManagedTruthStore /// The Jellyfin library item id. /// true if a file was deleted; false if none existed. bool Delete(Guid itemId); + + /// + /// Checks whether a managed truth file has been uploaded for the given item. + /// + /// The Jellyfin library item id. + /// true if a managed truth file exists. + bool Exists(Guid itemId); } diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs index fc27433..a7afbf6 100644 --- a/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs +++ b/Jellyfin.Plugin.JRay/Services/Interfaces/ITruthDataService.cs @@ -24,4 +24,13 @@ public interface ITruthDataService /// /// The Jellyfin library item id. void Invalidate(Guid itemId); + + /// + /// Cheaply checks whether truth data exists for an item (managed upload + /// or sidecar file), without loading or caching it. + /// + /// The Jellyfin library item id. + /// The item's media file path. + /// true if truth data exists for this item. + bool HasTruth(Guid itemId, string itemPath); } diff --git a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs index 3351580..cc099c5 100644 --- a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs +++ b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs @@ -83,6 +83,12 @@ public sealed class ManagedTruthStore : IManagedTruthStore return true; } + /// + public bool Exists(Guid itemId) + { + return File.Exists(GetPath(itemId)); + } + private string GetPath(Guid itemId) { return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json"); diff --git a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs index bfe40c5..5cc0b48 100644 --- a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs +++ b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs @@ -62,10 +62,7 @@ public sealed class TruthDataService : ITruthDataService return null; } - var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json"; - var truthPath = Path.Combine( - Path.GetDirectoryName(item.Path) ?? string.Empty, - Path.GetFileNameWithoutExtension(item.Path) + suffix); + var truthPath = GetSidecarPath(item.Path); if (!File.Exists(truthPath)) { @@ -95,5 +92,19 @@ public sealed class TruthDataService : ITruthDataService _cache.TryRemove(itemId, out _); } + /// + public bool HasTruth(Guid itemId, string itemPath) + { + return _managedTruthStore.Exists(itemId) || File.Exists(GetSidecarPath(itemPath)); + } + + private static string GetSidecarPath(string itemPath) + { + var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json"; + return Path.Combine( + Path.GetDirectoryName(itemPath) ?? string.Empty, + Path.GetFileNameWithoutExtension(itemPath) + suffix); + } + private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt); } diff --git a/README.md b/README.md index cedfae9..e9d84b6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ +# JRay + +JRay reads "truth" files produced offline by the scene-actor-extraction pipeline (face detection + recognition) and exposes an API to query which actors are visible on screen at a given timestamp in a movie, for building an actor-overlay (Jellyfin "X-Ray") style feature. + +## Quick Install + +Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories): + +``` +https://gitea.tourolle.paris/dtourolle/jRay/raw/branch/master/manifest.json +``` + +Then install "JRay" from the plugin catalog. + +--- + # So you want to make a Jellyfin plugin Awesome! This guide is for you. Jellyfin plugins are written using the dotnet standard framework. What that means is you can write them in any language that implements the CLI or the DLI and can compile to net8.0. The examples on this page are in C# because that is what most of Jellyfin is written in, but F#, Visual Basic, and IronPython should all be compatible once compiled. diff --git a/SPEC.md b/SPEC.md index d7c6294..e153a3f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -96,6 +96,71 @@ Requires an administrator API key. Serves the pause-overlay script that JRay injects into the web client's `index.html` (see below). Anonymous access. +## Client: pushing results from a remote extraction worker + +A worker that runs the extraction pipeline on a different machine than +Jellyfin (i.e. it cannot write a `Movie.jray.json` sidecar next to the media +file) can push results directly over HTTP. + +### 1. Authenticate + +Create an **Administrator** API key in Jellyfin (Dashboard → API Keys), and +send it on every request as either: + +``` +X-Emby-Token: +``` + +or: + +``` +Authorization: MediaBrowser Token="" +``` + +### 2. Resolve the Jellyfin item id + +The push endpoint is keyed by the Jellyfin item GUID, not by file path. To +find it for `Movie.mkv`: + +``` +GET /Items?Recursive=true&Fields=Path&IncludeItemTypes=Movie,Episode +``` + +(use `&ParentId=` to narrow the search if the library is large). +Each returned item DTO has `Id` (the GUID) and `Path`. Match `Path` against +the absolute path of the file you just processed — note this requires the +worker to see the file at the *same path* Jellyfin does (same mount/share); +translate paths first if the worker mounts the library elsewhere. + +This mapping is stable until the file is moved/re-scanned, so the worker +should cache `path -> itemId` and only re-resolve on a cache miss. + +### 3. Push the truth file + +``` +PUT /Plugins/JRay/Items/{itemId}/Truth +Content-Type: application/json + + +``` + +- `204 No Content` — stored. Takes effect immediately (any cached read for + this item is invalidated server-side). +- `400 Bad Request` — `schema_version` is not `1`. +- `401`/`403` — API key missing or not an administrator. + +The `PUT` is idempotent (replaces any existing managed truth for the item), +so the worker can safely retry on network errors. + +### 4. (Optional) Remove pushed data + +``` +DELETE /Plugins/JRay/Items/{itemId}/Truth +``` + +Always returns `204`. The item falls back to a sidecar `Movie.jray.json` (if +any) on the next read. + ## Web client pause overlay Since Jellyfin has no plugin hook for player UI, JRay injects