Add SceneSearchProvider.cs

This commit is contained in:
Sleeps
2026-10-06 16:52:46 +01:00
parent 4164cce292
commit 45fa536b17
@@ -0,0 +1,171 @@
using System.Runtime.CompilerServices;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SceneSearch.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SceneSearch;
/// <summary>
/// External Jellyfin search provider backed by jellyfin-scene-search.
/// </summary>
public sealed class SceneSearchProvider : IExternalSearchProvider
{
private static readonly HashSet<BaseItemKind> SupportedKinds =
[
BaseItemKind.Movie,
BaseItemKind.Episode,
BaseItemKind.Video
];
private readonly SceneSearchClient _client;
private readonly ILogger<SceneSearchProvider> _logger;
public SceneSearchProvider(
SceneSearchClient client,
ILogger<SceneSearchProvider> logger)
{
_client = client;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Scene Search";
/// <inheritdoc />
public MetadataPluginType Type => MetadataPluginType.SearchProvider;
/// <inheritdoc />
public int Priority => 20;
/// <inheritdoc />
public bool CanSearch(SearchProviderQuery query)
{
if (!_client.IsConfigured || string.IsNullOrWhiteSpace(query.SearchTerm))
{
return false;
}
var config = Configuration;
if (config.RequirePrefix && !HasPrefix(query.SearchTerm, config.SearchPrefix))
{
return false;
}
if (query.IncludeItemTypes.Length > 0
&& !query.IncludeItemTypes.Any(SupportedKinds.Contains))
{
return false;
}
return true;
}
/// <inheritdoc />
public async IAsyncEnumerable<SearchResult> SearchAsync(
SearchProviderQuery query,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!CanSearch(query))
{
yield break;
}
var config = Configuration;
var searchTerm = StripPrefix(query.SearchTerm, config);
if (string.IsNullOrWhiteSpace(searchTerm))
{
yield break;
}
var limit = Math.Min(query.Limit ?? config.MaxResults, config.MaxResults);
var results = await _client.SearchAsync(
searchTerm,
Math.Max(limit, 1),
cancellationToken).ConfigureAwait(false);
foreach (var result in results)
{
if (!Guid.TryParse(result.JellyfinItemId, out var itemId) || itemId == Guid.Empty)
{
_logger.LogDebug(
"Ignoring Scene Search result with invalid Jellyfin item id {ItemId}",
result.JellyfinItemId);
continue;
}
if (!MatchesRequestedType(result, query))
{
continue;
}
var score = result.RankingScore ?? result.SiglipScore ?? 0.5;
yield return new SearchResult(itemId, (float)Math.Clamp(score, 0.0, 1.0));
}
}
/// <inheritdoc />
async Task<IReadOnlyList<SearchResult>> ISearchProvider.SearchAsync(
SearchProviderQuery query,
CancellationToken cancellationToken)
{
var results = new List<SearchResult>();
await foreach (var result in SearchAsync(query, cancellationToken).ConfigureAwait(false))
{
results.Add(result);
}
return results;
}
private static PluginConfiguration Configuration
=> Plugin.Instance?.Configuration ?? new PluginConfiguration();
private static bool HasPrefix(string term, string prefix)
=> !string.IsNullOrWhiteSpace(prefix)
&& term.TrimStart().StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
private static string StripPrefix(string term, PluginConfiguration config)
{
var trimmed = term.Trim();
if (!config.RequirePrefix || string.IsNullOrWhiteSpace(config.SearchPrefix))
{
return trimmed;
}
return trimmed.StartsWith(config.SearchPrefix, StringComparison.OrdinalIgnoreCase)
? trimmed[config.SearchPrefix.Length..].Trim()
: trimmed;
}
private static bool MatchesRequestedType(
SceneSearchItem result,
SearchProviderQuery query)
{
if (!Enum.TryParse<BaseItemKind>(
result.JellyfinItemType,
ignoreCase: true,
out var kind))
{
return query.IncludeItemTypes.Length == 0;
}
if (!SupportedKinds.Contains(kind))
{
return false;
}
if (query.IncludeItemTypes.Length > 0 && !query.IncludeItemTypes.Contains(kind))
{
return false;
}
if (query.IncludeItemTypes.Length == 0 && query.ExcludeItemTypes.Contains(kind))
{
return false;
}
return true;
}
}