video scan: harden Jellyfin adapter (paging + per-item resilience)

- Paginate Jellyfin movie/series listings (StartIndex/Limit, 500/page) so large
  libraries aren't truncated on full/deep scans; incremental stays a single
  capped page.
- Per-item try/except in iter_movies/iter_shows (matches Plex) so one bad item
  doesn't abort the scan.
- Skip Jellyfin episodes with no IndexNumber (consistency with Plex; avoids
  ep-0 collisions). All three modes now solid for Plex + Jellyfin.
This commit is contained in:
BoulderBadgeDad 2026-06-14 00:53:07 -07:00
parent fba47e9665
commit 9974845470

View file

@ -235,6 +235,21 @@ class JellyfinVideoSource:
"tv": [{"title": v.get("Name")} for v in self._views("tvshows")],
}
def _paged(self, path, params, page_size=500):
"""Yield items across pages so large libraries aren't capped/truncated."""
start = 0
while True:
p = dict(params)
p.update({"StartIndex": str(start), "Limit": str(page_size)})
resp = self._req(path, p) or {}
batch = resp.get("Items", [])
for it in batch:
yield it
start += len(batch)
total = resp.get("TotalRecordCount")
if not batch or len(batch) < page_size or (total is not None and start >= total):
break
@staticmethod
def _ticks_to_seconds(ticks):
return int(ticks / 10_000_000) if ticks else None
@ -259,14 +274,20 @@ class JellyfinVideoSource:
}
def iter_movies(self, incremental=False):
path = f"/Users/{self.uid}/Items"
for view in self._views("movies", self._movies_lib):
params = {"ParentId": view["Id"], "IncludeItemTypes": "Movie",
"Recursive": "true", "Fields": _JF_MOVIE_FIELDS}
if incremental:
params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "100"})
resp = self._req(f"/Users/{self.uid}/Items", params) or {}
for it in resp.get("Items", []):
yield self._movie(it)
items = (self._req(path, params) or {}).get("Items", [])
else:
items = self._paged(path, params)
for it in items:
try:
yield self._movie(it)
except Exception:
logger.exception("Jellyfin: skipping movie %s", it.get("Name", "?"))
def _movie(self, it) -> dict:
studios = it.get("Studios") or []
@ -284,14 +305,20 @@ class JellyfinVideoSource:
}
def iter_shows(self, incremental=False):
path = f"/Users/{self.uid}/Items"
for view in self._views("tvshows", self._tv_lib):
params = {"ParentId": view["Id"], "IncludeItemTypes": "Series",
"Recursive": "true", "Fields": "Overview,ProductionYear,OfficialRating"}
if incremental:
params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "50"})
resp = self._req(f"/Users/{self.uid}/Items", params) or {}
for it in resp.get("Items", []):
yield self._show(it)
items = (self._req(path, params) or {}).get("Items", [])
else:
items = self._paged(path, params)
for it in items:
try:
yield self._show(it)
except Exception:
logger.exception("Jellyfin: skipping show %s", it.get("Name", "?"))
def _show(self, it) -> dict:
series_id = str(it["Id"])
@ -301,13 +328,16 @@ class JellyfinVideoSource:
"UserId": self.uid, "Fields": _JF_EP_FIELDS}) or {}
by_season: dict[int, list] = {}
for ep in eps_resp.get("Items", []):
enum = ep.get("IndexNumber")
if enum is None:
continue # unnumbered/special — can't key it
snum = ep.get("ParentIndexNumber") or 0
aired = ep.get("PremiereDate")
ticks = ep.get("RunTimeTicks")
by_season.setdefault(snum, []).append({
"server_id": str(ep["Id"]),
"season_number": snum,
"episode_number": ep.get("IndexNumber") or 0,
"episode_number": enum,
"title": ep.get("Name"),
"overview": ep.get("Overview"),
"air_date": aired[:10] if aired else None,