mirror of
https://github.com/MrSleeps/jellyfin-scene-search.git
synced 2026-11-14 22:11:27 +00:00
Index multiple Jellyfin libraries in one run
This commit is contained in:
+231
-48
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user