Sync played Jellyfin shows into the ledger #31

Manually merged
marvin8 merged 2 commits from feat/issue-30-jellyfin-show-sync into main 2026-10-17 01:25:55 +00:00
Collaborator

The Jellyfin sync now covers TV: the worker walks the user's played episodes, groups them by parent series, and lands every series with at least one played episode in the ledger as watched — one watch event per played episode, at series-level resolution, matching what a Trakt import produces. A half-watched show therefore counts as seen and stops being recommended.

  • JellyfinEpisode / JellyfinSeries client types, a paged played-episodes fetch, and a batched series metadata lookup (new)
  • sync_jellyfin_shows: series keyed on TMDB/IMDb/TheTVDB, series rating mirrored, unkeyable or metadata-less series skipped without failing the pass
  • The jellyfin_sync job runs the movies pass then the shows pass; a movies-pass failure skips the shows pass so the recorded error stays visible
  • No schema change: episodes stay non-first-class (#25 still owns episode-level tracking)

Re-syncs are idempotent — the ledger ignores plays it already holds.

Closes #30

The Jellyfin sync now covers TV: the worker walks the user's played episodes, groups them by parent series, and lands every series with at least one played episode in the ledger as watched — one watch event per played episode, at series-level resolution, matching what a Trakt import produces. A half-watched show therefore counts as seen and stops being recommended. - `JellyfinEpisode` / `JellyfinSeries` client types, a paged played-episodes fetch, and a batched series metadata lookup (new) - `sync_jellyfin_shows`: series keyed on TMDB/IMDb/TheTVDB, series rating mirrored, unkeyable or metadata-less series skipped without failing the pass - The `jellyfin_sync` job runs the movies pass then the shows pass; a movies-pass failure skips the shows pass so the recorded error stays visible - No schema change: episodes stay non-first-class (#25 still owns episode-level tracking) Re-syncs are idempotent — the ledger ignores plays it already holds. Closes #30
✨ feat: sync played Jellyfin shows into the ledger
All checks were successful
/ gitleaks (pull_request) Successful in 27s
/ checks (pull_request) Successful in 3m35s
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 2m3s
/ publish-container (pull_request) Successful in 2m43s
/ pr-review (pull_request) Successful in 7m26s
fd996f7976
forgejo-actions left a comment

WuMing

Found 4 issue(s). See inline comments below.

## WuMing Found **4** issue(s). See inline comments below.
@ -465,0 +586,4 @@
JellyfinError: The URL is not ``https`` or the request failed.
"""
url = _endpoint(_require_https(base_url), "/Items")

security [MEDIUM]

A10: User-provided base_url is validated only for HTTPS scheme, not for targeting legitimate Jellyfin servers. Enables SSRF against internal HTTPS services.

**security** [MEDIUM] A10: User-provided base_url is validated only for HTTPS scheme, not for targeting legitimate Jellyfin servers. Enables SSRF against internal HTTPS services. <!-- wuming:sha256:5d4b7247abce120d786d825f3f5ccae9375d7d0f3d4d93d39bf98be0bd5e242a -->
@ -465,0 +638,4 @@
"""
if not item_ids:
return ()
url = _endpoint(_require_https(base_url), "/Items")

security [MEDIUM]

A10: User-provided base_url in series metadata fetch can probe internal HTTPS endpoints without further validation, potential SSRF vector.

**security** [MEDIUM] A10: User-provided base_url in series metadata fetch can probe internal HTTPS endpoints without further validation, potential SSRF vector. <!-- wuming:sha256:ae81b0ffa3d4f0e00c959d70eaf8c68414910f3d2d4671ae6601fa2cdf5a664a -->
@ -304,0 +449,4 @@
transport=transport,
)
except JellyfinError as exc:
return {}, str(exc)

security [MEDIUM]

A09: Raw exception messages (str(exc)) are stored in the database via record_sync_result without sanitization. May leak sensitive data on API failures.

**security** [MEDIUM] A09: Raw exception messages (str(exc)) are stored in the database via record_sync_result without sanitization. May leak sensitive data on API failures. <!-- wuming:sha256:047571c2fe3c9b10d4be5ffdc46f57433e9c3d6b64bed4fc38dabe1339423020 -->
@ -304,0 +511,4 @@
transport=transport,
)
except JellyfinError as exc:
return summary, str(exc)

