From 5333b95aee0d83c0943432f4e6e43b41a594ffc1 Mon Sep 17 00:00:00 2001 From: Sleeps Date: Tue, 6 Oct 2026 17:47:10 +0100 Subject: [PATCH] Index multiple Jellyfin libraries in one run --- scene_search/jellyfin_index.py | 279 +++++++++++++++++++++++++++------ 1 file changed, 231 insertions(+), 48 deletions(-) diff --git a/scene_search/jellyfin_index.py b/scene_search/jellyfin_index.py index 20d5591..fa7607f 100644 --- a/scene_search/jellyfin_index.py +++ b/scene_search/jellyfin_index.py @@ -27,13 +27,83 @@ def build_jellyfin_client() -> JellyfinClient: ) -def resolve_library_name(requested: str | None) -> str: - name = (requested or settings.jellyfin_library).strip() - if not name: - raise RuntimeError( - "No Jellyfin library selected. Pass --library or set JELLYFIN_LIBRARY." - ) - return name +def _library_name(library: dict) -> str: + return str(library.get("Name") or library.get("Id") or "(unnamed)") + + +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 +128,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( @@ -142,27 +242,60 @@ def _embed_frames( 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, +) -> 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() 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) tags = item_tags(detail) normalized_tags = {tag.casefold() for tag in tags} @@ -221,9 +354,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( @@ -325,7 +458,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 +490,59 @@ 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 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) + ) + + embedder: SiglipEmbedder | None = None + total_indexed = 0 + total_skipped = 0 + total_failed = 0 + total_pruned = 0 + + for library in libraries: + embedder, indexed, skipped, failed, pruned = _index_library( + client, + library, + embedder=embedder, + limit=limit, + force=force, + ) + total_indexed += indexed + total_skipped += skipped + total_failed += failed + total_pruned += pruned + + print( + f"\nJellyfin indexing complete across {len(libraries)} librar" + f"{'y' if len(libraries) == 1 else 'ies'}: " + f"{total_indexed} indexed, {total_skipped} skipped, " + f"{total_failed} failed, {total_pruned} pruned." + ) + if total_indexed or total_pruned: print("Next: python -m scene_search.cli caption --sync")