Added work remaining url for remote extraction client

This commit is contained in:
Duncan Tourolle 2026-06-12 19:03:31 +02:00
parent 4c637de442
commit f1cffa7dfa
8 changed files with 220 additions and 4 deletions

View File

@ -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;
/// <summary>
/// Lets a remote extraction worker discover which library items still need
/// to be processed.
/// </summary>
[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;
/// <summary>
/// Initializes a new instance of the <see cref="TasksController"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="truthDataService">The truth data service.</param>
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService)
{
_libraryManager = libraryManager;
_truthDataService = truthDataService;
}
/// <summary>
/// Gets a random sample of movies/episodes that have no truth data yet.
/// </summary>
/// <param name="limit">The maximum number of items to return (default 10, max 100).</param>
/// <returns>Up to <paramref name="limit"/> items with no truth data, in random order.</returns>
[HttpGet("Pending")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PendingExtractionItem>> 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);
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// 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.
/// </summary>
public class PendingExtractionItem
{
/// <summary>
/// Gets or sets the Jellyfin item id, used to push results back via
/// <c>PUT /Plugins/JRay/Items/{itemId}/Truth</c>.
/// </summary>
[JsonPropertyName("item_id")]
public Guid ItemId { get; set; }
/// <summary>
/// Gets or sets the absolute path to the media file, as seen by Jellyfin.
/// </summary>
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item's display name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
}

View File

@ -35,4 +35,11 @@ public interface IManagedTruthStore
/// <param name="itemId">The Jellyfin library item id.</param> /// <param name="itemId">The Jellyfin library item id.</param>
/// <returns><c>true</c> if a file was deleted; <c>false</c> if none existed.</returns> /// <returns><c>true</c> if a file was deleted; <c>false</c> if none existed.</returns>
bool Delete(Guid itemId); bool Delete(Guid itemId);
/// <summary>
/// Checks whether a managed truth file has been uploaded for the given item.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns><c>true</c> if a managed truth file exists.</returns>
bool Exists(Guid itemId);
} }

View File

@ -24,4 +24,13 @@ public interface ITruthDataService
/// </summary> /// </summary>
/// <param name="itemId">The Jellyfin library item id.</param> /// <param name="itemId">The Jellyfin library item id.</param>
void Invalidate(Guid itemId); void Invalidate(Guid itemId);
/// <summary>
/// Cheaply checks whether truth data exists for an item (managed upload
/// or sidecar file), without loading or caching it.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="itemPath">The item's media file path.</param>
/// <returns><c>true</c> if truth data exists for this item.</returns>
bool HasTruth(Guid itemId, string itemPath);
} }

View File

@ -83,6 +83,12 @@ public sealed class ManagedTruthStore : IManagedTruthStore
return true; return true;
} }
/// <inheritdoc />
public bool Exists(Guid itemId)
{
return File.Exists(GetPath(itemId));
}
private string GetPath(Guid itemId) private string GetPath(Guid itemId)
{ {
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json"); return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");

View File

@ -62,10 +62,7 @@ public sealed class TruthDataService : ITruthDataService
return null; return null;
} }
var suffix = Plugin.Instance?.Configuration.TruthFileSuffix ?? ".jray.json"; var truthPath = GetSidecarPath(item.Path);
var truthPath = Path.Combine(
Path.GetDirectoryName(item.Path) ?? string.Empty,
Path.GetFileNameWithoutExtension(item.Path) + suffix);
if (!File.Exists(truthPath)) if (!File.Exists(truthPath))
{ {
@ -95,5 +92,19 @@ public sealed class TruthDataService : ITruthDataService
_cache.TryRemove(itemId, out _); _cache.TryRemove(itemId, out _);
} }
/// <inheritdoc />
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); private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt);
} }

View File

@ -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 # 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. 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.

65
SPEC.md
View File

@ -96,6 +96,71 @@ Requires an administrator API key.
Serves the pause-overlay script that JRay injects into the web client's Serves the pause-overlay script that JRay injects into the web client's
`index.html` (see below). Anonymous access. `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: <api-key>
```
or:
```
Authorization: MediaBrowser Token="<api-key>"
```
### 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=<library-id>` 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
<truth file JSON, schema_version 1, as produced by result_sink_node>
```
- `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 ## Web client pause overlay
Since Jellyfin has no plugin hook for player UI, JRay injects Since Jellyfin has no plugin hook for player UI, JRay injects