security [MEDIUM]

A09: Raw exception messages (str(exc)) are stored in the database via record_sync_result without sanitization. May leak sensitive data on metadata fetch failures.

**security** [MEDIUM] A09: Raw exception messages (str(exc)) are stored in the database via record_sync_result without sanitization. May leak sensitive data on metadata fetch failures. <!-- wuming:sha256:aded58bb8bb84f8599c4b04b5ad3dc590401c78bda5aee7f097a7bae438ddd08 -->
Author
Collaborator

🔴 Not actioned — SSRF via HTTPS-only base URL validation

src/xique/services/jellyfin.py · src/xique/services/jellyfin.py

A10: User-provided base_url is validated only for HTTPS scheme, not for targeting legitimate Jellyfin servers…

Declined — this is the spec's explicit, approved design, not an oversight. Design spec §7.1 requires https:// and additionally validates the target is a real Jellyfin server via /System/Info/Public before the URL is accepted, and states plainly: "Accept private/LAN addresses (legitimate self-hosted use); no blanket private-range block." Xique is an invite-only, single-instance, self-hosted app whose entire purpose is talking to each user's own LAN Jellyfin; a private-range block would break the product. The new fetches (fetch_played_episodes, fetch_series) also introduce no new surface — they go through the same _require_https gate as every existing client call, using a base URL the user already connected and validated.


🔴 Not actioned — raw str(exc) stored via record_sync_result

src/xique/services/sync.py · src/xique/services/sync.py

A09: Raw exception messages (str(exc)) are stored in the database via record_sync_result without sanitization…

Declined — the exceptions reaching record_sync_result are JellyfinError messages, which are token-free by construction: the client never embeds URLs, tokens, passwords, or response bodies in them ("Jellyfin request failed: {type}'', "Jellyfin API returned HTTP {status}" — pinned by client tests). The sqlite3.IntegrityError path carries constraint names only. This is exactly the pattern the existing movies pass uses at every call site, and the docstrings pin it ("token-free error description"). The new code adds no new exception content.

### 🔴 Not actioned — SSRF via HTTPS-only base URL validation [`src/xique/services/jellyfin.py`](https://forge.marvin8.zone/marvin8/xique/pulls/31#issuecomment-5818) · [`src/xique/services/jellyfin.py`](https://forge.marvin8.zone/marvin8/xique/pulls/31#issuecomment-5819) > A10: User-provided base_url is validated only for HTTPS scheme, not for targeting legitimate Jellyfin servers… Declined — this is the spec's explicit, approved design, not an oversight. Design spec §7.1 requires `https://` and additionally validates the target is a real Jellyfin server via `/System/Info/Public` before the URL is accepted, and states plainly: *"Accept private/LAN addresses (legitimate self-hosted use); no blanket private-range block."* Xique is an invite-only, single-instance, self-hosted app whose entire purpose is talking to each user's own LAN Jellyfin; a private-range block would break the product. The new fetches (`fetch_played_episodes`, `fetch_series`) also introduce no new surface — they go through the same `_require_https` gate as every existing client call, using a base URL the user already connected and validated. --- ### 🔴 Not actioned — raw `str(exc)` stored via `record_sync_result` [`src/xique/services/sync.py`](https://forge.marvin8.zone/marvin8/xique/pulls/31#issuecomment-5820) · [`src/xique/services/sync.py`](https://forge.marvin8.zone/marvin8/xique/pulls/31#issuecomment-5821) > A09: Raw exception messages (str(exc)) are stored in the database via record_sync_result without sanitization… Declined — the exceptions reaching `record_sync_result` are `JellyfinError` messages, which are token-free by construction: the client never embeds URLs, tokens, passwords, or response bodies in them (`"Jellyfin request failed: {type}''`, `"Jellyfin API returned HTTP {status}"` — pinned by client tests). The `sqlite3.IntegrityError` path carries constraint names only. This is exactly the pattern the existing movies pass uses at every call site, and the docstrings pin it ("token-free error description"). The new code adds no new exception content.
marvin8 approved these changes 2026-10-17 01:25:18 +00:00
marvin8 manually merged commit 920584d8d1 into main 2026-10-17 01:25:55 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/xique!31
No description provided.