first commit
🏗️ Build Plugin / build (push) Successful in 1m13s
🧪 Test Plugin / test (push) Successful in 20s
Latest Release / latest-release (push) Successful in 25s

This commit is contained in:
2026-06-12 18:16:47 +02:00
parent 7a9dbdafcc
commit a38122e993
39 changed files with 1522 additions and 295 deletions
@@ -0,0 +1,38 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Stores and retrieves truth files that were pushed to JRay directly
/// (e.g. by a remote extraction worker), independent of any sidecar file
/// on the media filesystem.
/// </summary>
public interface IManagedTruthStore
{
/// <summary>
/// Loads the managed truth file for the given item, if one was uploaded.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed truth file, or null if none has been uploaded for this item.</returns>
Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Saves (creates or replaces) the managed truth file for the given item.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="truth">The truth file contents to persist.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the file has been written.</returns>
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
/// <summary>
/// Deletes the managed truth file for the given item, if one exists.
/// </summary>
/// <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>
bool Delete(Guid itemId);
}
@@ -0,0 +1,27 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Loads and caches scene-actor-extraction "truth" files for library items.
/// </summary>
public interface ITruthDataService
{
/// <summary>
/// Gets the truth file for the given library item, if one exists.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Removes any cached truth file for the given item, so the next
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
void Invalidate(Guid itemId);
}
@@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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>
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
/// worker) under the plugin's configuration directory, keyed by item id.
/// </summary>
public sealed class ManagedTruthStore : IManagedTruthStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger<ManagedTruthStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ManagedTruthStore"/> class.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="logger">The logger.</param>
public ManagedTruthStore(IApplicationPaths applicationPaths, ILogger<ManagedTruthStore> logger)
{
_applicationPaths = applicationPaths;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> LoadAsync(Guid itemId, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
if (!File.Exists(path))
{
return null;
}
try
{
using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path);
return null;
}
}
/// <inheritdoc />
public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
Directory.CreateDirectory(directory);
var tempPath = path + ".tmp";
using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, truth, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Move(tempPath, path, overwrite: true);
}
/// <inheritdoc />
public bool Delete(Guid itemId)
{
var path = GetPath(itemId);
if (!File.Exists(path))
{
return false;
}
File.Delete(path);
return true;
}
private string GetPath(Guid itemId)
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");
}
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Loads scene-actor-extraction truth files from disk, alongside each media
/// item's source file, and caches the parsed result for a configurable
/// duration.
/// </summary>
public sealed class TruthDataService : ITruthDataService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILibraryManager _libraryManager;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ILogger<TruthDataService> _logger;
private readonly ConcurrentDictionary<Guid, CacheEntry> _cache = new();
/// <summary>
/// Initializes a new instance of the <see cref="TruthDataService"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="managedTruthStore">The managed truth store.</param>
/// <param name="logger">The logger.</param>
public TruthDataService(ILibraryManager libraryManager, IManagedTruthStore managedTruthStore, ILogger<TruthDataService> logger)
{
_libraryManager = libraryManager;
_managedTruthStore = managedTruthStore;
_logger = logger;
}
/// <inheritdoc />
public async Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken)
{
var cacheDuration = TimeSpan.FromMinutes(Math.Max(0, Plugin.Instance?.Configuration.CacheDurationMinutes ?? 0));
if (_cache.TryGetValue(itemId, out var cached) && DateTime.UtcNow - cached.LoadedAt < cacheDuration)
{
return cached.Truth;
}
var managed = await _managedTruthStore.LoadAsync(itemId, cancellationToken).ConfigureAwait(false);
if (managed is not null)
{
_cache[itemId] = new CacheEntry(managed, DateTime.UtcNow);
return managed;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null || string.IsNullOrEmpty(item.Path))
{
_logger.LogDebug("JRay: item {ItemId} not found or has no path", itemId);
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);
if (!File.Exists(truthPath))
{
_logger.LogDebug("JRay: no truth file at {TruthPath}", truthPath);
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
return null;
}
try
{
using var stream = File.OpenRead(truthPath);
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
_cache[itemId] = new CacheEntry(truth, DateTime.UtcNow);
return truth;
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath);
return null;
}
}
/// <inheritdoc />
public void Invalidate(Guid itemId)
{
_cache.TryRemove(itemId, out _);
}
private sealed record CacheEntry(TruthFile? Truth, DateTime LoadedAt);
}
@@ -0,0 +1,72 @@
using System;
using System.IO;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
/// that loads JRay's pause-overlay script. This follows the pattern used by
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
/// plugin hook for player-overlay UI.
/// </summary>
public static class WebClientPatchService
{
private const string Marker = "<!-- jray-overlay -->";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
private const string Injected = ScriptTag + Marker + "\n</body>";
/// <summary>
/// Ensures the web client's index.html either has or does not have the
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="enableOverlay">Whether the overlay script should be present.</param>
/// <param name="logger">The logger.</param>
public static void Apply(IApplicationPaths applicationPaths, bool enableOverlay, ILogger logger)
{
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
try
{
if (!File.Exists(indexPath))
{
logger.LogDebug("JRay: web client index.html not found at {Path}", indexPath);
return;
}
var html = File.ReadAllText(indexPath);
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
if (enableOverlay && !hasMarker)
{
var patched = ReplaceLast(html, "</body>", Injected);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
}
else if (!enableOverlay && hasMarker)
{
var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: removed pause-overlay script from {Path}", indexPath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "JRay: failed to patch web client index.html at {Path}", indexPath);
}
}
private static string ReplaceLast(string source, string find, string replace)
{
var index = source.LastIndexOf(find, StringComparison.Ordinal);
if (index < 0)
{
return source;
}
return source[..index] + replace + source[(index + find.Length)..];
}
}