mirror of
https://github.com/MrSleeps/jellyfin-scene-search.git
synced 2026-11-15 22:41:28 +00:00
Merge pull request #11 from MrSleeps/feature/multi-library-ingestion
Manage multi-library indexing from the Jellyfin plugin
This commit is contained in:
@@ -26,6 +26,11 @@ CAPTION_ROUTER_CONTEXT_SECONDS=6
|
||||
# Jellyfin ingestion
|
||||
JELLYFIN_URL=http://127.0.0.1:8096
|
||||
JELLYFIN_API_KEY=
|
||||
# Preferred: comma-separated list of libraries to index by default.
|
||||
JELLYFIN_LIBRARIES=
|
||||
# Optional libraries to leave out, including when --all-libraries is used.
|
||||
JELLYFIN_EXCLUDE_LIBRARIES=
|
||||
# Backward-compatible single-library setting. Used only when JELLYFIN_LIBRARIES is empty.
|
||||
JELLYFIN_LIBRARY=
|
||||
JELLYFIN_TRICKPLAY_WIDTH=320
|
||||
JELLYFIN_FALLBACK_INTERVAL_SECONDS=10
|
||||
|
||||
@@ -167,12 +167,16 @@ On the first run after enabling routing, existing captions are classified. A cap
|
||||
|
||||
Jellyfin can now be the source of truth for media identity, metadata and frame sampling.
|
||||
|
||||
Configure a server API key and a small test library:
|
||||
Configure the Jellyfin server connection used by the Python worker. Library selection is normally managed from the Jellyfin plugin UI:
|
||||
|
||||
```env
|
||||
JELLYFIN_URL=http://jellyfin-server:8096
|
||||
JELLYFIN_API_KEY=replace-me
|
||||
JELLYFIN_LIBRARY=Scene Search Test
|
||||
|
||||
# Optional CLI defaults only. The Jellyfin plugin stores its own selected library ids.
|
||||
JELLYFIN_LIBRARIES=
|
||||
JELLYFIN_EXCLUDE_LIBRARIES=
|
||||
JELLYFIN_LIBRARY=
|
||||
|
||||
JELLYFIN_TRICKPLAY_WIDTH=320
|
||||
JELLYFIN_FALLBACK_INTERVAL_SECONDS=10
|
||||
@@ -193,21 +197,53 @@ python -m scene_search.cli jellyfin-test
|
||||
python -m scene_search.cli jellyfin-libraries
|
||||
```
|
||||
|
||||
Inspect the test library before doing any GPU work:
|
||||
Inspect selected libraries before doing any GPU work:
|
||||
|
||||
```bash
|
||||
python -m scene_search.cli jellyfin-items --library "Scene Search Test" --limit 10
|
||||
python -m scene_search.cli jellyfin-items --library "Movies" --library "TV" --limit 10
|
||||
```
|
||||
|
||||
This prints each Jellyfin item ID, type, tags, path and available Trickplay information.
|
||||
|
||||
Index the library:
|
||||
Or inspect every visible Jellyfin library:
|
||||
|
||||
```bash
|
||||
python -m scene_search.cli jellyfin-index --library "Scene Search Test"
|
||||
python -m scene_search.cli jellyfin-items --all-libraries --limit 10
|
||||
```
|
||||
|
||||
The indexer prefers Jellyfin's existing Trickplay sheets. It downloads each sheet, splits the sheet into timestamped thumbnails using Jellyfin's Trickplay manifest, embeds those thumbnails with SigLIP and stores the Jellyfin item/media-source identity alongside the canonical vectors.
|
||||
This prints each Jellyfin item ID, type, tags, path and available Trickplay information. The item query itself is restricted to Movies, Episodes and generic Videos, so non-video libraries normally report zero selected items.
|
||||
|
||||
Index the libraries configured in `JELLYFIN_LIBRARIES`:
|
||||
|
||||
```bash
|
||||
python -m scene_search.cli jellyfin-index
|
||||
```
|
||||
|
||||
You can also select several libraries explicitly:
|
||||
|
||||
```bash
|
||||
python -m scene_search.cli jellyfin-index \
|
||||
--library "Movies" \
|
||||
--library "TV" \
|
||||
--library "Home Videos"
|
||||
```
|
||||
|
||||
Or index every library visible to the API key:
|
||||
|
||||
```bash
|
||||
python -m scene_search.cli jellyfin-index --all-libraries
|
||||
```
|
||||
|
||||
Exclude libraries either in `JELLYFIN_EXCLUDE_LIBRARIES` or per command:
|
||||
|
||||
```bash
|
||||
python -m scene_search.cli jellyfin-index \
|
||||
--all-libraries \
|
||||
--exclude-library "Music" \
|
||||
--exclude-library "Photos"
|
||||
```
|
||||
|
||||
`--limit` applies per selected library and is intended for inspection/test runs. Limited runs never prune missing items. On a complete library scan, the indexer reconciles that library against Jellyfin and removes canonical entries/thumbnails for items that no longer exist there.
|
||||
|
||||
The indexer processes selected libraries sequentially but keeps one SigLIP model instance loaded across the whole run. It prefers Jellyfin's existing Trickplay sheets, downloads each sheet, splits it into timestamped thumbnails using Jellyfin's Trickplay manifest, embeds those thumbnails with SigLIP and stores the Jellyfin item/media-source/library identity alongside the canonical vectors.
|
||||
|
||||
If an item has no Trickplay manifest, the indexer falls back to ffmpeg only when Jellyfin's reported media path is accessible from the indexing machine.
|
||||
|
||||
@@ -322,6 +358,14 @@ When `SCENE_API_KEY` is set, search and thumbnail endpoints accept either `Autho
|
||||
|
||||
The API intentionally runs with one worker. Multiple workers would each load their own copy of the embedding model into GPU memory.
|
||||
|
||||
The API also exposes authenticated Jellyfin-management endpoints used by the plugin:
|
||||
|
||||
- `GET /api/jellyfin/libraries` — list visible libraries and indexed counts.
|
||||
- `POST /api/jellyfin/index` — start one background indexing job for selected library ids.
|
||||
- `GET /api/jellyfin/index/status` — report live library/item/frame/sync progress.
|
||||
|
||||
Only one plugin-triggered indexing job can run at a time. Search requests return a temporary 503 while that job owns the shared embedding model.
|
||||
|
||||
## Jellyfin 12 search plugin
|
||||
|
||||
The repository now includes a thin Jellyfin 12 plugin under `jellyfin-plugin/`. Jellyfin 12's external search-provider API accepts real Jellyfin item IDs with relevance scores, while Jellyfin core applies the requesting user's library visibility and access filtering afterwards. The plugin therefore sends the search text to the local Python API and returns the matching Jellyfin item IDs; it does not run AI models inside Jellyfin.
|
||||
@@ -350,6 +394,13 @@ Open **Dashboard → Plugins → Scene Search** and configure:
|
||||
- API key: the same value as `SCENE_API_KEY`.
|
||||
- Require a search prefix: enabled by default.
|
||||
- Prefix: `scene:`.
|
||||
- Indexed libraries: tick the Jellyfin libraries that Scene Search should manage.
|
||||
|
||||
The plugin asks the Python service for the libraries visible to the configured Jellyfin API key and shows their current indexed item/frame counts. The selected library ids are saved in Jellyfin's plugin configuration, so normal operation does not require editing `.env`.
|
||||
|
||||
Use **Index selected libraries** to start a backend indexing job. The plugin shows the current library, item and frame progress and polls until the job completes. When ingestion finishes, the backend automatically rebuilds Meilisearch, so newly indexed vectors are searchable without a separate `sync-meili` command.
|
||||
|
||||
Plugin-triggered indexing reuses the API process's existing SigLIP instance and blocks semantic searches while the indexing job owns the model, avoiding a second GPU copy on the same machine.
|
||||
|
||||
The prefix is intentional for the first rollout. Jellyfin 12 uses non-empty external-provider results instead of its built-in SQL search results. While only a test library is indexed, making Scene Search answer every query could hide ordinary Jellyfin results that have not been indexed yet.
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MediaBrowser.Common.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.SceneSearch.Api;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[Route("Plugins/SceneSearch")]
|
||||
[Produces("application/json")]
|
||||
public sealed class SceneSearchController : ControllerBase
|
||||
{
|
||||
private readonly SceneSearchClient _client;
|
||||
|
||||
public SceneSearchController(SceneSearchClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[HttpGet("Libraries")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SceneSearchLibraryListResponse>> Libraries()
|
||||
{
|
||||
var result = await _client.GetLibrariesAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return result is null
|
||||
? StatusCode(StatusCodes.Status503ServiceUnavailable, "Scene Search API unavailable.")
|
||||
: Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("Index")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<SceneSearchIndexStatus>> Index([FromBody] SceneSearchIndexRequest request)
|
||||
{
|
||||
if (request.LibraryIds.Count == 0)
|
||||
{
|
||||
return BadRequest("Select at least one library.");
|
||||
}
|
||||
|
||||
var result = await _client.StartIndexAsync(request, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return result is null
|
||||
? StatusCode(StatusCodes.Status503ServiceUnavailable, "Scene Search API unavailable.")
|
||||
: Accepted(result);
|
||||
}
|
||||
|
||||
[HttpGet("IndexStatus")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SceneSearchIndexStatus>> IndexStatus()
|
||||
{
|
||||
var result = await _client.GetIndexStatusAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return result is null
|
||||
? StatusCode(StatusCodes.Status503ServiceUnavailable, "Scene Search API unavailable.")
|
||||
: Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
SearchPrefix = "scene:";
|
||||
MaxResults = 20;
|
||||
TimeoutSeconds = 60;
|
||||
SelectedLibraryIds = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -52,4 +53,9 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// Gets or sets the HTTP timeout in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin library ids managed by Scene Search.
|
||||
/// </summary>
|
||||
public string[] SelectedLibraryIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,16 +23,13 @@
|
||||
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="password" id="apiKey" label="API Key" />
|
||||
<div class="fieldDescription">Must match SCENE_API_KEY in jellyfin-scene-search. Leave empty only if the API has no key configured.</div>
|
||||
<div class="fieldDescription">Must match SCENE_API_KEY in jellyfin-scene-search.</div>
|
||||
</div>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input is="emby-checkbox" type="checkbox" id="requirePrefix" />
|
||||
<span>Require a search prefix</span>
|
||||
</label>
|
||||
<div class="fieldDescription" style="margin-left: 3em; margin-top: -1em; margin-bottom: 1em;">
|
||||
Recommended while only part of the Jellyfin library is indexed. When enabled, ordinary searches keep using Jellyfin's built-in search.
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="text" id="searchPrefix" label="Search Prefix" />
|
||||
@@ -45,9 +42,26 @@
|
||||
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="timeoutSeconds" min="5" max="300" step="1" label="API Timeout (seconds)" />
|
||||
<div class="fieldDescription">The first semantic search may take longer while SigLIP is loaded.</div>
|
||||
</div>
|
||||
|
||||
<h2>Indexed Libraries</h2>
|
||||
<div class="fieldDescription" style="margin-bottom:1em;">
|
||||
Choose which Jellyfin libraries Scene Search should index. Selection is stored in this plugin, not in the Python .env file.
|
||||
</div>
|
||||
<div id="libraryList">Loading libraries…</div>
|
||||
|
||||
<div style="margin-top:1em; margin-bottom:1em;">
|
||||
<button is="emby-button" type="button" id="indexNow" class="raised">
|
||||
<span>Index selected libraries</span>
|
||||
</button>
|
||||
<label class="checkboxContainer" style="margin-top:1em;">
|
||||
<input is="emby-checkbox" type="checkbox" id="forceIndex" />
|
||||
<span>Force rebuild already-indexed items</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="indexStatus" class="fieldDescription" style="margin-bottom:1.5em;"></div>
|
||||
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
@@ -59,10 +73,112 @@
|
||||
(function () {
|
||||
var pluginId = "2d8d1bb5-3f0c-4d5a-9d59-934463c06d35";
|
||||
var page = document.querySelector('#sceneSearchConfigPage');
|
||||
var currentConfig = null;
|
||||
var statusTimer = null;
|
||||
|
||||
function selectedLibraryIds() {
|
||||
return Array.prototype.slice.call(
|
||||
page.querySelectorAll('.sceneLibrary:checked')
|
||||
).map(function (el) { return el.value; });
|
||||
}
|
||||
|
||||
function renderLibraries(data) {
|
||||
var container = page.querySelector('#libraryList');
|
||||
var selected = new Set((currentConfig && currentConfig.SelectedLibraryIds) || []);
|
||||
var libraries = (data && data.libraries) || [];
|
||||
|
||||
if (!libraries.length) {
|
||||
container.textContent = 'No Jellyfin video libraries were returned by the Scene Search API.';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = libraries.map(function (library) {
|
||||
var checked = selected.has(library.id) ? ' checked' : '';
|
||||
var detail = library.indexed_items + ' indexed item' +
|
||||
(library.indexed_items === 1 ? '' : 's') + ', ' +
|
||||
library.indexed_frames + ' frames';
|
||||
return '<label class="checkboxContainer">' +
|
||||
'<input type="checkbox" class="sceneLibrary" value="' +
|
||||
library.id.replace(/"/g, '"') + '"' + checked + ' />' +
|
||||
'<span>' + library.name.replace(/</g, '<').replace(/>/g, '>') + '</span>' +
|
||||
'<div class="fieldDescription" style="margin-left:3em;">' + detail + '</div>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
var container = page.querySelector('#indexStatus');
|
||||
if (!status) {
|
||||
container.textContent = 'Index status unavailable.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.running) {
|
||||
var parts = ['Indexing'];
|
||||
if (status.library) {
|
||||
parts.push(status.library);
|
||||
}
|
||||
if (status.item) {
|
||||
parts.push(status.item);
|
||||
}
|
||||
if (status.item_number && status.item_total) {
|
||||
parts.push(status.item_number + '/' + status.item_total);
|
||||
}
|
||||
if (status.frames_total) {
|
||||
parts.push((status.frames_processed || 0) + '/' + status.frames_total + ' frames');
|
||||
}
|
||||
if (status.stage === 'sync' || status.stage === 'syncing') {
|
||||
parts = ['Rebuilding search index'];
|
||||
if (status.documents_processed) {
|
||||
parts.push(status.documents_processed + ' documents');
|
||||
}
|
||||
}
|
||||
container.textContent = parts.join(' — ');
|
||||
} else if (status.stage === 'failed') {
|
||||
container.textContent = 'Index failed: ' + (status.error || 'unknown error');
|
||||
} else if (status.stage === 'complete') {
|
||||
container.textContent =
|
||||
'Index complete — ' + (status.indexed || 0) + ' indexed, ' +
|
||||
(status.skipped || 0) + ' unchanged, ' +
|
||||
(status.failed || 0) + ' failed, ' +
|
||||
(status.pruned || 0) + ' removed.';
|
||||
} else {
|
||||
container.textContent = 'Index idle.';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
return ApiClient.getJSON(ApiClient.getUrl('Plugins/SceneSearch/IndexStatus'))
|
||||
.then(function (status) {
|
||||
renderStatus(status);
|
||||
if (status && status.running) {
|
||||
if (!statusTimer) {
|
||||
statusTimer = setInterval(refreshStatus, 2000);
|
||||
}
|
||||
} else if (statusTimer) {
|
||||
clearInterval(statusTimer);
|
||||
statusTimer = null;
|
||||
loadLibraries();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
renderStatus(null);
|
||||
});
|
||||
}
|
||||
|
||||
function loadLibraries() {
|
||||
return ApiClient.getJSON(ApiClient.getUrl('Plugins/SceneSearch/Libraries'))
|
||||
.then(renderLibraries)
|
||||
.catch(function () {
|
||||
page.querySelector('#libraryList').textContent =
|
||||
'Could not load libraries. Check the Scene Search API URL/key and that the Python service is running.';
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
currentConfig = config;
|
||||
page.querySelector('#enabled').checked = config.Enabled;
|
||||
page.querySelector('#apiUrl').value = config.ApiUrl || 'http://127.0.0.1:8097';
|
||||
page.querySelector('#apiKey').value = config.ApiKey || '';
|
||||
@@ -70,10 +186,58 @@
|
||||
page.querySelector('#searchPrefix').value = config.SearchPrefix || 'scene:';
|
||||
page.querySelector('#maxResults').value = config.MaxResults || 20;
|
||||
page.querySelector('#timeoutSeconds').value = config.TimeoutSeconds || 60;
|
||||
|
||||
return Promise.all([loadLibraries(), refreshStatus()]);
|
||||
}).finally(function () {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
page.addEventListener('pagehide', function () {
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
page.querySelector('#indexNow').addEventListener('click', function () {
|
||||
var ids = selectedLibraryIds();
|
||||
if (!ids.length) {
|
||||
page.querySelector('#indexStatus').textContent = 'Select at least one library first.';
|
||||
return;
|
||||
}
|
||||
|
||||
page.querySelector('#indexStatus').textContent = 'Saving settings and starting index…';
|
||||
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
config.Enabled = page.querySelector('#enabled').checked;
|
||||
config.ApiUrl = page.querySelector('#apiUrl').value.trim();
|
||||
config.ApiKey = page.querySelector('#apiKey').value;
|
||||
config.RequirePrefix = page.querySelector('#requirePrefix').checked;
|
||||
config.SearchPrefix = page.querySelector('#searchPrefix').value.trim();
|
||||
config.MaxResults = parseInt(page.querySelector('#maxResults').value, 10) || 20;
|
||||
config.TimeoutSeconds = parseInt(page.querySelector('#timeoutSeconds').value, 10) || 60;
|
||||
config.SelectedLibraryIds = ids;
|
||||
currentConfig = config;
|
||||
return ApiClient.updatePluginConfiguration(pluginId, config);
|
||||
}).then(function () {
|
||||
return ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl('Plugins/SceneSearch/Index'),
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
library_ids: ids,
|
||||
force: page.querySelector('#forceIndex').checked
|
||||
})
|
||||
});
|
||||
}).then(function () {
|
||||
refreshStatus();
|
||||
}).catch(function () {
|
||||
page.querySelector('#indexStatus').textContent =
|
||||
'Could not start indexing. Check Jellyfin logs and Scene Search API status.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('.sceneSearchConfigForm').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
@@ -86,6 +250,7 @@
|
||||
config.SearchPrefix = page.querySelector('#searchPrefix').value.trim();
|
||||
config.MaxResults = parseInt(page.querySelector('#maxResults').value, 10) || 20;
|
||||
config.TimeoutSeconds = parseInt(page.querySelector('#timeoutSeconds').value, 10) || 60;
|
||||
config.SelectedLibraryIds = selectedLibraryIds();
|
||||
|
||||
return ApiClient.updatePluginConfiguration(pluginId, config);
|
||||
}).then(function (result) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SceneSearch;
|
||||
|
||||
public sealed class SceneSearchLibraryListResponse
|
||||
{
|
||||
[JsonPropertyName("libraries")]
|
||||
public List<SceneSearchLibrary> Libraries { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SceneSearchLibrary
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("indexed_items")]
|
||||
public int IndexedItems { get; set; }
|
||||
|
||||
[JsonPropertyName("indexed_frames")]
|
||||
public int IndexedFrames { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneSearchIndexRequest
|
||||
{
|
||||
[JsonPropertyName("library_ids")]
|
||||
public List<string> LibraryIds { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneSearchIndexStatus
|
||||
{
|
||||
[JsonPropertyName("running")]
|
||||
public bool Running { get; set; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public string Stage { get; set; } = "idle";
|
||||
|
||||
[JsonPropertyName("started_at")]
|
||||
public string? StartedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("finished_at")]
|
||||
public string? FinishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("library")]
|
||||
public string? Library { get; set; }
|
||||
|
||||
[JsonPropertyName("item")]
|
||||
public string? Item { get; set; }
|
||||
|
||||
[JsonPropertyName("library_number")]
|
||||
public int? LibraryNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("library_total")]
|
||||
public int? LibraryTotal { get; set; }
|
||||
|
||||
[JsonPropertyName("item_number")]
|
||||
public int? ItemNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("item_total")]
|
||||
public int? ItemTotal { get; set; }
|
||||
|
||||
[JsonPropertyName("frames_processed")]
|
||||
public int? FramesProcessed { get; set; }
|
||||
|
||||
[JsonPropertyName("frames_total")]
|
||||
public int? FramesTotal { get; set; }
|
||||
|
||||
[JsonPropertyName("documents_processed")]
|
||||
public int? DocumentsProcessed { get; set; }
|
||||
|
||||
[JsonPropertyName("indexed")]
|
||||
public int? Indexed { get; set; }
|
||||
|
||||
[JsonPropertyName("skipped")]
|
||||
public int? Skipped { get; set; }
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
public int? Failed { get; set; }
|
||||
|
||||
[JsonPropertyName("pruned")]
|
||||
public int? Pruned { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.SceneSearch.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -19,9 +21,6 @@ public sealed class SceneSearchClient : IDisposable
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether enough configuration exists to search.
|
||||
/// </summary>
|
||||
public bool IsConfigured
|
||||
{
|
||||
get
|
||||
@@ -35,9 +34,6 @@ public sealed class SceneSearchClient : IDisposable
|
||||
private static PluginConfiguration Configuration
|
||||
=> Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Searches the local scene-search API.
|
||||
/// </summary>
|
||||
internal async Task<IReadOnlyList<SceneSearchItem>> SearchAsync(
|
||||
string searchTerm,
|
||||
int limit,
|
||||
@@ -50,8 +46,7 @@ public sealed class SceneSearchClient : IDisposable
|
||||
}
|
||||
|
||||
var safeLimit = Math.Clamp(limit, 1, Math.Clamp(config.MaxResults, 1, 50));
|
||||
var requestUri = string.Concat(
|
||||
config.ApiUrl.TrimEnd('/'),
|
||||
var path = string.Concat(
|
||||
"/api/search?q=",
|
||||
Uri.EscapeDataString(searchTerm),
|
||||
"&scene_limit=",
|
||||
@@ -59,7 +54,58 @@ public sealed class SceneSearchClient : IDisposable
|
||||
"&item_limit=",
|
||||
safeLimit.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
var payload = await SendAsync<SceneSearchResponse>(
|
||||
HttpMethod.Get,
|
||||
path,
|
||||
content: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return payload?.Items ?? [];
|
||||
}
|
||||
|
||||
public Task<SceneSearchLibraryListResponse?> GetLibrariesAsync(
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<SceneSearchLibraryListResponse>(
|
||||
HttpMethod.Get,
|
||||
"/api/jellyfin/libraries",
|
||||
content: null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<SceneSearchIndexStatus?> StartIndexAsync(
|
||||
SceneSearchIndexRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<SceneSearchIndexStatus>(
|
||||
HttpMethod.Post,
|
||||
"/api/jellyfin/index",
|
||||
JsonContent.Create(request),
|
||||
cancellationToken);
|
||||
|
||||
public Task<SceneSearchIndexStatus?> GetIndexStatusAsync(
|
||||
CancellationToken cancellationToken)
|
||||
=> SendAsync<SceneSearchIndexStatus>(
|
||||
HttpMethod.Get,
|
||||
"/api/jellyfin/index/status",
|
||||
content: null,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<T?> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
HttpContent? content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var config = Configuration;
|
||||
if (string.IsNullOrWhiteSpace(config.ApiUrl))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
method,
|
||||
config.ApiUrl.TrimEnd('/') + path)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ApiKey))
|
||||
@@ -79,30 +125,26 @@ public sealed class SceneSearchClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Scene Search API returned HTTP {StatusCode} for '{SearchTerm}'",
|
||||
(int)response.StatusCode,
|
||||
searchTerm);
|
||||
return [];
|
||||
var body = await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false);
|
||||
throw new HttpRequestException(
|
||||
$"Scene Search API returned HTTP {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SceneSearchResponse>(
|
||||
return await response.Content.ReadFromJsonAsync<T>(
|
||||
cancellationToken: timeout.Token).ConfigureAwait(false);
|
||||
|
||||
return payload?.Items ?? [];
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Scene Search API timed out after {TimeoutSeconds}s for '{SearchTerm}'",
|
||||
"Scene Search API timed out after {TimeoutSeconds}s for {Path}",
|
||||
config.TimeoutSeconds,
|
||||
searchTerm);
|
||||
return [];
|
||||
path);
|
||||
return default;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Scene Search API request failed for '{SearchTerm}'", searchTerm);
|
||||
return [];
|
||||
_logger.LogWarning(ex, "Scene Search API request failed for {Path}", path);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .config import settings
|
||||
from .database import connect, init_db
|
||||
from .index_jobs import IndexJobManager
|
||||
from .search_service import SearchService
|
||||
|
||||
app = FastAPI(
|
||||
@@ -16,6 +18,12 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
_service: SearchService | None = None
|
||||
_index_jobs: IndexJobManager | None = None
|
||||
|
||||
|
||||
class IndexRequest(BaseModel):
|
||||
library_ids: list[str] = Field(min_length=1)
|
||||
force: bool = False
|
||||
|
||||
|
||||
def get_search_service() -> SearchService:
|
||||
@@ -33,6 +41,13 @@ def get_search_service() -> SearchService:
|
||||
return _service
|
||||
|
||||
|
||||
def get_index_jobs() -> IndexJobManager:
|
||||
global _index_jobs
|
||||
if _index_jobs is None:
|
||||
_index_jobs = IndexJobManager(get_search_service())
|
||||
return _index_jobs
|
||||
|
||||
|
||||
def require_api_key(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_scene_search_key: str | None = Header(default=None),
|
||||
@@ -60,6 +75,12 @@ def search(
|
||||
scene_limit: int = Query(default=50, ge=1, le=100),
|
||||
item_limit: int = Query(default=20, ge=1, le=50),
|
||||
) -> dict:
|
||||
if get_index_jobs().running:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Jellyfin indexing is currently using the embedding model",
|
||||
)
|
||||
|
||||
try:
|
||||
return get_search_service().search(
|
||||
q,
|
||||
@@ -73,6 +94,44 @@ def search(
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/jellyfin/libraries",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def jellyfin_libraries() -> dict:
|
||||
try:
|
||||
return {"libraries": get_index_jobs().libraries()}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/jellyfin/index",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
status_code=202,
|
||||
)
|
||||
def jellyfin_index(request: IndexRequest) -> dict:
|
||||
try:
|
||||
return get_index_jobs().start(
|
||||
request.library_ids,
|
||||
force=request.force,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
message = str(exc)
|
||||
status = 409 if "already running" in message else 400
|
||||
raise HTTPException(status_code=status, detail=message) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/jellyfin/index/status",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def jellyfin_index_status() -> dict:
|
||||
return get_index_jobs().status()
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/frames/{frame_id}/thumbnail",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
|
||||
+53
-72
@@ -30,6 +30,7 @@ from .maintenance import list_media_by_source, remove_media_by_source
|
||||
from .meili import Meili
|
||||
from .routing import ExplicitContentRouter, apply_context_routing
|
||||
from .search_service import SearchService
|
||||
from .sync import rebuild_meili
|
||||
|
||||
|
||||
def format_ts(ms: int) -> str:
|
||||
@@ -141,74 +142,18 @@ def cmd_index_file(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_sync_meili(_: argparse.Namespace) -> None:
|
||||
init_db(settings.db_path)
|
||||
meili = Meili(settings.meili_url, settings.meili_master_key, settings.meili_index)
|
||||
def report(update: dict) -> None:
|
||||
if update.get("stage") == "sync":
|
||||
print(
|
||||
f"Queued {int(update.get('documents_processed') or 0)} "
|
||||
"documents into Meilisearch"
|
||||
)
|
||||
|
||||
with connect(settings.db_path) as con:
|
||||
dims = [r[0] for r in con.execute("SELECT DISTINCT embedding_dim FROM media")]
|
||||
if not dims:
|
||||
raise SystemExit("Nothing indexed yet")
|
||||
if len(dims) != 1:
|
||||
raise SystemExit(f"Multiple embedding dimensions found: {dims}. Use separate indexes per model.")
|
||||
dim = int(dims[0])
|
||||
meili.configure_user_vectors(dim)
|
||||
meili.clear_documents()
|
||||
|
||||
cursor = con.execute(
|
||||
"""
|
||||
SELECT f.id, f.media_id, f.timestamp_ms, f.thumb_path, f.embedding_f16,
|
||||
f.caption, f.caption_model, f.captioned_at,
|
||||
f.caption_route, f.caption_route_reason, f.route_score,
|
||||
f.route_model, f.route_policy, f.routed_at,
|
||||
m.title, m.source_path, m.embedding_dim, m.model_id,
|
||||
m.source_kind, m.jellyfin_item_id, m.jellyfin_media_source_id,
|
||||
m.jellyfin_library_id, m.jellyfin_item_type, m.jellyfin_tags,
|
||||
m.trickplay_width
|
||||
FROM frames f
|
||||
JOIN media m ON m.id = f.media_id
|
||||
ORDER BY f.id
|
||||
"""
|
||||
)
|
||||
|
||||
total = 0
|
||||
while True:
|
||||
rows = cursor.fetchmany(100)
|
||||
if not rows:
|
||||
break
|
||||
docs = []
|
||||
for row in rows:
|
||||
vec = unpack_vector(row["embedding_f16"], row["embedding_dim"])
|
||||
docs.append({
|
||||
"id": row["id"],
|
||||
"media_id": row["media_id"],
|
||||
"title": row["title"],
|
||||
"source_path": row["source_path"],
|
||||
"timestamp_ms": row["timestamp_ms"],
|
||||
"thumb_path": row["thumb_path"],
|
||||
"model_id": row["model_id"],
|
||||
"caption": row["caption"],
|
||||
"caption_model": row["caption_model"],
|
||||
"captioned_at": row["captioned_at"],
|
||||
"caption_route": row["caption_route"],
|
||||
"caption_route_reason": row["caption_route_reason"],
|
||||
"route_score": row["route_score"],
|
||||
"route_model": row["route_model"],
|
||||
"route_policy": row["route_policy"],
|
||||
"routed_at": row["routed_at"],
|
||||
"source_kind": row["source_kind"],
|
||||
"jellyfin_item_id": row["jellyfin_item_id"],
|
||||
"jellyfin_media_source_id": row["jellyfin_media_source_id"],
|
||||
"jellyfin_library_id": row["jellyfin_library_id"],
|
||||
"jellyfin_item_type": row["jellyfin_item_type"],
|
||||
"jellyfin_tags": tags_from_json(row["jellyfin_tags"]),
|
||||
"trickplay_width": row["trickplay_width"],
|
||||
"_vectors": {"siglip2": vec.tolist()},
|
||||
})
|
||||
meili.add_documents(docs)
|
||||
total += len(docs)
|
||||
print(f"Queued {total} documents into Meilisearch")
|
||||
|
||||
print("Meilisearch rebuild complete (tasks may continue briefly inside Meili).")
|
||||
total = rebuild_meili(progress=report)
|
||||
if total:
|
||||
print("Meilisearch rebuild complete (tasks may continue briefly inside Meili).")
|
||||
else:
|
||||
print("Meilisearch cleared; no canonical media remain.")
|
||||
|
||||
|
||||
|
||||
@@ -724,13 +669,49 @@ def make_parser() -> argparse.ArgumentParser:
|
||||
jf_libs.set_defaults(func=cmd_jellyfin_libraries)
|
||||
|
||||
jf_items = sub.add_parser("jellyfin-items", help="Inspect video items, tags and Trickplay")
|
||||
jf_items.add_argument("--library", help="Jellyfin library name (or use JELLYFIN_LIBRARY)")
|
||||
jf_items.add_argument("--limit", type=int, help="Maximum number of items to inspect")
|
||||
jf_items.add_argument(
|
||||
"--library",
|
||||
action="append",
|
||||
help="Jellyfin library name; repeat to inspect multiple libraries",
|
||||
)
|
||||
jf_items.add_argument(
|
||||
"--all-libraries",
|
||||
action="store_true",
|
||||
help="Inspect every Jellyfin library visible to the API key",
|
||||
)
|
||||
jf_items.add_argument(
|
||||
"--exclude-library",
|
||||
action="append",
|
||||
help="Library name to exclude; repeat as needed",
|
||||
)
|
||||
jf_items.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
help="Maximum number of items to inspect per selected library",
|
||||
)
|
||||
jf_items.set_defaults(func=cmd_jellyfin_items)
|
||||
|
||||
jf_index = sub.add_parser("jellyfin-index", help="Index a Jellyfin library via Trickplay")
|
||||
jf_index.add_argument("--library", help="Jellyfin library name (or use JELLYFIN_LIBRARY)")
|
||||
jf_index.add_argument("--limit", type=int, help="Maximum number of items to index")
|
||||
jf_index = sub.add_parser("jellyfin-index", help="Index one or more Jellyfin libraries via Trickplay")
|
||||
jf_index.add_argument(
|
||||
"--library",
|
||||
action="append",
|
||||
help="Jellyfin library name; repeat to index multiple libraries",
|
||||
)
|
||||
jf_index.add_argument(
|
||||
"--all-libraries",
|
||||
action="store_true",
|
||||
help="Index every Jellyfin library visible to the API key",
|
||||
)
|
||||
jf_index.add_argument(
|
||||
"--exclude-library",
|
||||
action="append",
|
||||
help="Library name to exclude; repeat as needed",
|
||||
)
|
||||
jf_index.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
help="Maximum number of items to index per selected library; disables pruning",
|
||||
)
|
||||
jf_index.add_argument("--force", action="store_true", help="Rebuild matching Jellyfin items")
|
||||
jf_index.set_defaults(func=cmd_jellyfin_index)
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ def _csv_env(name: str, default: str = "") -> tuple[str, ...]:
|
||||
)
|
||||
|
||||
|
||||
def _list_env(name: str, default: str = "") -> tuple[str, ...]:
|
||||
value = os.getenv(name, default)
|
||||
return tuple(
|
||||
part.strip()
|
||||
for part in value.split(",")
|
||||
if part.strip()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
meili_url: str = os.getenv("MEILI_URL", "http://127.0.0.1:7700").rstrip("/")
|
||||
@@ -75,6 +84,10 @@ class Settings:
|
||||
jellyfin_url: str = os.getenv("JELLYFIN_URL", "").rstrip("/")
|
||||
jellyfin_api_key: str = os.getenv("JELLYFIN_API_KEY", "")
|
||||
jellyfin_library: str = os.getenv("JELLYFIN_LIBRARY", "")
|
||||
jellyfin_libraries: tuple[str, ...] = _list_env("JELLYFIN_LIBRARIES")
|
||||
jellyfin_exclude_libraries: tuple[str, ...] = _list_env(
|
||||
"JELLYFIN_EXCLUDE_LIBRARIES"
|
||||
)
|
||||
jellyfin_trickplay_width: int = int(os.getenv("JELLYFIN_TRICKPLAY_WIDTH", "320"))
|
||||
jellyfin_fallback_interval_seconds: float = float(
|
||||
os.getenv("JELLYFIN_FALLBACK_INTERVAL_SECONDS", "10")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .database import connect, init_db
|
||||
from .jellyfin_index import (
|
||||
build_jellyfin_client,
|
||||
index_libraries,
|
||||
resolve_library_ids,
|
||||
)
|
||||
from .search_service import SearchService
|
||||
from .sync import rebuild_meili
|
||||
|
||||
|
||||
class IndexJobManager:
|
||||
"""Runs one plugin-triggered Jellyfin indexing job at a time."""
|
||||
|
||||
def __init__(self, search_service: SearchService) -> None:
|
||||
self.search_service = search_service
|
||||
self._state_lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._state: dict[str, Any] = {
|
||||
"running": False,
|
||||
"stage": "idle",
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
with self._state_lock:
|
||||
return bool(self._state.get("running"))
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._state_lock:
|
||||
return deepcopy(self._state)
|
||||
|
||||
def libraries(self) -> list[dict[str, Any]]:
|
||||
client = build_jellyfin_client()
|
||||
libraries = client.media_folders()
|
||||
init_db(self.search_service.db_path)
|
||||
|
||||
counts: dict[str, tuple[int, int]] = {}
|
||||
with connect(self.search_service.db_path) as con:
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT m.jellyfin_library_id,
|
||||
COUNT(DISTINCT m.id) AS indexed_items,
|
||||
COUNT(f.id) AS indexed_frames
|
||||
FROM media m
|
||||
LEFT JOIN frames f ON f.media_id = m.id
|
||||
WHERE m.source_kind = 'jellyfin'
|
||||
AND m.jellyfin_library_id IS NOT NULL
|
||||
GROUP BY m.jellyfin_library_id
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
counts[str(row["jellyfin_library_id"])] = (
|
||||
int(row["indexed_items"]),
|
||||
int(row["indexed_frames"]),
|
||||
)
|
||||
|
||||
result = []
|
||||
for library in sorted(
|
||||
libraries,
|
||||
key=lambda item: str(item.get("Name") or "").casefold(),
|
||||
):
|
||||
library_id = str(library.get("Id") or "")
|
||||
indexed_items, indexed_frames = counts.get(library_id, (0, 0))
|
||||
result.append(
|
||||
{
|
||||
"id": library_id,
|
||||
"name": str(library.get("Name") or library_id),
|
||||
"indexed_items": indexed_items,
|
||||
"indexed_frames": indexed_frames,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def start(self, library_ids: list[str], *, force: bool = False) -> dict[str, Any]:
|
||||
with self._state_lock:
|
||||
if self._state.get("running"):
|
||||
raise RuntimeError("A Jellyfin indexing job is already running.")
|
||||
|
||||
client = build_jellyfin_client()
|
||||
libraries = resolve_library_ids(client, library_ids)
|
||||
|
||||
with self._state_lock:
|
||||
self._state = {
|
||||
"running": True,
|
||||
"stage": "starting",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
"library_total": len(libraries),
|
||||
"library_ids": [str(item.get("Id") or "") for item in libraries],
|
||||
}
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
args=(libraries, force),
|
||||
name="scene-search-index",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
return self.status()
|
||||
|
||||
def _progress(self, update: dict) -> None:
|
||||
with self._state_lock:
|
||||
self._state.update(update)
|
||||
|
||||
def _run(self, libraries: list[dict], force: bool) -> None:
|
||||
try:
|
||||
# Search and indexing share one model instance and never use it concurrently.
|
||||
with self.search_service.model_lock:
|
||||
embedder = self.search_service.get_embedder()
|
||||
_, summary = index_libraries(
|
||||
build_jellyfin_client(),
|
||||
libraries,
|
||||
embedder=embedder,
|
||||
force=force,
|
||||
progress=self._progress,
|
||||
)
|
||||
self._progress({"stage": "syncing", **summary})
|
||||
documents = rebuild_meili(progress=self._progress)
|
||||
|
||||
self._progress(
|
||||
{
|
||||
"running": False,
|
||||
"stage": "complete",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"documents_processed": documents,
|
||||
**summary,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._progress(
|
||||
{
|
||||
"running": False,
|
||||
"stage": "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
+349
-50
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
@@ -27,13 +28,112 @@ def build_jellyfin_client() -> JellyfinClient:
|
||||
)
|
||||
|
||||
|
||||
def resolve_library_name(requested: str | None) -> str:
|
||||
name = (requested or settings.jellyfin_library).strip()
|
||||
if not name:
|
||||
def _library_name(library: dict) -> str:
|
||||
return str(library.get("Name") or library.get("Id") or "(unnamed)")
|
||||
|
||||
|
||||
ProgressCallback = Callable[[dict], None]
|
||||
|
||||
|
||||
def resolve_library_ids(
|
||||
client: JellyfinClient,
|
||||
library_ids: list[str],
|
||||
) -> list[dict]:
|
||||
requested = {str(value).strip() for value in library_ids if str(value).strip()}
|
||||
if not requested:
|
||||
raise RuntimeError("Select at least one Jellyfin library.")
|
||||
|
||||
available = client.media_folders()
|
||||
by_id = {
|
||||
str(library.get("Id") or ""): library
|
||||
for library in available
|
||||
if str(library.get("Id") or "")
|
||||
}
|
||||
missing = sorted(requested.difference(by_id))
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"No Jellyfin library selected. Pass --library or set JELLYFIN_LIBRARY."
|
||||
"Jellyfin library id(s) not found: " + ", ".join(missing)
|
||||
)
|
||||
return name
|
||||
|
||||
return sorted(
|
||||
[by_id[library_id] for library_id in requested],
|
||||
key=lambda library: _library_name(library).casefold(),
|
||||
)
|
||||
|
||||
|
||||
def resolve_libraries(
|
||||
client: JellyfinClient,
|
||||
requested: list[str] | None,
|
||||
*,
|
||||
all_libraries: bool = False,
|
||||
excluded: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
if all_libraries and requested:
|
||||
raise RuntimeError("Use either --all-libraries or --library, not both.")
|
||||
|
||||
available = client.media_folders()
|
||||
by_name = {
|
||||
_library_name(library).casefold(): library
|
||||
for library in available
|
||||
}
|
||||
|
||||
excluded_names = {
|
||||
name.casefold()
|
||||
for name in (*settings.jellyfin_exclude_libraries, *(excluded or []))
|
||||
if name.strip()
|
||||
}
|
||||
|
||||
if all_libraries:
|
||||
selected = [
|
||||
library
|
||||
for library in available
|
||||
if _library_name(library).casefold() not in excluded_names
|
||||
]
|
||||
else:
|
||||
configured = list(settings.jellyfin_libraries)
|
||||
if not configured and settings.jellyfin_library.strip():
|
||||
configured = [settings.jellyfin_library.strip()]
|
||||
|
||||
names = requested or configured
|
||||
if not names:
|
||||
raise RuntimeError(
|
||||
"No Jellyfin libraries selected. Repeat --library, use "
|
||||
"--all-libraries, set JELLYFIN_LIBRARIES, or keep using "
|
||||
"the legacy JELLYFIN_LIBRARY setting."
|
||||
)
|
||||
|
||||
selected = []
|
||||
missing = []
|
||||
seen_ids: set[str] = set()
|
||||
for name in names:
|
||||
key = name.strip().casefold()
|
||||
library = by_name.get(key)
|
||||
if library is None:
|
||||
missing.append(name)
|
||||
continue
|
||||
if key in excluded_names:
|
||||
continue
|
||||
library_id = str(library.get("Id") or "")
|
||||
if library_id and library_id in seen_ids:
|
||||
continue
|
||||
if library_id:
|
||||
seen_ids.add(library_id)
|
||||
selected.append(library)
|
||||
|
||||
if missing:
|
||||
available_names = ", ".join(
|
||||
sorted(_library_name(library) for library in available)
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Jellyfin librar{'y' if len(missing) == 1 else 'ies'} not found: "
|
||||
f"{', '.join(repr(name) for name in missing)}. "
|
||||
f"Available: {available_names or 'none'}"
|
||||
)
|
||||
|
||||
if not selected:
|
||||
raise RuntimeError("No Jellyfin libraries remain after exclusions.")
|
||||
|
||||
return sorted(selected, key=lambda library: _library_name(library).casefold())
|
||||
|
||||
|
||||
def _display_tags(tags: list[str]) -> str:
|
||||
@@ -58,38 +158,68 @@ def cmd_jellyfin_libraries(_: object) -> None:
|
||||
print("No Jellyfin libraries returned.")
|
||||
return
|
||||
|
||||
for item in sorted(libraries, key=lambda row: str(row.get("Name") or "")):
|
||||
print(f"{item.get('Name') or '(unnamed)'}\t{item.get('Id')}")
|
||||
configured = {
|
||||
name.casefold()
|
||||
for name in settings.jellyfin_libraries
|
||||
}
|
||||
if settings.jellyfin_library.strip():
|
||||
configured.add(settings.jellyfin_library.strip().casefold())
|
||||
excluded = {
|
||||
name.casefold()
|
||||
for name in settings.jellyfin_exclude_libraries
|
||||
}
|
||||
|
||||
for item in sorted(libraries, key=lambda row: _library_name(row).casefold()):
|
||||
name = _library_name(item)
|
||||
flags = []
|
||||
if name.casefold() in configured:
|
||||
flags.append("configured")
|
||||
if name.casefold() in excluded:
|
||||
flags.append("excluded")
|
||||
suffix = f"\t[{', '.join(flags)}]" if flags else ""
|
||||
print(f"{name}\t{item.get('Id')}{suffix}")
|
||||
|
||||
|
||||
def cmd_jellyfin_items(args: object) -> None:
|
||||
client = build_jellyfin_client()
|
||||
library_name = resolve_library_name(getattr(args, "library", None))
|
||||
library = client.find_library(library_name)
|
||||
summaries = client.items(str(library["Id"]), limit=getattr(args, "limit", None))
|
||||
libraries = resolve_libraries(
|
||||
client,
|
||||
getattr(args, "library", None),
|
||||
all_libraries=bool(getattr(args, "all_libraries", False)),
|
||||
excluded=getattr(args, "exclude_library", None),
|
||||
)
|
||||
limit = getattr(args, "limit", None)
|
||||
|
||||
if not summaries:
|
||||
print(f"No video items found in {library_name!r}.")
|
||||
return
|
||||
for library in libraries:
|
||||
library_name = _library_name(library)
|
||||
summaries = client.items(str(library["Id"]), limit=limit)
|
||||
print(f"\n=== {library_name} ({len(summaries)} video item(s)) ===")
|
||||
|
||||
for summary in summaries:
|
||||
detail = summary
|
||||
tags = item_tags(detail)
|
||||
trickplay = client.select_trickplay(detail, settings.jellyfin_trickplay_width)
|
||||
tp = (
|
||||
f"{trickplay.width}px/{trickplay.interval_ms}ms/"
|
||||
f"{trickplay.thumbnail_count} thumbs"
|
||||
if trickplay
|
||||
else "none"
|
||||
)
|
||||
print(
|
||||
f"{detail.get('Name') or summary.get('Name') or '(unnamed)'}\n"
|
||||
f" id: {detail.get('Id')}\n"
|
||||
f" type: {detail.get('Type')}\n"
|
||||
f" tags: {_display_tags(tags)}\n"
|
||||
f" trickplay: {tp}\n"
|
||||
f" path: {detail.get('Path') or '-'}"
|
||||
)
|
||||
if not summaries:
|
||||
print("No video items found.")
|
||||
continue
|
||||
|
||||
for summary in summaries:
|
||||
detail = summary
|
||||
tags = item_tags(detail)
|
||||
trickplay = client.select_trickplay(
|
||||
detail,
|
||||
settings.jellyfin_trickplay_width,
|
||||
)
|
||||
tp = (
|
||||
f"{trickplay.width}px/{trickplay.interval_ms}ms/"
|
||||
f"{trickplay.thumbnail_count} thumbs"
|
||||
if trickplay
|
||||
else "none"
|
||||
)
|
||||
print(
|
||||
f"{detail.get('Name') or summary.get('Name') or '(unnamed)'}\n"
|
||||
f" id: {detail.get('Id')}\n"
|
||||
f" type: {detail.get('Type')}\n"
|
||||
f" tags: {_display_tags(tags)}\n"
|
||||
f" trickplay: {tp}\n"
|
||||
f" path: {detail.get('Path') or '-'}"
|
||||
)
|
||||
|
||||
|
||||
def _embed_frames(
|
||||
@@ -97,6 +227,9 @@ def _embed_frames(
|
||||
frame_paths: list[Path],
|
||||
media_id: str,
|
||||
interval_ms: int,
|
||||
*,
|
||||
progress: ProgressCallback | None = None,
|
||||
progress_context: dict | None = None,
|
||||
) -> tuple[list[tuple], int]:
|
||||
rows: list[tuple] = []
|
||||
dim: int | None = None
|
||||
@@ -132,38 +265,104 @@ def _embed_frames(
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
f" embedded "
|
||||
f"{min(start + len(batch_paths), len(frame_paths))}/{len(frame_paths)}"
|
||||
)
|
||||
processed = min(start + len(batch_paths), len(frame_paths))
|
||||
print(f" embedded {processed}/{len(frame_paths)}")
|
||||
if progress is not None:
|
||||
progress(
|
||||
{
|
||||
**(progress_context or {}),
|
||||
"stage": "embedding",
|
||||
"frames_processed": processed,
|
||||
"frames_total": len(frame_paths),
|
||||
}
|
||||
)
|
||||
|
||||
if dim is None:
|
||||
raise RuntimeError("No frames were embedded")
|
||||
return rows, dim
|
||||
|
||||
|
||||
def cmd_jellyfin_index(args: object) -> None:
|
||||
init_db(settings.db_path)
|
||||
client = build_jellyfin_client()
|
||||
library_name = resolve_library_name(getattr(args, "library", None))
|
||||
library = client.find_library(library_name)
|
||||
summaries = client.items(str(library["Id"]), limit=getattr(args, "limit", None))
|
||||
force = bool(getattr(args, "force", False))
|
||||
def _reconcile_library(library_id: str, current_item_ids: set[str]) -> int:
|
||||
stale_rows = []
|
||||
with connect(settings.db_path) as con:
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT id, jellyfin_item_id
|
||||
FROM media
|
||||
WHERE source_kind = 'jellyfin'
|
||||
AND jellyfin_library_id = ?
|
||||
""",
|
||||
(library_id,),
|
||||
).fetchall()
|
||||
|
||||
stale_rows = [
|
||||
row
|
||||
for row in rows
|
||||
if str(row["jellyfin_item_id"] or "") not in current_item_ids
|
||||
]
|
||||
for row in stale_rows:
|
||||
con.execute("DELETE FROM media WHERE id = ?", (row["id"],))
|
||||
|
||||
for row in stale_rows:
|
||||
shutil.rmtree(settings.thumbs_dir / str(row["id"]), ignore_errors=True)
|
||||
|
||||
return len(stale_rows)
|
||||
|
||||
|
||||
def _index_library(
|
||||
client: JellyfinClient,
|
||||
library: dict,
|
||||
*,
|
||||
embedder: SiglipEmbedder | None,
|
||||
limit: int | None,
|
||||
force: bool,
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> tuple[SiglipEmbedder | None, int, int, int, int]:
|
||||
library_id = str(library["Id"])
|
||||
library_name = _library_name(library)
|
||||
summaries = client.items(library_id, limit=limit)
|
||||
|
||||
print(
|
||||
f"Jellyfin library {library_name!r}: {len(summaries)} video item(s) selected"
|
||||
f"\n=== Jellyfin library {library_name!r}: "
|
||||
f"{len(summaries)} video item(s) selected ==="
|
||||
)
|
||||
|
||||
embedder: SiglipEmbedder | None = None
|
||||
indexed = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
current_item_ids: set[str] = set()
|
||||
|
||||
if progress is not None:
|
||||
progress(
|
||||
{
|
||||
"stage": "library",
|
||||
"library_id": library_id,
|
||||
"library": library_name,
|
||||
"item_number": 0,
|
||||
"item_total": len(summaries),
|
||||
}
|
||||
)
|
||||
|
||||
for number, summary in enumerate(summaries, 1):
|
||||
try:
|
||||
detail = summary
|
||||
item_id = str(detail.get("Id") or summary["Id"])
|
||||
current_item_ids.add(item_id)
|
||||
title = str(detail.get("Name") or summary.get("Name") or item_id)
|
||||
if progress is not None:
|
||||
progress(
|
||||
{
|
||||
"stage": "item",
|
||||
"library_id": library_id,
|
||||
"library": library_name,
|
||||
"item_id": item_id,
|
||||
"item": title,
|
||||
"item_number": number,
|
||||
"item_total": len(summaries),
|
||||
"frames_processed": 0,
|
||||
"frames_total": 0,
|
||||
}
|
||||
)
|
||||
tags = item_tags(detail)
|
||||
normalized_tags = {tag.casefold() for tag in tags}
|
||||
|
||||
@@ -221,9 +420,9 @@ def cmd_jellyfin_index(args: object) -> None:
|
||||
and existing["jellyfin_tags"] == tags_json
|
||||
and existing["model_id"] == settings.model_id
|
||||
and int(existing["interval_ms"]) == expected_interval_ms
|
||||
and str(existing["jellyfin_library_id"] or "") == library_id
|
||||
and not force
|
||||
):
|
||||
frame_count = 0
|
||||
with connect(settings.db_path) as con:
|
||||
frame_count = int(
|
||||
con.execute(
|
||||
@@ -296,6 +495,15 @@ def cmd_jellyfin_index(args: object) -> None:
|
||||
frame_paths,
|
||||
media_id,
|
||||
interval_ms,
|
||||
progress=progress,
|
||||
progress_context={
|
||||
"library_id": library_id,
|
||||
"library": library_name,
|
||||
"item_id": item_id,
|
||||
"item": title,
|
||||
"item_number": number,
|
||||
"item_total": len(summaries),
|
||||
},
|
||||
)
|
||||
|
||||
size_bytes = int(source.get("Size") or 0)
|
||||
@@ -325,7 +533,7 @@ def cmd_jellyfin_index(args: object) -> None:
|
||||
"jellyfin",
|
||||
item_id,
|
||||
media_source_id,
|
||||
str(library["Id"]),
|
||||
library_id,
|
||||
str(detail.get("Type") or ""),
|
||||
tags_json,
|
||||
etag,
|
||||
@@ -357,9 +565,100 @@ def cmd_jellyfin_index(args: object) -> None:
|
||||
f"{summary.get('Name') or summary.get('Id')}: {exc}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Jellyfin indexing complete: {indexed} indexed, "
|
||||
f"{skipped} skipped, {failed} failed."
|
||||
pruned = 0
|
||||
if limit is None:
|
||||
pruned = _reconcile_library(library_id, current_item_ids)
|
||||
if pruned:
|
||||
print(f" pruned {pruned} stale item(s) no longer present in Jellyfin")
|
||||
else:
|
||||
print(" limited run: stale-item reconciliation skipped")
|
||||
|
||||
return embedder, indexed, skipped, failed, pruned
|
||||
|
||||
|
||||
def index_libraries(
|
||||
client: JellyfinClient,
|
||||
libraries: list[dict],
|
||||
*,
|
||||
embedder: SiglipEmbedder | None = None,
|
||||
limit: int | None = None,
|
||||
force: bool = False,
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> tuple[SiglipEmbedder | None, dict]:
|
||||
total_indexed = 0
|
||||
total_skipped = 0
|
||||
total_failed = 0
|
||||
total_pruned = 0
|
||||
|
||||
for library_number, library in enumerate(libraries, 1):
|
||||
if progress is not None:
|
||||
progress(
|
||||
{
|
||||
"stage": "library",
|
||||
"library_id": str(library.get("Id") or ""),
|
||||
"library": _library_name(library),
|
||||
"library_number": library_number,
|
||||
"library_total": len(libraries),
|
||||
}
|
||||
)
|
||||
|
||||
embedder, indexed, skipped, failed, pruned = _index_library(
|
||||
client,
|
||||
library,
|
||||
embedder=embedder,
|
||||
limit=limit,
|
||||
force=force,
|
||||
progress=progress,
|
||||
)
|
||||
total_indexed += indexed
|
||||
total_skipped += skipped
|
||||
total_failed += failed
|
||||
total_pruned += pruned
|
||||
|
||||
summary = {
|
||||
"library_total": len(libraries),
|
||||
"indexed": total_indexed,
|
||||
"skipped": total_skipped,
|
||||
"failed": total_failed,
|
||||
"pruned": total_pruned,
|
||||
}
|
||||
if progress is not None:
|
||||
progress({"stage": "indexed", **summary})
|
||||
|
||||
return embedder, summary
|
||||
|
||||
|
||||
def cmd_jellyfin_index(args: object) -> None:
|
||||
init_db(settings.db_path)
|
||||
client = build_jellyfin_client()
|
||||
libraries = resolve_libraries(
|
||||
client,
|
||||
getattr(args, "library", None),
|
||||
all_libraries=bool(getattr(args, "all_libraries", False)),
|
||||
excluded=getattr(args, "exclude_library", None),
|
||||
)
|
||||
if indexed:
|
||||
limit = getattr(args, "limit", None)
|
||||
force = bool(getattr(args, "force", False))
|
||||
|
||||
print(
|
||||
f"Selected {len(libraries)} Jellyfin librar"
|
||||
f"{'y' if len(libraries) == 1 else 'ies'}: "
|
||||
+ ", ".join(_library_name(library) for library in libraries)
|
||||
)
|
||||
|
||||
_, summary = index_libraries(
|
||||
client,
|
||||
libraries,
|
||||
limit=limit,
|
||||
force=force,
|
||||
)
|
||||
|
||||
print(
|
||||
f"\nJellyfin indexing complete across {summary['library_total']} librar"
|
||||
f"{'y' if summary['library_total'] == 1 else 'ies'}: "
|
||||
f"{summary['indexed']} indexed, {summary['skipped']} skipped, "
|
||||
f"{summary['failed']} failed, {summary['pruned']} pruned."
|
||||
)
|
||||
if summary["indexed"] or summary["pruned"]:
|
||||
print("Next: python -m scene_search.cli caption --sync")
|
||||
|
||||
|
||||
@@ -38,11 +38,17 @@ class SearchService:
|
||||
def model_loaded(self) -> bool:
|
||||
return self._embedder is not None
|
||||
|
||||
def _get_embedder(self) -> SiglipEmbedder:
|
||||
def get_embedder(self) -> SiglipEmbedder:
|
||||
"""Get the shared embedding model, loading it lazily."""
|
||||
if self._embedder is None:
|
||||
self._embedder = SiglipEmbedder(self.model_id, self.device)
|
||||
return self._embedder
|
||||
|
||||
@property
|
||||
def model_lock(self) -> threading.Lock:
|
||||
"""Lock guarding exclusive access to the shared GPU embedding model."""
|
||||
return self._model_lock
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
init_db(self.db_path)
|
||||
with connect(self.db_path) as con:
|
||||
@@ -174,7 +180,7 @@ class SearchService:
|
||||
# SigLIP / PyTorch model execution is kept behind one lock so concurrent
|
||||
# API requests do not fight over a single GPU model instance.
|
||||
with self._model_lock:
|
||||
embedder = self._get_embedder()
|
||||
embedder = self.get_embedder()
|
||||
text_vector = embedder.embed_text(query)
|
||||
|
||||
result = self.meili.search_vector(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from .config import settings
|
||||
from .database import connect, init_db, unpack_vector
|
||||
from .jellyfin import tags_from_json
|
||||
from .meili import Meili
|
||||
|
||||
SyncProgress = Callable[[dict], None]
|
||||
|
||||
|
||||
def rebuild_meili(*, progress: SyncProgress | None = None) -> int:
|
||||
"""Rebuild the disposable Meilisearch index from canonical SQLite."""
|
||||
init_db(settings.db_path)
|
||||
meili = Meili(
|
||||
settings.meili_url,
|
||||
settings.meili_master_key,
|
||||
settings.meili_index,
|
||||
)
|
||||
|
||||
with connect(settings.db_path) as con:
|
||||
dims = [row[0] for row in con.execute(
|
||||
"SELECT DISTINCT embedding_dim FROM media"
|
||||
)]
|
||||
if not dims:
|
||||
meili.clear_documents()
|
||||
if progress is not None:
|
||||
progress({"stage": "sync", "documents_processed": 0})
|
||||
return 0
|
||||
if len(dims) != 1:
|
||||
raise RuntimeError(
|
||||
f"Multiple embedding dimensions found: {dims}. "
|
||||
"Use separate indexes per model."
|
||||
)
|
||||
|
||||
meili.configure_user_vectors(int(dims[0]))
|
||||
meili.clear_documents()
|
||||
|
||||
cursor = con.execute(
|
||||
"""
|
||||
SELECT f.id, f.media_id, f.timestamp_ms, f.thumb_path, f.embedding_f16,
|
||||
f.caption, f.caption_model, f.captioned_at,
|
||||
f.caption_route, f.caption_route_reason, f.route_score,
|
||||
f.route_model, f.route_policy, f.routed_at,
|
||||
m.title, m.source_path, m.embedding_dim, m.model_id,
|
||||
m.source_kind, m.jellyfin_item_id, m.jellyfin_media_source_id,
|
||||
m.jellyfin_library_id, m.jellyfin_item_type, m.jellyfin_tags,
|
||||
m.trickplay_width
|
||||
FROM frames f
|
||||
JOIN media m ON m.id = f.media_id
|
||||
ORDER BY f.id
|
||||
"""
|
||||
)
|
||||
|
||||
total = 0
|
||||
while True:
|
||||
rows = cursor.fetchmany(100)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
docs = []
|
||||
for row in rows:
|
||||
vec = unpack_vector(
|
||||
row["embedding_f16"],
|
||||
row["embedding_dim"],
|
||||
)
|
||||
docs.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"media_id": row["media_id"],
|
||||
"title": row["title"],
|
||||
"source_path": row["source_path"],
|
||||
"timestamp_ms": row["timestamp_ms"],
|
||||
"thumb_path": row["thumb_path"],
|
||||
"model_id": row["model_id"],
|
||||
"caption": row["caption"],
|
||||
"caption_model": row["caption_model"],
|
||||
"captioned_at": row["captioned_at"],
|
||||
"caption_route": row["caption_route"],
|
||||
"caption_route_reason": row["caption_route_reason"],
|
||||
"route_score": row["route_score"],
|
||||
"route_model": row["route_model"],
|
||||
"route_policy": row["route_policy"],
|
||||
"routed_at": row["routed_at"],
|
||||
"source_kind": row["source_kind"],
|
||||
"jellyfin_item_id": row["jellyfin_item_id"],
|
||||
"jellyfin_media_source_id": row["jellyfin_media_source_id"],
|
||||
"jellyfin_library_id": row["jellyfin_library_id"],
|
||||
"jellyfin_item_type": row["jellyfin_item_type"],
|
||||
"jellyfin_tags": tags_from_json(row["jellyfin_tags"]),
|
||||
"trickplay_width": row["trickplay_width"],
|
||||
"_vectors": {"siglip2": vec.tolist()},
|
||||
}
|
||||
)
|
||||
|
||||
meili.add_documents(docs)
|
||||
total += len(docs)
|
||||
if progress is not None:
|
||||
progress(
|
||||
{
|
||||
"stage": "sync",
|
||||
"documents_processed": total,
|
||||
}
|
||||
)
|
||||
|
||||
if progress is not None:
|
||||
progress({"stage": "synced", "documents_processed": total})
|
||||
return total
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from scene_search.cli import make_parser
|
||||
from scene_search.database import connect, init_db
|
||||
from scene_search.jellyfin_index import (
|
||||
_reconcile_library,
|
||||
resolve_libraries,
|
||||
resolve_library_ids,
|
||||
)
|
||||
|
||||
|
||||
class FakeJellyfinClient:
|
||||
def media_folders(self) -> list[dict]:
|
||||
return [
|
||||
{"Id": "lib-movies", "Name": "Movies"},
|
||||
{"Id": "lib-tv", "Name": "TV"},
|
||||
{"Id": "lib-home", "Name": "Home Videos"},
|
||||
]
|
||||
|
||||
|
||||
class MultiLibrarySelectionTests(unittest.TestCase):
|
||||
def test_all_libraries_can_exclude_named_library(self) -> None:
|
||||
client = FakeJellyfinClient()
|
||||
|
||||
with patch(
|
||||
"scene_search.jellyfin_index.settings",
|
||||
SimpleNamespace(
|
||||
jellyfin_exclude_libraries=(),
|
||||
jellyfin_libraries=(),
|
||||
jellyfin_library="",
|
||||
),
|
||||
):
|
||||
libraries = resolve_libraries(
|
||||
client, # type: ignore[arg-type]
|
||||
None,
|
||||
all_libraries=True,
|
||||
excluded=["TV"],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[library["Name"] for library in libraries],
|
||||
["Home Videos", "Movies"],
|
||||
)
|
||||
|
||||
def test_repeated_library_arguments_select_multiple(self) -> None:
|
||||
parser = make_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"jellyfin-index",
|
||||
"--library",
|
||||
"Movies",
|
||||
"--library",
|
||||
"TV",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(args.library, ["Movies", "TV"])
|
||||
self.assertFalse(args.all_libraries)
|
||||
|
||||
def test_plugin_library_ids_resolve_to_jellyfin_libraries(self) -> None:
|
||||
client = FakeJellyfinClient()
|
||||
|
||||
libraries = resolve_library_ids(
|
||||
client, # type: ignore[arg-type]
|
||||
["lib-tv", "lib-movies"],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[library["Name"] for library in libraries],
|
||||
["Movies", "TV"],
|
||||
)
|
||||
|
||||
def test_requested_library_names_are_case_insensitive(self) -> None:
|
||||
client = FakeJellyfinClient()
|
||||
|
||||
with patch(
|
||||
"scene_search.jellyfin_index.settings",
|
||||
SimpleNamespace(
|
||||
jellyfin_exclude_libraries=(),
|
||||
jellyfin_libraries=(),
|
||||
jellyfin_library="",
|
||||
),
|
||||
):
|
||||
libraries = resolve_libraries(
|
||||
client, # type: ignore[arg-type]
|
||||
["movies", "HOME VIDEOS"],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[library["Name"] for library in libraries],
|
||||
["Home Videos", "Movies"],
|
||||
)
|
||||
|
||||
|
||||
class LibraryReconciliationTests(unittest.TestCase):
|
||||
def test_reconcile_only_prunes_stale_items_from_target_library(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
db_path = root / "scenes.sqlite3"
|
||||
thumbs_dir = root / "thumbs"
|
||||
init_db(db_path)
|
||||
|
||||
rows = [
|
||||
("keep", "Keep", "jf-keep", "lib-a"),
|
||||
("stale", "Stale", "jf-stale", "lib-a"),
|
||||
("other", "Other", "jf-other", "lib-b"),
|
||||
]
|
||||
with connect(db_path) as con:
|
||||
for media_id, title, jellyfin_id, library_id in rows:
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO media (
|
||||
id, source_path, title, content_sha256, size_bytes,
|
||||
mtime_ns, interval_ms, model_id, embedding_dim,
|
||||
source_kind, jellyfin_item_id, jellyfin_library_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
media_id,
|
||||
f"jellyfin://{jellyfin_id}",
|
||||
title,
|
||||
"hash",
|
||||
0,
|
||||
0,
|
||||
10000,
|
||||
"model",
|
||||
2,
|
||||
"jellyfin",
|
||||
jellyfin_id,
|
||||
library_id,
|
||||
),
|
||||
)
|
||||
folder = thumbs_dir / media_id
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "frame.jpg").write_bytes(b"x")
|
||||
|
||||
fake_settings = SimpleNamespace(
|
||||
db_path=db_path,
|
||||
thumbs_dir=thumbs_dir,
|
||||
)
|
||||
with patch("scene_search.jellyfin_index.settings", fake_settings):
|
||||
pruned = _reconcile_library("lib-a", {"jf-keep"})
|
||||
|
||||
self.assertEqual(pruned, 1)
|
||||
with connect(db_path) as con:
|
||||
remaining = {
|
||||
row["id"]
|
||||
for row in con.execute("SELECT id FROM media").fetchall()
|
||||
}
|
||||
|
||||
self.assertEqual(remaining, {"keep", "other"})
|
||||
self.assertFalse((thumbs_dir / "stale").exists())
|
||||
self.assertTrue((thumbs_dir / "keep").exists())
|
||||
self.assertTrue((thumbs_dir / "other").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user