Index multiple Jellyfin libraries in one run

This commit is contained in:
Sleeps
2026-10-06 17:47:10 +01:00
parent b21d25f3a9
commit 5333b95aee
+231 -48
View File
@@ -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")