Compare commits

...
Author SHA1 Message Date
coderabbitai[bot]andGitHub 94a280e89a CodeRabbit Generated Unit Tests: Add unit tests for PR changes 2026-06-27 18:51:29 +00:00
QuickandClaude Opus 4.8 4d2a990de1 chore(skills): harden issue-to-pr with researched best practices
Folds in findings from Anthropic's Claude Code guidance and the agentic-SWE
literature:

- Manual-only trigger (disable-model-invocation) for this side-effecting,
  PR-creating workflow, per Anthropic's fix-issue template.
- Reproduce-first hard gate: confirm the bug exists on current main (red test
  before the fix); stop and report if it doesn't reproduce, rather than patching
  correct/stale code.
- Verification is the primary correctness signal; full-suite run for regressions;
  forbid reaching green by weakening/skipping tests or relaxing CI.
- Adversarial-review loop: confirm each finding is real/grounded before acting,
  fix only correctness/requirement-affecting findings (no gold-plating), and cap
  the loop at 2 (max 3) iterations before escalating.
- Guards against hallucinated APIs, plan-proportional-to-scope, and long-run
  context-loss (progress + failed-approaches notes, re-assert guardrails after
  compaction).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:07:36 -04:00
QuickandClaude Opus 4.8 96a279819e chore(skills): add issue-to-pr skill (worktree → fix → adversarial review → PR)
Adds a committed, shared Claude Code skill that takes a GitHub issue number,
creates an isolated worktree off the latest main, finds the root cause or
scopes the feature, implements and verifies the change, runs a Codex
adversarial review, and opens a ready PR.

Carves a narrow exception into the .gitignore /.claude/ rule so only this
skill directory is tracked; everything else under .claude/ stays ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:07:36 -04:00
443445f0fe fix(catalog): make media_items scalar columns NOT NULL to stop NULL-scan crashes (#228)
media_items had many columns the Go model (models.MediaItem) declares as
non-pointer string/int fields, but migration 001 left them nullable. Five
scanners (catalog item_repo, catalog browse, jellycompat, sections, and
the catalog API handler) read these straight into the non-pointer fields,
so a NULL row panics with "cannot scan NULL into *string" (or *int).
item_repo papers over a subset (poster/backdrop/logo/metadata paths) with
COALESCE in its SELECT, but the other four scanners read the same columns
raw and crash; sort_title/original_title/etc. are not coalesced anywhere.

No writer stores a meaningful NULL (every insert/upsert passes the Go
field, '' or 0 at worst) and all sort/filter SQL already collapses NULL
and '' (e.g. COALESCE(NULLIF(BTRIM(sort_title), ''), title); "poster_path
IS NULL OR poster_path = ''"). Enforce the invariant the code already
assumes at the schema level rather than scattering COALESCE across every
current and future scanner. Mirrors what later migrations already did for
original_language, show_status, default_metadata_language, and the
*_source_path columns (all NOT NULL DEFAULT '').

- Migration: backfill existing NULLs, then NOT NULL DEFAULT '' on 15 text
  columns (sort_title, original_title, content_rating, overview, tagline,
  imdb_id/tmdb_id/tvdb_id, poster_path, poster_thumbhash, backdrop_path,
  backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag) and
  NOT NULL DEFAULT 0 on year/runtime.
- item_repo: SetLocalPoster and UpdateArtworkIfSourceMatches now store ''
  for empty thumbhashes (was NULLIF($,'')) — the only deliberate NULL
  writers — matching the upsert path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:01 -04:00
02e62767a1 feat(watchsync): sync watchlists with Trakt/Simkl/MDBList (#227)
* feat(watchsync): sync watchlists with Trakt/Simkl/MDBList

Extend the watch-providers feature to sync a user's watchlist, generalizing
the existing favorites pipeline rather than duplicating it.

What changed
- Generalize the favorites sync into one ListKind-parameterized pipeline
  (internal/watchsync/lists.go) driving both favorites and watchlist; the
  per-favorites service methods are replaced by kind-generic ones. The shadow
  table watch_provider_favorite_items becomes watch_provider_list_items with a
  list_kind discriminator.
- Providers: Trakt gains watchlist sync (/sync/watchlist, distinct from
  favorites); Simkl gains plan-to-watch sync; MDBList is re-mapped from
  favorites to watchlist (its only list is a watchlist) — its capabilities now
  report import_favorites=false / import_watchlist=true, and the migration
  re-binds existing MDBList connections.
- Auto-remove watched items from the watchlist: a standalone, default-on
  profile preference (user_profiles.remove_watched_from_watchlist) removes a
  movie when watched and a series once every episode is watched. Implemented as
  watchstate.CompletionObserver (internal/watchlist.Maintainer), wired into the
  manual mark-watched, playback-stop, and jellycompat mark-played paths.
- Optional MDBList sort-order mirroring: an opt-in, capability-gated toggle
  mirrors MDBList's watchlist order into Silo via user_watchlist.sort_index;
  ListWatchlist orders by sort_index then added_at, so both /api/v1/watchlist
  and the catalog watchlist view inherit it.
- Real-time + scheduled: local add/remove pushes to connected providers
  immediately (removals gated by the opt-in removals toggle); the hourly job is
  the inbound/import + retry/reconcile path.
- Web: watch-provider settings gain watchlist import/export/removals and
  "mirror watchlist order" toggles plus watchlist sync stats.

Why
- The favorites and watchlist pipelines are ~90% identical; generalizing keeps
  one code path (per CLAUDE.md's anti-duplication guidance) instead of cloning.

API/compat
- All new fields on ConnectionStatus/Capabilities/ConnectionUpdate/SyncRun and
  the web types are additive (Silo v1 additive-only rule). No existing field is
  renamed, removed, or retyped.

Risks / follow-up
- MDBList capability flip is intentional and client-visible: silo-android /
  silo-apple may need to surface MDBList under the watchlist (not favorites) UI.
- MDBList existing users: their MDBList list previously mirrored Silo favorites
  and now mirrors Silo watchlist; the first post-migration sync is a union
  (removals default off), so nothing is destructively purged.
- Order mirroring reflects the order MDBList returns from /watchlist/items
  (couldn't confirm against their docs — Cloudflare-blocked); if it ever
  diverges from the UI sort, a sort param is the small follow-up.

Tests: new maintainer (auto-remove) and watchlist-order unit tests; provider +
service tests updated. go build, go test (affected pkgs), migrate-validate,
verify-local-paths, web prettier/eslint/tsc all pass.

AI-use disclosure: implemented with Claude Code (Claude Opus 4.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(watchsync): update list shadow table references

* fix(watchsync): address review — retry/progress + error propagation

Addresses CodeRabbit review on #227:
- maintainer: propagate transient catalog lookup errors instead of silently
  treating every items.GetByID failure as "maybe an episode".
- exportList: mark every queued item not confirmed sent (not_found, failed, or
  omitted) so the pending loop always advances; the next run's upsert clears the
  error and re-attempts, so transient failures still retry.
- removePendingListItems + realtime removal: treat Sent and NotFound as
  reconciled; leave true failures pending (no last_error, which would strand
  them from the removal query) so the scheduled run retries, using in-memory
  dedupe to terminate the loop.
- exportLocalListItems: send the normalized items (with computed
  ProviderItemKey), not the original event slice.
- UpdateConnection: clear mirrored watchlist order before persisting the disable
  and propagate failures, so a failed clear can't report "disabled" while
  sort_index ordering is still active.
- web: include favorite + watchlist removal counts in the exported "sent" total.
- test: align serviceFakeRepo list-state with Postgres (clear last_error on
  successful transitions); add maintainer error-propagation test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:05:53 -04:00
b4a66b29e2 fix(web): preserve untouched metadata levels when toggling a provider (#226)
toggleLevelProvider merged the toggled level into the levelChains state
object, which starts empty. After the first toggle the state collapsed to
a single level, so once chainDirty flipped true activeLevelChains dropped
every untouched level and the dialog rendered them empty.

Seed from the full resolved activeLevelChains map and write it back the
same way reorderLevel already does, so disabling a source on one TV level
no longer blanks the others on the first toggle.

The earlier redesign (b9a9613) only patched the per-level source fallback,
which kept the toggled level's items but did not preserve the rest of the
map; builds containing it still reproduced the issue.

Part of #213

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:46:40 -04:00
6854eac80e fix(web/deps): bump vulnerable transitive deps via pnpm overrides (#225)
Resolves 8 open Dependabot alerts in web/pnpm-lock.yaml (all dev-only
transitive dependencies) by pinning patched versions through the existing
pnpm.overrides mechanism:

- undici  7.24.7 -> 7.28.0  (via jsdom; alerts #43-#49, incl. 2 high:
  TLS cert validation bypass & cross-origin routing via SOCKS5 ProxyAgent)
- js-yaml 4.1.1  -> 4.2.0   (via @eslint/eslintrc; alert #39, quadratic DoS)
- @babel/core 7.29.0 -> 7.29.6 (via @vitejs/plugin-react; alert #38,
  arbitrary file read via sourceMappingURL)

No vulnerable versions remain in the lockfile. Web build and lint pass;
the only failing tests (ServerStorageStep ResizeObserver) are pre-existing
on main and unrelated to these bumps.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:37:43 -04:00
CoffeeKnyteandGitHub 6414061022 fix(collections): correct multi-library sync — match all bound libraries and dedupe items (#184)
* fix(collections): sync matches across all bound libraries

Collection sync resolved item membership against only the legacy
library_collections.library_id, so a collection bound to multiple
libraries (N:N library_collection_libraries) had its items collapsed to a
single library on every sync.

Switch the MDBList/TMDB/Trakt sync matchers to the existing
LibraryItemRepository.GetItemsInFolders over collection.LibraryIDs.
GetByID already populates LibraryIDs from the N:N table (falling back to
[LibraryID]), so single-library collections are unaffected. No schema
change; listing and the catalog open path were already N:N-aware.

Unblocks merging duplicated per-library collections into single
multi-library collections.

* fix(collections): dedupe matched items by media_item_id before ReplaceItems

A collection sync resolves source entries (external IDs) to library
content_ids and appends one item per matched entry without deduping by
media_item_id. When two different source entries resolve to the same
library item, the same content_id is appended twice; ReplaceItems then
hits the library_collection_items composite PK (collection_id,
media_item_id) with a duplicate-key violation, failing the whole sync
with an HTTP 500 and zero items.

The multi-library union added in the previous commit (GetItemsInFolders
over collection.LibraryIDs) widens the matched set and so raises the
collision probability, making this latent bug more likely to fire.

Dedupe at the single choke point in ReplaceItems (first occurrence wins,
preserving rank order) so all five sync paths are covered at once, and
renumber position over the surviving rows so it stays dense.

Also log the underlying error in HandleSyncAdminCollection, which
previously mapped every non-sentinel sync error to a generic 500 with
no log line, making this failure invisible on the server.
2026-06-26 12:28:14 -04:00
a3aa93534d fix(jellycompat): authenticate Android TV token-less direct play (#200)
* fix(jellycompat): authenticate Android TV token-less direct play

Stock Jellyfin Android TV ignores the api_key-bearing DirectStreamUrl
returned from PlaybackInfo and builds its own direct-play URL with no
auth header, no api_key/ApiKey query param, and no PlaySessionId. The
media request arrives via the player's HTTP stack (okhttp) with
auth_kind=none, so PlaybackSessionAuth 401s it — the client retries,
falls back to a transcode that stalls, and surfaces "player error".

Add a third fallback in PlaybackSessionAuth, scoped strictly to the
direct-play video stream routes (/Videos/{id}/stream[.{container}]) via
the chi route pattern so /Items/{id}/Download stays protected: anchor
auth on the PlaybackSession negotiated during the preceding (already
authenticated) PlaybackInfo, looked up by mediaSourceId when present
(else the route item id), and resolve its CompatToken.

Covered by tests: token-less direct play with a matching session
succeeds, no matching session 401s, and Download is not loosened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(jellycompat): require session item match + expand direct-play tests

Address CodeRabbit review on PR #200:
- Require the matched PlaybackSession's RouteItemID to equal the requested
  route item before authorizing, so a mediaSourceId cannot authorize a
  stream for a different item.
- Seed the compat session in the 401 tests so they fail on route/session
  scoping rather than a missing session.
- Table-drive the positive test across /Videos/{id}/stream and
  /Videos/{id}/stream.{container}, plus the route-item lookup branch when
  mediaSourceId is absent; add a cross-item denial test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:27:37 -04:00
QuickandGitHub d83f7fadef fix(audiobooks): mirror ABS playback into live sessions (#203) 2026-06-26 12:27:12 -04:00
185a90b048 fix(jellycompat): honor client subtitle selection in PlaybackInfo (#222)
* fix(jellycompat): honor client subtitle selection in PlaybackInfo

Jellyfin clients send the chosen subtitle as SubtitleStreamIndex on
PlaybackInfo, but the compat layer only ever advertised the media's
embedded default subtitle (or none). Picking a subtitle before playback
had no effect and the video started without it — and an explicit
"subtitles off" was ignored when the media had a default subtitle.

Mirror the existing audio-selection plumbing for subtitles:
- parse SubtitleStreamIndex from the PlaybackInfo body and query
- persist it as SelectedSubtitleStreamIndex on the play-session source
- advertise it via DefaultSubtitleStreamIndex and flip IsDefault onto the
  chosen stream (embedded, external, or Silo-downloaded)
- honor a negative index as an explicit "subtitles off"

The selection is validated against streamable tracks (bitmap subs that
require burn-in are excluded, matching delivery) and the downloaded
subtitle range, falling back to the media default when invalid.

Out of scope (follow-ups): bitmap-subtitle burn-in transcode wiring and
mid-playback subtitle changes on progress reports.

Part of #217

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(jellycompat): handle downloaded-subtitle lookup failure in selection

Don't let a transient ListDownloadedSubtitles error masquerade as "no
downloaded subtitles", which silently downgraded a valid subtitle
selection to the media default. The lookup error is now logged, and
resolution honors a requested index it cannot validate (it may be a
downloaded subtitle) instead of discarding the user's choice, while
embedded/external selections continue to resolve normally.

Addresses CodeRabbit review feedback on #222.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:26:27 -04:00
QuickandGitHub 78d4f9acbf Merge pull request #223 from Silo-Server/codex/silodiscordtriage-httpsdiscordappcomchannels148783
Fix semantic-disabled Meilisearch settings
2026-06-26 12:12:31 -04:00
Quick 7f67bb80ad Fix semantic-disabled Meilisearch settings
- Omit Meilisearch embedders unless semantic search is enabled
- Default semantic ratio setting to 0.50
2026-06-26 12:09:45 -04:00
68 changed files with 5026 additions and 1012 deletions
+264
View File
@@ -0,0 +1,264 @@
---
name: issue-to-pr
disable-model-invocation: true
description: >-
End-to-end implement a GitHub issue and open a ready pull request. Invoke
explicitly as `/issue-to-pr <number|#ref|url>` (this is a side-effecting
workflow — it creates branches and opens PRs — so it does not auto-trigger).
It creates an isolated git worktree off the latest main, reproduces the bug
(or scopes the feature), finds the root cause, implements the smallest correct
change following repo conventions, verifies it (build/lint/tests), runs a Codex
adversarial review and addresses the material findings, then commits and opens
a ready PR. Not for pure triage (use triage-issue), pure review of an existing
PR (use review-pr-or-mr / code-review), or analysis without code changes.
---
# Issue → PR
Turn a single GitHub issue into a verified, reviewed, ready-to-merge pull
request — autonomously — while staying honest about uncertainty and stopping
when a human decision is genuinely required.
The goal is not "produce a diff." It is: **confirm the real problem exists,
make the smallest change that correctly solves it, prove it works by execution,
have an adversary try to break it, and only then ship a PR.** Speed comes from
doing these in order, not from skipping them.
## Inputs
- The issue reference is passed as `$ARGUMENTS` — a number (`123`), a `#123`
reference, or a full issue URL. If none is given, ask; do not guess.
- The repo is the current git repository; PRs target `origin`
(`Silo-Server/silo-server`) and base `main`.
## Operating principles
- **Default to "maybe nothing needs fixing."** Coding agents "fix" already-correct
or stale code in a large fraction of cases because they don't stop to confirm the
problem is real. Confirm the issue reproduces on current `main` before changing
anything — it may already be fixed.
- **Root cause over symptom.** A fix you can't explain is a fix you can't trust.
- **Verify by execution, not by reading.** Build/lint/tests are the primary
correctness signal. The adversarial review is a *second* layer on top — never a
substitute for green checks. If you can't verify it, don't ship it.
- **Smallest correct change.** Match surrounding code's idioms, naming, and comment
density. Put code in the package that owns the behavior (CLAUDE.md); don't create
catch-all helpers or duplicate logic — search for an existing helper before
writing a new one, and extract shared logic instead of copying.
- **Don't trust recall for APIs.** Verify every symbol, function, and signature you
call actually exists by grepping the codebase — agents hallucinate plausible-looking
APIs.
- **Don't gold-plate.** A reviewer told to find gaps will always find some. Address
what affects correctness or the issue's stated requirements; treat the rest as
optional. Extra abstraction "just in case" is a defect, not diligence.
- **Know when to stop.** See [Stop and ask](#stop-and-ask). Autonomy is for
execution, not for inventing product decisions or shipping unverifiable guesses.
Track the phases below with a task list (TaskCreate/TaskUpdate) so progress is
visible and nothing is skipped on long runs.
## Workflow
### 1. Understand the issue
Read everything before writing anything.
```bash
gh issue view <N> --json number,title,body,labels,state,author,url,comments
```
Determine: **bug vs. feature** (labels + body → branch prefix `fix/` vs `feat/`);
**acceptance criteria** (what does "done" mean?); **linked scope** (does it serve
an epic / sub-issue to reference as `Part of #NNN`?); and **client surface** (does
it touch API/auth/playback/session/library/metadata behavior consumed by
`silo-android` / `silo-apple`? — if so, coordinated client follow-up may be needed).
If the issue is vague, underspecified, or needs a product call, stop and ask now —
before spending a worktree on the wrong problem.
### 2. Create a worktree off the latest main
Work in isolation so the user's checkout is untouched. Create it at the **main
repository root**, not inside the current worktree.
```bash
# Resolve the primary repo root (works from inside any worktree)
MAIN_ROOT="$(cd "$(dirname "$(git rev-parse --git-common-dir)")" && pwd)"
git -C "$MAIN_ROOT" fetch origin # always branch off fresh main
SLUG=<short-kebab-slug-from-issue-title>
BRANCH=fix/issue-<N>-$SLUG # use feat/ for features
WT="$MAIN_ROOT/.claude/worktrees/issue-<N>"
git -C "$MAIN_ROOT" worktree add -b "$BRANCH" "$WT" origin/main
cd "$WT"
```
If the branch/worktree already exists from a prior run, reuse it (cd in) rather
than failing — but confirm it's based on current `origin/main`; if it's stale, tell
the user before continuing. From here, **all work happens in `$WT`** (the Bash tool
keeps the working directory between calls); use absolute paths when in doubt.
### 3. Reproduce / confirm — a hard gate
This is the highest-leverage step. Do not skip it.
- **Bug:** reproduce it concretely — ideally a **test that fails on current `main`**
(red), or a precise script/trace through the faulty path. Pin down the exact code
path and the invariant it violates.
- **If you cannot reproduce it, STOP and report** — do not patch. The issue may
already be fixed, be environment-specific, or be a misunderstanding. Shipping a
speculative fix for a non-reproducing bug is the most common way agents make
things worse.
- **Feature:** confirm the capability is actually missing, then map where it fits —
the owning package, patterns to follow, the data flow, the smallest set of files.
**Plan proportionally.** If you could describe the diff in one sentence (a typo, a
log line, a one-line guard), skip formal planning and make the change. Plan first
when the work is multi-file, unfamiliar, or architecturally uncertain — name the
files/interfaces involved, state what's out of scope, and end the plan with the
verification step. For anything large, confirm direction with the user before a big
build-out.
### 4. Implement
Make the change, scoped to this one concern (one concern per PR). Respect the hard
rules:
- **API:** additive-only within `/api/v1` — never rename/remove a response field,
change a field's type, or repurpose a status code. New behavior = new
fields/endpoints; expose capability endpoints for feature detection.
- **Migrations:** new DB changes are Goose SQL migrations via
`make migrate-create NAME=...` (timestamped). Never `goose fix`, never paired
`.up.sql`/`.down.sql`.
- **Maintainability:** extract shared logic instead of duplicating; change existing
code rather than bolting on local workarounds.
For a bug, the failing test from step 3 should now pass.
### 5. Verify — the primary correctness gate
Run the checks and make them genuinely pass. The exact commands and the worktree
quirks (`GOWORK=off`, stubbing `web/dist`, the v2 golangci invocation, the flaky
GPU tests) are in **[references/verification.md](references/verification.md)** —
read it first, because plain `go build ./...` / `make lint` fail inside
`.claude/worktrees`.
At minimum: Go build + the relevant package tests pass; if `web/` changed,
`pnpm run lint` + `pnpm run format:check` + build pass; `make verify-local-paths` is
clean. Run the **full relevant suite**, not just your new test — catch regressions
in code you didn't touch. Show the command output as evidence; don't assert success.
**Never reach green by weakening the checks.** Deleting/skipping/`xfail`-ing tests,
loosening assertions, lowering coverage thresholds, or relaxing lint/CI to make a
red check pass is a blocker, full stop — if a test genuinely must change, that needs
explicit human sign-off, not a quiet edit. If you can't make the checks pass on the
merits, stop and report.
### 6. Adversarial review (Codex)
Commit your work first (so the review sees exactly what will ship), then run a Codex
adversarial review of the branch against `main`. Full invocation, output handling,
the iteration cap, and the fallback are in
**[references/codex-review.md](references/codex-review.md)** — follow it exactly.
The loop, in short:
1. Commit the implementation on the branch.
2. Run the adversarial review (`--base main`, foreground/`--wait`, extended timeout).
3. For each finding: **first confirm it's real and grounded in the actual code**
(reviewers over-flag — don't fix phantom nits). Then **fix** material findings
that affect correctness or the stated requirements (and re-run the review), or
**rebut** them with a defensible, written reason. Note non-material findings as
optional.
4. **Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
sharply after ~2–3 attempts, so thrashing won't help — if material findings still
stand after the cap, stop and escalate to the user instead of looping.
Do not open the PR while a material, un-rebutted adversarial finding stands.
### 7. Commit, push, open the PR
Conventional Commit subjects scoped to the domain, e.g.
`fix(playback): guard against nil session on reconnect`. End commit messages with:
```
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
```
Push and open a **ready** (non-draft) PR against `main`:
```bash
git push -u origin "$BRANCH"
gh pr create --base main --head "$BRANCH" --title "<conventional subject>" --body "$(cat <<'EOF'
## Problem
<what's broken / missing, grounded in the issue — and how you reproduced it>
## Approach
<what you changed and *why this approach* — the tradeoff, not a file list>
## Verification
<commands run and their result; the failing→passing test; manual checks>
## Adversarial review
<clean, or the material findings and how each was fixed/rebutted>
## Risks & follow-up
<rollout/migration/compatibility risks; any silo-android / silo-apple
follow-up; anything deferred>
Closes #<N>
<add "Part of #<epic>" if the issue serves a capability epic/sub-issue>
## AI-use disclosure
Implemented by Claude Code (issue-to-pr skill) with human oversight.
EOF
)"
```
End the PR body with:
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
For **UI changes**, attach before/after screenshots or a recording (the repo
requires this); if you can't capture them, explicitly flag in the PR that they're
still needed.
### 8. Report back
Give the user the PR URL, a 2–3 line summary, the verification result, the
adversarial-review outcome, any flagged risks or required client follow-up, and the
worktree path so they can inspect it.
## Long or multi-iteration runs
Performance degrades on long sessions, and context compaction can silently drop
constraints. So: keep a short progress note and a "failed approaches" list in the
scratchpad so you don't repeat dead ends; and after any compaction, re-read CLAUDE.md
and re-assert the hard guardrails (additive-only API, migration rules, no weakening
of tests) before continuing.
## Stop and ask
Pause and ask the user (don't push a PR) when:
- The issue is ambiguous, underspecified, or needs a product/UX decision.
- **The bug doesn't reproduce** on current `main`.
- The fix would require a non-additive API change, or a risky/irreversible migration.
- The change balloons into something large or architectural — propose a plan first.
- Checks can't be made to pass on the merits, or material adversarial findings remain
after the iteration cap.
- A test/CI change would be needed to "pass" — get sign-off, don't do it silently.
- The right fix clearly belongs in a sibling repo (`silo-android`, `silo-apple`, a
plugin/SDK repo) — see CLAUDE.md's multi-repo guidance.
In these cases, summarize what you found and what you'd do, and let the user decide.
## Notes
- Authorized to open ready PRs autonomously once checks pass and the adversarial
review is clean — that's the durable instruction from setup. Not authorized to
merge, force-push shared branches, or close issues by hand.
- Never invent a fix you can't verify or explain. An honest "here's what's uncertain"
beats a confident wrong PR.
@@ -0,0 +1,88 @@
# Codex adversarial review
The `/codex:adversarial-review` command is marked `disable-model-invocation: true`,
so you cannot trigger it as a tool/skill. Call the same underlying runtime
directly via Bash: the `codex-companion.mjs` helper, subcommand
`adversarial-review`. It challenges the *approach and design*, not just defects —
it tries to find the strongest reasons the change should not ship.
## 1. Locate the companion script
```bash
CODEX_COMPANION="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs}"
if [ -z "$CODEX_COMPANION" ] || [ ! -f "$CODEX_COMPANION" ]; then
CODEX_COMPANION="$(find "$HOME/.claude/plugins/marketplaces" "$HOME/.claude/plugins/cache" \
-name codex-companion.mjs 2>/dev/null | head -n1)"
fi
echo "$CODEX_COMPANION"
```
If nothing is found, the Codex plugin isn't installed/set up. Don't silently skip
the review — tell the user, point them at `/codex:setup`, and ask whether to
proceed without it (or fall back to a `feature-dev:code-reviewer` agent pass).
The user asked specifically for the Codex adversarial review, so treat its absence
as a real blocker, not a detail.
## 2. Commit first, then review the branch against main
Review what will actually ship. Commit the implementation on the branch, then
diff against `main`:
```bash
git add -A && git commit -m "<conventional subject>"
```
Run it in the **foreground** so results come back this turn, and set an extended
Bash timeout (reviews can take several minutes) — pass `timeout: 600000` on the
Bash call:
```bash
node "$CODEX_COMPANION" adversarial-review --wait --base main "optional focus text"
```
- `--wait` runs foreground and prints the result to stdout.
- `--base main` reviews the whole branch diff (`main...HEAD`).
- Append focus text to steer it (e.g. the specific failure mode, the risky
concurrency path, the migration). Keep the adversarial framing — don't soften it.
- Do **not** use `--background` here; you need the findings in-hand to act on them.
## 3. Interpret the output
The review returns a verdict plus findings:
- **`approve` / no material findings** → green light for the PR.
- **`needs-attention`** → there is at least one material risk. Each finding names
a file, a line range, an impact, and a concrete recommendation.
LLM reviewers systematically **over-flag** — they report gaps even when the work is
sound. So for every finding, in order:
1. **Confirm it's real.** Open the cited file/lines and verify the finding is
grounded in the actual code and actually reachable. Drop findings that reference
code that doesn't exist, lines outside the diff, or behavior the code doesn't have
— don't fix phantom nits.
2. For a confirmed finding, do one of two things — never silently ignore it:
- **Fix it** if it affects correctness or the issue's stated requirements, then
re-run the review (go back to step 2 after committing the fix); or
- **Rebut it** with a defensible, written reason it doesn't apply, and capture
that reasoning in the PR's "Adversarial review" section.
3. Treat style/naming/speculative/low-value findings as **optional** — don't
gold-plate. The adversarial prompt is told not to emit these, but if any slip
through, note and move on; don't add abstraction or defensive code for cases that
can't happen.
## 4. Loop — but cap it
Repeat implement → verify → commit → review until the review is `approve` or every
material finding is fixed or defensibly rebutted.
**Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
sharply after ~2–3 attempts — if material findings still stand after the cap, stop
and escalate to the user with a summary rather than thrashing or shipping over a
standing objection. Only open the PR once the review is clean within the cap.
## 5. Record it in the PR
Summarize the outcome in the PR body's **Adversarial review** section: either
"clean — no material findings" or a terse list of what was raised and how each was
fixed or rebutted. This gives reviewers the adversary's perspective for free.
@@ -0,0 +1,77 @@
# Verification inside a worktree
Commands assume the repository root is the cwd (the new worktree, `$WT`).
## Why the obvious commands fail here
A `.claude/worktrees/*` checkout lives under the parent Silo workspace, whose
`go.work` only lists the primary `./silo-server`, not worktree checkouts. So:
- `go env GOWORK` resolves to the parent workspace from anywhere under `SiloServer/`.
- Plain `go build ./...` / `go test ./...` fail with
*"directory prefix . does not contain modules listed in go.work"*.
- `go build ./...` also needs `web/dist` to exist (the embed directive).
- `golangci-lint` is not installed locally.
## Go
Prefix every Go command with `GOWORK=off`.
```bash
# Build the parts that matter without needing web/dist:
GOWORK=off go build ./internal/... ./cmd/...
# Run the tests for the package(s) you touched (fast, targeted):
GOWORK=off go test ./internal/<pkg>/...
# If you need a full ./... build, stub the embed target first:
mkdir -p web/dist && touch web/dist/.gitkeep
GOWORK=off go build ./...
```
Lint Go without installing golangci-lint — must be the **v2** module path (the
repo's `.golangci` config rejects v1):
```bash
GOWORK=off go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run ./internal/...
```
**Known flake, not a real failure:** `internal/playback` GPU-probe tests
(`TestResolveHWAccel*`, the NVENC smoke test) can fail with `signal: killed`
under parallel load on macOS, but pass in isolation. If they're the only
failures, re-run them alone before treating them as real:
```bash
GOWORK=off go test ./internal/playback/ -run TestResolveHWAccel -count=1
```
## Frontend (only if `web/` changed)
```bash
cd web
pnpm install # if deps changed or node_modules is absent
pnpm run lint
pnpm run format:check
pnpm run build # catches type/build errors before the PR
cd ..
```
The repo requires `pnpm run lint` and `pnpm run format:check` to be clean before
a merge request.
## Repo-wide
```bash
make verify-local-paths # must be clean; CLAUDE.md forbids absolute paths in docs/specs
```
## Migrations
If the change needs schema work, create a timestamped Goose migration — never
hand-number, never `goose fix`, never paired up/down files:
```bash
make migrate-create NAME=add_thing
make migrate-validate
make migrate-status
```
+5 -1
View File
@@ -50,7 +50,11 @@ docs/inspiration
.agents/
/.claude/
# Ignore everything in .claude/ except committed, shared skills.
/.claude/*
!/.claude/skills/
/.claude/skills/*
!/.claude/skills/issue-to-pr/
.cursor/
.superpowers/
.codex/
+13
View File
@@ -95,6 +95,7 @@ import (
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/userstore/pgstore"
"github.com/Silo-Server/silo-server/internal/watchlist"
"github.com/Silo-Server/silo-server/internal/watchstate"
"github.com/Silo-Server/silo-server/internal/watchsync"
watchmdblist "github.com/Silo-Server/silo-server/internal/watchsync/providers/mdblist"
@@ -1449,6 +1450,15 @@ func main() {
}
})
}
// Auto-remove fully-watched items from the watchlist (standalone behavior,
// default-on per profile), propagating removals to connected providers.
if itemRepo != nil && episodeRepo != nil && userStoreProvider != nil {
maintainer := watchlist.NewMaintainer(userStoreProvider, itemRepo, episodeRepo)
if watchProviderService != nil {
maintainer.WithListEventDispatcher(watchProviderService)
}
deps.WatchCompletionObserver = maintainer
}
deps.SessionMgr = sessionMgr
deps.PlaybackRealtimeHub = playback.NewRealtimeHub()
if chapterThumbService != nil && deps.S3Public != nil {
@@ -1881,6 +1891,8 @@ func main() {
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
Recs: recommendations.NewRepo(deps.DB),
Detail: absDetailSvc,
SessionMgr: sessionMgr,
SessionSyncer: deps.SessionSyncer,
}
absH := audiobooksService.BuildABSHandler(absHDeps)
deps.ABSHandler = absH
@@ -2170,6 +2182,7 @@ func main() {
compatDeps.FolderRepo = folderRepo
compatDeps.SessionMgr = sessionMgr
compatDeps.UserStoreProvider = userStoreProvider
compatDeps.WatchCompletionObserver = deps.WatchCompletionObserver
compatDeps.SettingsRepo = settingsRepo
compatDeps.PersonRepo = personRepo
+11 -2
View File
@@ -130,9 +130,18 @@ func (s stubStore) ListFavorites(context.Context, string, int, int) ([]userstore
func (s stubStore) ListFavoritesByMediaItems(context.Context, string, []string) (map[string]bool, error) {
panic("unused")
}
func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") }
func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") }
func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") }
func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") }
func (s stubStore) AddToWatchlistAt(context.Context, string, string, time.Time) error {
panic("unused")
}
func (s stubStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
return true, nil
}
func (s stubStore) RemoveFromWatchlist(context.Context, string, string) error { panic("unused") }
func (s stubStore) ReplaceWatchlistOrder(context.Context, string, []string) error {
panic("unused")
}
func (s stubStore) ListWatchlist(context.Context, string, int, int) ([]userstore.WatchlistEntry, error) {
panic("unused")
}
+16 -13
View File
@@ -25,8 +25,8 @@ type personalDataItemRepository interface {
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
}
type LocalFavoriteEventDispatcher interface {
HandleLocalFavoriteEvent(ctx context.Context, event watchsync.LocalFavoriteEvent) error
type LocalListEventDispatcher interface {
HandleLocalListEvent(ctx context.Context, event watchsync.LocalListEvent) error
}
// PersonalDataHandler handles favorites, watchlist, and history endpoints.
@@ -37,7 +37,7 @@ type PersonalDataHandler struct {
seasonRepo *catalog.SeasonRepository
detailSvc *catalog.DetailService
EventsHub *evt.Hub
localFavoriteDispatcher LocalFavoriteEventDispatcher
localListDispatcher LocalListEventDispatcher
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
ebookProgressStore EbookReaderProgressLister
@@ -78,8 +78,8 @@ func (h *PersonalDataHandler) SetSeasonRepo(repo *catalog.SeasonRepository) {
h.seasonRepo = repo
}
func (h *PersonalDataHandler) SetLocalFavoriteEventDispatcher(dispatcher LocalFavoriteEventDispatcher) {
h.localFavoriteDispatcher = dispatcher
func (h *PersonalDataHandler) SetLocalListEventDispatcher(dispatcher LocalListEventDispatcher) {
h.localListDispatcher = dispatcher
}
// --- Response types ---
@@ -219,7 +219,7 @@ func (h *PersonalDataHandler) HandleAddFavorite(w http.ResponseWriter, r *http.R
return
}
h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventAdded, userID, profileID, itemID)
h.dispatchLocalListEvent(r.Context(), watchsync.ListKindFavorites, watchsync.ListChangeAdded, userID, profileID, itemID)
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{
IsFavorite: boolPtr(true),
@@ -249,7 +249,7 @@ func (h *PersonalDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *htt
return
}
h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventRemoved, userID, profileID, itemID)
h.dispatchLocalListEvent(r.Context(), watchsync.ListKindFavorites, watchsync.ListChangeRemoved, userID, profileID, itemID)
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{
IsFavorite: boolPtr(false),
@@ -257,15 +257,15 @@ func (h *PersonalDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *htt
w.WriteHeader(http.StatusNoContent)
}
func (h *PersonalDataHandler) dispatchLocalFavoriteEvent(ctx context.Context, kind watchsync.LocalFavoriteEventKind, userID int, profileID string, itemID string) {
if h == nil || h.localFavoriteDispatcher == nil || h.itemRepo == nil {
func (h *PersonalDataHandler) dispatchLocalListEvent(ctx context.Context, list watchsync.ListKind, change watchsync.ListChange, userID int, profileID string, itemID string) {
if h == nil || h.localListDispatcher == nil || h.itemRepo == nil {
return
}
item, err := h.itemRepo.GetByID(ctx, itemID)
if err != nil || item == nil {
return
}
favorite := watchsync.LocalFavorite{
listItem := watchsync.LocalFavorite{
MediaItemID: item.ContentID,
Kind: item.Type,
Title: item.Title,
@@ -275,11 +275,12 @@ func (h *PersonalDataHandler) dispatchLocalFavoriteEvent(ctx context.Context, ki
TVDBID: item.TvdbID,
FavoritedAt: time.Now().UTC(),
}
_ = h.localFavoriteDispatcher.HandleLocalFavoriteEvent(ctx, watchsync.LocalFavoriteEvent{
Kind: kind,
_ = h.localListDispatcher.HandleLocalListEvent(ctx, watchsync.LocalListEvent{
List: list,
Change: change,
UserID: userID,
ProfileID: profileID,
Favorites: []watchsync.LocalFavorite{favorite},
Items: []watchsync.LocalFavorite{listItem},
})
}
@@ -374,6 +375,7 @@ func (h *PersonalDataHandler) HandleAddToWatchlist(w http.ResponseWriter, r *htt
return
}
h.dispatchLocalListEvent(r.Context(), watchsync.ListKindWatchlist, watchsync.ListChangeAdded, userID, profileID, itemID)
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{
InWatchlist: boolPtr(true),
@@ -403,6 +405,7 @@ func (h *PersonalDataHandler) HandleRemoveFromWatchlist(w http.ResponseWriter, r
return
}
h.dispatchLocalListEvent(r.Context(), watchsync.ListKindWatchlist, watchsync.ListChangeRemoved, userID, profileID, itemID)
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{
InWatchlist: boolPtr(false),
+9
View File
@@ -107,6 +107,15 @@ func NewItemsHandler(
}
}
// SetCompletionObserver wires an optional observer notified when a watch
// completes (used to auto-remove fully-watched items from the watchlist).
func (h *ItemsHandler) SetCompletionObserver(obs watchstate.CompletionObserver) {
if h == nil || h.watchState == nil || obs == nil {
return
}
h.watchState.WithCompletionObserver(obs)
}
// SetProfileStaler configures an optional staleness trigger for taste profiles.
func (h *ItemsHandler) SetProfileStaler(ps ProfileStaler) {
h.profileStaler = ps
@@ -1566,6 +1566,7 @@ func (h *LibraryCollectionHandler) HandleSyncAdminCollection(w http.ResponseWrit
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
slog.Error("collection sync failed", "collection_id", collectionID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync collection")
return
}
+4 -1
View File
@@ -103,6 +103,7 @@ type PlaybackHandler struct {
StoreProvider userstore.UserStoreProvider // optional; enables progress/history persistence
WatchScrobbler PlaybackWatchScrobbler
StableIdentityResolver *watchstate.StableIdentityResolver
CompletionObserver watchstate.CompletionObserver // optional; auto-removes watched items from the watchlist
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
AdminStore PlaybackAdminStore // optional; enables admin playback history/live session cleanup
@@ -826,7 +827,9 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
duration := float64(file.Duration)
thresholds := h.playbackThresholds(ctx)
watchSvc := watchstate.NewService(h.StoreProvider).WithStableIdentityResolver(h.StableIdentityResolver)
watchSvc := watchstate.NewService(h.StoreProvider).
WithStableIdentityResolver(h.StableIdentityResolver).
WithCompletionObserver(h.CompletionObserver)
stoppedAt := time.Now().UTC()
result, err := watchSvc.RecordPlaybackStop(ctx, session.UserID, session.ProfileID, targetID, duration, session.Position, stoppedAt, userstore.VersionHints{
FileID: file.ID,
+7 -2
View File
@@ -143,6 +143,7 @@ type Dependencies struct {
MarkerContributionStore *markers.ContributionStore
MarkerContributionService *markers.ContributionService
WatchProviderService handlers.WatchProviderService
WatchCompletionObserver watchstate.CompletionObserver
PluginService *plugins.Service
PluginHTTPProxy *plugins.HTTPProxy
PluginUserConfig *plugins.UserConfigStore
@@ -523,6 +524,9 @@ func NewRouter(deps Dependencies) chi.Router {
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
}
if deps.WatchCompletionObserver != nil {
itemsHandler.SetCompletionObserver(deps.WatchCompletionObserver)
}
if ebookProgressStore != nil {
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
@@ -638,8 +642,8 @@ func NewRouter(deps Dependencies) chi.Router {
personalDataHandler.SetEpisodeRepo(episodeRepo)
personalDataHandler.SetSeasonRepo(seasonRepo)
personalDataHandler.EventsHub = deps.EventsHub
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalFavoriteEventDispatcher); ok {
personalDataHandler.SetLocalFavoriteEventDispatcher(dispatcher)
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalListEventDispatcher); ok {
personalDataHandler.SetLocalListEventDispatcher(dispatcher)
}
progressHandler = handlers.NewProgressHandler(deps.UserStoreProvider)
progressHandler.EventsHub = deps.EventsHub
@@ -734,6 +738,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.StoreProvider = deps.UserStoreProvider
}
playbackHandler.StableIdentityResolver = watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo)
playbackHandler.CompletionObserver = deps.WatchCompletionObserver
if scrobbler, ok := deps.WatchProviderService.(handlers.PlaybackWatchScrobbler); ok {
playbackHandler.WatchScrobbler = scrobbler
}
+3
View File
@@ -172,6 +172,9 @@ func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session expired", http.StatusGone)
return
}
if h.beginNativePlaybackTransport(sid) {
defer h.endNativePlaybackTransport(sid)
}
access, err := h.accessFilterForAuth(r.Context(), ctxAuth{UserID: sess.UserID, ProfileID: sess.ProfileID})
if err != nil {
+8
View File
@@ -258,6 +258,14 @@ type Dependencies struct {
// SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil;
// the route is only registered when a non-nil value is supplied.
SocketIO SocketIOServer
// NativeSessions mirrors ABS playback into Silo's native playback session
// manager so shared live-session views, limits, and stale-session cleanup
// see Audiobookshelf-compatible clients. May be nil; ABS playback still
// functions, but admin live-session visibility is unavailable.
NativeSessions PlaybackSessionManager
// NativeSessionSyncer flushes native session-manager state into the shared
// admin live-session table after ABS play/sync/close events.
NativeSessionSyncer PlaybackSessionSyncer
// CoverResolver translates a raw silo poster path (e.g.
// "local/audiobooks/.../original.webp") into a fully-qualified URL
// the ABS client can fetch. Optional; when nil, /api/items/{id}/cover
+226
View File
@@ -0,0 +1,226 @@
package abs
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
// PlaybackSessionManager is the native Silo playback-session surface ABS needs
// to make compatibility playback visible to live admin/session monitoring.
type PlaybackSessionManager interface {
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
UpdateProgress(sessionID string, position float64, isPaused bool) error
UpdateStreamState(sessionID string, state playback.SessionStreamState) error
BeginTransport(sessionID string) error
EndTransport(sessionID string) error
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
StopSession(sessionID string) error
}
// PlaybackSessionSyncer flushes the local native-session snapshot into the
// shared admin live-session table.
type PlaybackSessionSyncer interface {
SyncNow(ctx context.Context) error
}
func (h *Handler) startNativePlaybackSession(
r *http.Request,
a ctxAuth,
files []*models.MediaFile,
startPosition float64,
) (*playback.Session, error) {
if h == nil || h.deps.NativeSessions == nil || len(files) == 0 {
return nil, nil
}
userID, err := strconv.Atoi(a.UserID)
if err != nil {
return nil, fmt.Errorf("invalid ABS user id %q: %w", a.UserID, err)
}
file := files[0]
sessionCtx := playback.WithClientInfo(r.Context(), absPlaybackClientInfoFromRequest(r))
session, err := h.deps.NativeSessions.StartSessionWithFilesContext(
sessionCtx,
userID,
a.ProfileID,
file.ID,
file.ID,
playback.PlayDirect,
false,
)
if err != nil {
return nil, err
}
if err := h.deps.NativeSessions.SetProgressPersistenceDisabled(session.ID, true); err != nil {
slog.Warn("abs play: disable native progress persistence failed",
"session_id", session.ID, "error", err)
} else {
session.DisableProgressPersistence = true
}
if startPosition > 0 {
if err := h.deps.NativeSessions.UpdateProgress(session.ID, startPosition, false); err != nil {
slog.Warn("abs play: seed native session progress failed",
"session_id", session.ID, "position", startPosition, "error", err)
} else {
session.Position = startPosition
session.IsPaused = false
}
}
streamBitrateKbps := 0
if file.Bitrate > 0 {
streamBitrateKbps = file.Bitrate
}
if err := h.deps.NativeSessions.UpdateStreamState(session.ID, playback.SessionStreamState{
PlayMethod: playback.PlayDirect,
BasePlayMethod: playback.PlayDirect,
ClientIP: requestClientIP(r),
ClientName: session.ClientName,
ClientVersion: session.ClientVersion,
ClientUserAgent: session.ClientUserAgent,
StreamBitrateKbps: streamBitrateKbps,
}); err != nil {
slog.Warn("abs play: update native session stream state failed",
"session_id", session.ID, "error", err)
} else {
session.PlayMethod = playback.PlayDirect
session.BasePlayMethod = playback.PlayDirect
session.ClientIP = requestClientIP(r)
session.StreamBitrateKbps = streamBitrateKbps
}
h.syncNativeSessionsNow(r.Context(), "abs_start")
return session, nil
}
func (h *Handler) updateNativePlaybackProgress(ctx context.Context, sessionID string, position float64) {
if h == nil || h.deps.NativeSessions == nil || sessionID == "" {
return
}
if err := h.deps.NativeSessions.UpdateProgress(sessionID, position, false); err != nil {
if !errors.Is(err, playback.ErrSessionNotFound) {
slog.Warn("abs session sync: update native session progress failed",
"session_id", sessionID, "position", position, "error", err)
}
return
}
h.syncNativeSessionsNow(ctx, "abs_progress")
}
func (h *Handler) stopNativePlaybackSession(ctx context.Context, sessionID string) {
if h == nil || h.deps.NativeSessions == nil || sessionID == "" {
return
}
if err := h.deps.NativeSessions.StopSession(sessionID); err != nil {
if !errors.Is(err, playback.ErrSessionNotFound) {
slog.Warn("abs session close: stop native session failed",
"session_id", sessionID, "error", err)
}
return
}
h.syncNativeSessionsNow(ctx, "abs_close")
}
func (h *Handler) beginNativePlaybackTransport(sessionID string) bool {
if h == nil || h.deps.NativeSessions == nil || sessionID == "" {
return false
}
if err := h.deps.NativeSessions.BeginTransport(sessionID); err != nil {
if !errors.Is(err, playback.ErrSessionNotFound) {
slog.Warn("abs public track: begin native transport failed",
"session_id", sessionID, "error", err)
}
return false
}
return true
}
func (h *Handler) endNativePlaybackTransport(sessionID string) {
if h == nil || h.deps.NativeSessions == nil || sessionID == "" {
return
}
if err := h.deps.NativeSessions.EndTransport(sessionID); err != nil && !errors.Is(err, playback.ErrSessionNotFound) {
slog.Warn("abs public track: end native transport failed",
"session_id", sessionID, "error", err)
}
}
func (h *Handler) syncNativeSessionsNow(ctx context.Context, reason string) {
if h == nil || h.deps.NativeSessionSyncer == nil {
return
}
if err := h.deps.NativeSessionSyncer.SyncNow(ctx); err != nil {
slog.Error("abs: failed to sync native playback sessions", "reason", reason, "error", err)
}
}
func writeNativePlaybackStartError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, playback.ErrTooManyStreams):
http.Error(w, "too many concurrent streams", http.StatusTooManyRequests)
case errors.Is(err, playback.ErrTooManyTranscodes):
http.Error(w, "too many concurrent transcodes", http.StatusTooManyRequests)
default:
slog.Error("abs play: start native playback session failed", "error", err)
http.Error(w, "failed to start playback session", http.StatusInternalServerError)
}
}
func absPlaybackClientInfoFromRequest(r *http.Request) playback.ClientInfo {
if r == nil {
return playback.ClientInfo{Name: "Audiobookshelf"}
}
name := firstHeaderValue(r,
"X-Silo-Client",
"X-Client-Name",
"X-Device-Name",
"X-Emby-Client",
)
if name == "" {
name = "Audiobookshelf"
}
return playback.ClientInfo{
Name: name,
Version: firstHeaderValue(r,
"X-Silo-Client-Version",
"X-Client-Version",
"X-Emby-Client-Version",
),
UserAgent: r.UserAgent(),
}
}
func firstHeaderValue(r *http.Request, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(r.Header.Get(name)); value != "" {
return value
}
}
return ""
}
func requestClientIP(r *http.Request) string {
if r == nil {
return ""
}
if ip := clientip.FromContext(r.Context()); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
@@ -0,0 +1,229 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
type playStartMediaStore struct {
noopMediaStore
item *models.MediaItem
files []*models.MediaFile
}
func (s *playStartMediaStore) GetAudiobookByID(_ context.Context, id string, _ catalog.AccessFilter) (*models.MediaItem, error) {
if s.item != nil && s.item.ContentID == id {
return s.item, nil
}
return nil, nil
}
func (s *playStartMediaStore) GetMediaFiles(_ context.Context, contentID string, _ catalog.AccessFilter) ([]*models.MediaFile, error) {
if s.item != nil && s.item.ContentID == contentID {
return s.files, nil
}
return nil, nil
}
type recordingPlaybackSessionSyncer struct {
calls int
}
func (s *recordingPlaybackSessionSyncer) SyncNow(context.Context) error {
s.calls++
return nil
}
func TestHandlePlayStartCreatesNativePlaybackSession(t *testing.T) {
now := time.Now()
media := &playStartMediaStore{
item: &models.MediaItem{
ContentID: "book-1",
Type: "audiobook",
Title: "Native Session Book",
UpdatedAt: now,
AddedAt: &now,
},
files: []*models.MediaFile{{
ID: 42,
ContentID: "book-1",
FilePath: "/tmp/book.mp3",
FileSize: 1024,
Duration: 3600,
Bitrate: 128,
CodecAudio: "mp3",
}},
}
absSessions := &fakePlaybackSessionStore{}
nativeSessions := playback.NewSessionManager(0, 0)
syncer := &recordingPlaybackSessionSyncer{}
progress := &fakeProgressStore{row: &ProgressRow{
UserID: "1",
ProfileID: "profile-1",
ContentID: "book-1",
CurrentSeconds: 123.5,
DurationSeconds: 3600,
UpdatedAt: now,
}}
h := New(Dependencies{
MediaStore: media,
ProgressStore: progress,
PlaybackSessionStore: absSessions,
NativeSessions: nativeSessions,
NativeSessionSyncer: syncer,
})
rec := dispatchABSWithParams(
http.MethodPost,
"/api/items/book-1/play",
map[string]string{"libraryItemId": "book-1"},
nil,
"1",
"profile-1",
h.handlePlayStart,
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
sessionID, _ := body["id"].(string)
if sessionID == "" {
t.Fatalf("response id is empty: %#v", body["id"])
}
native, err := nativeSessions.GetSession(sessionID)
if err != nil {
t.Fatalf("native session %q missing: %v", sessionID, err)
}
if native.MediaFileID != 42 || native.RequestedMediaFileID != 42 {
t.Fatalf("native file ids = (%d, %d), want (42, 42)", native.MediaFileID, native.RequestedMediaFileID)
}
if !native.DisableProgressPersistence {
t.Fatalf("native session should disable progress persistence")
}
if native.Position != 123.5 {
t.Fatalf("native position = %v, want 123.5", native.Position)
}
if syncer.calls == 0 {
t.Fatalf("native session syncer was not called")
}
absSession, err := absSessions.GetPlaybackSession(context.Background(), sessionID)
if err != nil {
t.Fatalf("ABS session %q missing: %v", sessionID, err)
}
if absSession.CurrentPositionSeconds != 123.5 {
t.Fatalf("ABS session position = %v, want 123.5", absSession.CurrentPositionSeconds)
}
tracks, _ := body["audioTracks"].([]any)
if len(tracks) != 1 {
t.Fatalf("audioTracks length = %d, want 1", len(tracks))
}
track, _ := tracks[0].(map[string]any)
if got, _ := track["contentUrl"].(string); got == "" || !strings.Contains(got, "/abs/public/session/"+sessionID+"/track/1") {
t.Fatalf("contentUrl = %q, want session-scoped URL", got)
}
}
func TestHandleSessionSyncUpdatesNativePlaybackSession(t *testing.T) {
media := &playStartMediaStore{
item: &models.MediaItem{ContentID: "book-1", Type: "audiobook", Title: "Book", UpdatedAt: time.Now()},
}
absSessions := &fakePlaybackSessionStore{}
nativeSessions := playback.NewSessionManager(0, 0)
native, err := nativeSessions.StartSessionWithFilesContext(context.Background(), 1, "profile-1", 42, 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("start native session: %v", err)
}
_ = absSessions.InsertPlaybackSession(context.Background(), ABSPlaybackSession{
ID: native.ID,
UserID: "1",
ProfileID: "profile-1",
ContentID: "book-1",
})
syncer := &recordingPlaybackSessionSyncer{}
h := New(Dependencies{
MediaStore: media,
ProgressStore: &fakeProgressStore{},
PlaybackSessionStore: absSessions,
NativeSessions: nativeSessions,
NativeSessionSyncer: syncer,
})
rec := dispatchABSWithParams(
http.MethodPatch,
"/api/session/"+native.ID,
map[string]string{"sid": native.ID},
[]byte(`{"currentTime":55.25,"timeListening":10}`),
"1",
"profile-1",
h.handleSessionSync,
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
updated, err := nativeSessions.GetSession(native.ID)
if err != nil {
t.Fatalf("native session missing: %v", err)
}
if updated.Position != 55.25 {
t.Fatalf("native position = %v, want 55.25", updated.Position)
}
if updated.IsPaused {
t.Fatalf("native session should be marked playing")
}
if syncer.calls == 0 {
t.Fatalf("native session syncer was not called")
}
}
func TestHandleSessionCloseStopsNativePlaybackSession(t *testing.T) {
absSessions := &fakePlaybackSessionStore{}
nativeSessions := playback.NewSessionManager(0, 0)
native, err := nativeSessions.StartSessionWithFilesContext(context.Background(), 1, "profile-1", 42, 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("start native session: %v", err)
}
_ = absSessions.InsertPlaybackSession(context.Background(), ABSPlaybackSession{
ID: native.ID,
UserID: "1",
ProfileID: "profile-1",
ContentID: "book-1",
})
syncer := &recordingPlaybackSessionSyncer{}
h := New(Dependencies{
MediaStore: noopMediaStore{},
PlaybackSessionStore: absSessions,
NativeSessions: nativeSessions,
NativeSessionSyncer: syncer,
})
rec := dispatchABSWithParams(
http.MethodPost,
"/api/session/"+native.ID+"/close",
map[string]string{"sid": native.ID},
nil,
"1",
"profile-1",
h.handleSessionClose,
)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
if _, err := nativeSessions.GetSession(native.ID); err == nil {
t.Fatalf("native session still exists after close")
}
if syncer.calls == 0 {
t.Fatalf("native session syncer was not called")
}
}
+28 -21
View File
@@ -59,21 +59,45 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) {
return
}
// currentTime seeds the audio element's initial position so cross-device
// resume works. Lookup is best-effort: any error returns position 0,
// which is always correct for a first listen.
//
// Note: we deliberately do NOT emit a "progress" (0.0-1.0) field on the
// play session response. The canonical continuum-plugin handler omits it
// too — "progress" belongs to /me/progress responses, not playbackSession.
// The spec's Phase 0 row mentioning "currentTime AND progress fields" was
// over-specified; matching the canonical wire shape is the load-bearing
// requirement.
var currentTime float64
currentTime, err = resolveResumeTime(r.Context(), h.deps.ProgressStore, a.UserID, a.ProfileID, contentID)
if err != nil {
slog.Debug("play: progress lookup failed", "user", a.UserID, "item", contentID, "err", err)
// currentTime is already 0 on error path; safe to continue.
}
// The {ino} parameter used by handleFileStream is a 0-based file index, but
// we want iOS clients to resolve it via the stable MD5 derivation. Emit inos
// that handleFileStream can reverse without a database lookup.
baseURL := h.absBaseURL(r)
sessionID := ulid.Make().String()
if nativeSession, err := h.startNativePlaybackSession(r, a, files, currentTime); err != nil {
writeNativePlaybackStartError(w, err)
return
} else if nativeSession != nil {
sessionID = nativeSession.ID
}
// Persist the session row so subsequent PATCH /session/{sid} heartbeats
// and POST /session/{sid}/close can find it. Without this, the session
// ID is returned to the client but every sync/close lookup 404s.
if h.deps.PlaybackSessionStore != nil {
sess := ABSPlaybackSession{
ID: sessionID,
UserID: a.UserID,
ProfileID: a.ProfileID,
ContentID: contentID,
ID: sessionID,
UserID: a.UserID,
ProfileID: a.ProfileID,
ContentID: contentID,
CurrentPositionSeconds: currentTime,
}
if len(files) > 0 {
fid := files[0].ID
@@ -111,23 +135,6 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) {
dateStr := now.UTC().Format("2006-01-02")
dayOfWeek := now.UTC().Weekday().String()
// currentTime seeds the audio element's initial position so cross-device
// resume works. Lookup is best-effort: any error returns position 0,
// which is always correct for a first listen.
//
// Note: we deliberately do NOT emit a "progress" (0.0-1.0) field on the
// play session response. The canonical continuum-plugin handler omits it
// too — "progress" belongs to /me/progress responses, not playbackSession.
// The spec's Phase 0 row mentioning "currentTime AND progress fields" was
// over-specified; matching the canonical wire shape is the load-bearing
// requirement.
var currentTime float64
currentTime, err = resolveResumeTime(r.Context(), h.deps.ProgressStore, a.UserID, a.ProfileID, contentID)
if err != nil {
slog.Debug("play: progress lookup failed", "user", a.UserID, "item", contentID, "err", err)
// currentTime is already 0 on error path; safe to continue.
}
playbackSession := map[string]any{
"id": sessionID,
"userId": a.UserID,
+2
View File
@@ -376,6 +376,7 @@ func (h *Handler) handleSessionSync(w http.ResponseWriter, r *http.Request) {
"session_id", sid, "content_id", sess.ContentID, "error", err)
}
}
h.updateNativePlaybackProgress(r.Context(), sid, p.CurrentTime)
// Realtime push to other connected clients.
h.publish(a.UserID, "user_item_progress_updated", map[string]any{
@@ -421,6 +422,7 @@ func (h *Handler) handleSessionClose(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.stopNativePlaybackSession(r.Context(), sid)
h.publish(a.UserID, "user_session_closed", map[string]any{
"id": sid,
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/audiobooks/abssocket"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/scanner"
@@ -54,7 +55,9 @@ type ABSHandlerDeps struct {
// Detail resolves audiobook poster S3 paths into fully-qualified URLs
// that ABS clients can fetch. Optional; when nil, /api/items/{id}/cover
// 404s rather than redirecting to an unreachable storage path.
Detail *catalog.DetailService
Detail *catalog.DetailService
SessionMgr *playback.SessionManager
SessionSyncer abs.PlaybackSessionSyncer
}
// absAuthAdapter is the narrow slice of internal/auth that BuildABSHandler
@@ -158,6 +161,8 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
SmartCollectionStore: smartCollectionStore,
RSSFeedStore: rssFeedStore,
SocketIO: socketServer,
NativeSessions: deps.SessionMgr,
NativeSessionSyncer: deps.SessionSyncer,
CoverResolver: func(ctx context.Context, path, variant string) string {
if deps.Detail == nil {
return ""
+3 -3
View File
@@ -90,7 +90,7 @@ func (r *ItemRepository) SetLocalPoster(ctx context.Context, contentID, posterPa
tag, err := r.pool.Exec(ctx, `
UPDATE media_items
SET poster_path = $2,
poster_thumbhash = NULLIF($3, ''),
poster_thumbhash = $3,
updated_at = NOW()
WHERE content_id = $1
AND (poster_path IS NULL OR poster_path = '' OR poster_path LIKE $4 || '%')
@@ -1607,7 +1607,7 @@ func (r *ItemRepository) UpdateArtworkIfSourceMatches(ctx context.Context, conte
UPDATE media_items
SET poster_path = $3,
poster_source_path = $2,
poster_thumbhash = NULLIF($4, ''),
poster_thumbhash = $4,
updated_at = NOW()
WHERE content_id = $1
AND poster_source_path = $2`
@@ -1617,7 +1617,7 @@ func (r *ItemRepository) UpdateArtworkIfSourceMatches(ctx context.Context, conte
UPDATE media_items
SET backdrop_path = $3,
backdrop_source_path = $2,
backdrop_thumbhash = NULLIF($4, ''),
backdrop_thumbhash = $4,
updated_at = NOW()
WHERE content_id = $1
AND backdrop_source_path = $2`
+16 -1
View File
@@ -732,14 +732,29 @@ func (r *LibraryCollectionRepository) ReplaceItems(ctx context.Context, collecti
return fmt.Errorf("clearing collection items: %w", err)
}
// Dedupe by media_item_id before inserting. Different source entries can
// resolve to the same library item (e.g. an IMDb and a TMDB entry pointing
// at the same local content_id, or one item present in several of the
// collection's bound folders), and library_collection_items has a composite
// PK (collection_id, media_item_id). Inserting the same media_item_id twice
// would raise a duplicate-key violation that fails the whole sync. First
// occurrence wins, preserving rank order. Position is renumbered over the
// surviving rows so it stays dense.
seen := make(map[string]struct{}, len(items))
position := 0
for _, item := range items {
if _, dup := seen[item.MediaItemID]; dup {
continue
}
seen[item.MediaItemID] = struct{}{}
if _, err := tx.Exec(ctx, `
INSERT INTO library_collection_items (
collection_id, media_item_id, position, source_rank
) VALUES ($1, $2, $3, $4)
`, collectionID, item.MediaItemID, item.Position, item.SourceRank); err != nil {
`, collectionID, item.MediaItemID, position, item.SourceRank); err != nil {
return fmt.Errorf("inserting collection item %s: %w", item.MediaItemID, err)
}
position++
}
if _, err := tx.Exec(ctx, "UPDATE library_collections SET updated_at = NOW() WHERE id = $1", collectionID); err != nil {
+15 -15
View File
@@ -339,7 +339,7 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
// Single batched library membership query covering EVERY candidate across
// all entries (preserves the libraryID filter from the legacy
// resolveMDBListEntry).
libraryMembers, err := s.libraryItems.GetItemsInFolder(ctx, candidateIDs, collection.LibraryID)
libraryMembers, err := s.libraryItems.GetItemsInFolders(ctx, candidateIDs, collection.LibraryIDs)
if err != nil {
return nil, err
}
@@ -355,7 +355,7 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
scannedEntries = index + 1
r, ok := resolvedByIndex[index]
if !ok {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
warnings = append(warnings, fmt.Sprintf("No match in libraries %v for %s", collection.LibraryIDs, entry.Title))
continue
}
var chosen string
@@ -366,7 +366,7 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
}
}
if chosen == "" {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
warnings = append(warnings, fmt.Sprintf("No match in libraries %v for %s", collection.LibraryIDs, entry.Title))
continue
}
matchedItems = append(matchedItems, LibraryCollectionItemInput{
@@ -474,7 +474,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
item, err := s.resolveTMDBEntry(ctx, collection.LibraryIDs, entry)
if err != nil {
return nil, err
}
@@ -488,7 +488,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
"tvdb_id", entry.TVDBID,
)
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
warnings = append(warnings, fmt.Sprintf("No match in libraries %v for %s", collection.LibraryIDs, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
@@ -638,7 +638,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
item, err := s.resolveTMDBEntry(ctx, collection.LibraryIDs, entry)
if err != nil {
return nil, err
}
@@ -650,7 +650,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
"imdb_id", entry.IMDbID,
)
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
warnings = append(warnings, fmt.Sprintf("No match in libraries %v for %s", collection.LibraryIDs, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
@@ -813,7 +813,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
item, err := s.resolveTMDBEntry(ctx, collection.LibraryIDs, entry)
if err != nil {
return nil, err
}
@@ -827,7 +827,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
"tvdb_id", entry.TVDBID,
)
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
warnings = append(warnings, fmt.Sprintf("No match in libraries %v for %s", collection.LibraryIDs, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
@@ -975,13 +975,13 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTraktEntry(ctx, collection.LibraryID, entry)
item, err := s.resolveTraktEntry(ctx, collection.LibraryIDs, entry)
if err != nil {
return nil, err
}
if item == nil {
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
warnings = append(warnings, fmt.Sprintf("No match in libraries %v for %s", collection.LibraryIDs, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
@@ -1071,7 +1071,7 @@ func (s *LibraryCollectionService) recordFailedCollectionSync(ctx context.Contex
// resolveTMDBEntry finds a media item in the library matching a TMDB preset entry.
// It tries TMDB ID, IMDb ID, and TVDB ID (for TV shows) to maximize match rate.
func (s *LibraryCollectionService) resolveTMDBEntry(ctx context.Context, libraryID int, entry TMDBCollectionEntry) (*models.MediaItem, error) {
func (s *LibraryCollectionService) resolveTMDBEntry(ctx context.Context, libraryIDs []int, entry TMDBCollectionEntry) (*models.MediaItem, error) {
itemType := "movie"
if entry.MediaType == "tv" {
itemType = "series"
@@ -1094,7 +1094,7 @@ func (s *LibraryCollectionService) resolveTMDBEntry(ctx context.Context, library
return nil, nil
}
membership, err := s.libraryItems.GetItemsInFolder(ctx, []string{item.ContentID}, libraryID)
membership, err := s.libraryItems.GetItemsInFolders(ctx, []string{item.ContentID}, libraryIDs)
if err != nil {
return nil, err
}
@@ -1106,7 +1106,7 @@ func (s *LibraryCollectionService) resolveTMDBEntry(ctx context.Context, library
}
// resolveTraktEntry finds a local item matching a Trakt discovery entry.
func (s *LibraryCollectionService) resolveTraktEntry(ctx context.Context, libraryID int, entry TraktCollectionEntry) (*models.MediaItem, error) {
func (s *LibraryCollectionService) resolveTraktEntry(ctx context.Context, libraryIDs []int, entry TraktCollectionEntry) (*models.MediaItem, error) {
itemType := "movie"
if entry.MediaType == "tv" {
itemType = "series"
@@ -1130,7 +1130,7 @@ func (s *LibraryCollectionService) resolveTraktEntry(ctx context.Context, librar
return nil, nil
}
membership, err := s.libraryItems.GetItemsInFolder(ctx, candidates, libraryID)
membership, err := s.libraryItems.GetItemsInFolders(ctx, candidates, libraryIDs)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -115,8 +115,8 @@ const orphanedMediaItemSafetyConditions = `NOT EXISTS (
WHERE uwl.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_provider_favorite_items wpfi
WHERE wpfi.media_item_id = mi.content_id
SELECT 1 FROM public.watch_provider_list_items wpli
WHERE wpli.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_provider_history_exports wphe
+2
View File
@@ -24,6 +24,7 @@ func TestOrphanedProvisionalPredicatePreservesDurableMediaItemReferences(t *test
"public.user_personal_collection_items upci WHERE upci.media_item_id = mi.content_id",
"public.user_series_playback_preferences uspp WHERE uspp.series_id = mi.content_id",
"public.user_subtitle_preferences usp WHERE usp.series_id = mi.content_id",
"public.watch_provider_list_items wpli WHERE wpli.media_item_id = mi.content_id",
} {
if !strings.Contains(predicate, normalizePredicateSQL(want)) {
t.Fatalf("cleanup predicate missing durable reference guard %q", want)
@@ -33,6 +34,7 @@ func TestOrphanedProvisionalPredicatePreservesDurableMediaItemReferences(t *test
"abs_collection_items",
"abs_playlist_items",
"abs_playlists",
"watch_provider_favorite_items",
} {
if strings.Contains(predicate, droppedTable) {
t.Fatalf("cleanup predicate must not reference dropped legacy table %q", droppedTable)
+11 -8
View File
@@ -258,7 +258,7 @@ func (i *CatalogSearchIndexer) Rebuild(ctx context.Context, progress SearchIndex
if err := client.WaitTask(ctx, taskID); err != nil {
return stats, err
}
taskID, err = client.UpdateSettings(ctx, buildIndexUID, catalogSearchMeilisearchSettings(settings.Embedder))
taskID, err = client.UpdateSettings(ctx, buildIndexUID, catalogSearchMeilisearchSettings(settings.Embedder, settings.SemanticEnabled))
if err != nil {
return stats, err
}
@@ -438,12 +438,8 @@ func (i *CatalogSearchIndexer) CheckConnection(ctx context.Context, settings Cat
return client.Health(ctx)
}
func catalogSearchMeilisearchSettings(embedder string) map[string]any {
embedder, err := NormalizeCatalogSearchEmbedderName(embedder)
if err != nil {
embedder = DefaultMeilisearchEmbedder
}
return map[string]any{
func catalogSearchMeilisearchSettings(embedder string, semanticEnabled bool) map[string]any {
settings := map[string]any{
"displayedAttributes": []string{"content_id", "type"},
"filterableAttributes": []string{"type"},
"searchableAttributes": []string{
@@ -462,8 +458,15 @@ func catalogSearchMeilisearchSettings(embedder string) map[string]any {
"pagination": map[string]any{
"maxTotalHits": meilisearchDefaultCandidateScanCap,
},
"embedders": catalogSearchMeilisearchEmbedderSettings(embedder),
}
if semanticEnabled {
embedder, err := NormalizeCatalogSearchEmbedderName(embedder)
if err != nil {
embedder = DefaultMeilisearchEmbedder
}
settings["embedders"] = catalogSearchMeilisearchEmbedderSettings(embedder)
}
return settings
}
func coalesceSearchIndexEvents(events []SearchIndexEvent) (upsertIDs []string, deleteIDs []string) {
+23
View File
@@ -127,6 +127,29 @@ func TestCatalogSearchDocumentVectorsUseEmbedderAndOptOutMissing(t *testing.T) {
}
}
func TestCatalogSearchMeilisearchSettingsOmitEmbeddersWhenSemanticDisabled(t *testing.T) {
settings := catalogSearchMeilisearchSettings("silo_recommendations", false)
if _, ok := settings["embedders"]; ok {
t.Fatalf("semantic-disabled settings should not include embedders: %#v", settings["embedders"])
}
if _, ok := settings["searchableAttributes"]; !ok {
t.Fatal("semantic-disabled settings should still configure keyword searchable attributes")
}
}
func TestCatalogSearchMeilisearchSettingsIncludeEmbeddersWhenSemanticEnabled(t *testing.T) {
settings := catalogSearchMeilisearchSettings("custom_embedder", true)
embedders, ok := settings["embedders"].(map[string]any)
if !ok {
t.Fatalf("embedders = %#v, want map[string]any", settings["embedders"])
}
if _, ok := embedders["custom_embedder"]; !ok {
t.Fatalf("embedders = %#v, want custom_embedder", embedders)
}
}
func TestCatalogSearchDocumentPayloadBatchesSplitVectorDocs(t *testing.T) {
vector := make([]float32, 128)
docs := []catalogSearchDocument{
+32
View File
@@ -10,6 +10,7 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/go-chi/chi/v5"
)
type sessionContextKey string
@@ -249,6 +250,37 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
return
}
}
// Stock Jellyfin Android TV ignores the api_key-bearing DirectStreamUrl
// we return from PlaybackInfo and builds its own direct-play URL with no
// auth header, no api_key/ApiKey, and no PlaySessionId. Anchor auth on
// the PlaybackSession negotiated for this item: a successful PlaybackInfo
// already authenticated the user and registered a session holding the
// CompatToken. Scope this strictly to the direct-play video stream routes
// (NOT /Items/{id}/Download) via the chi route pattern, prefer matching
// on mediaSourceId when present, and require the matched session's
// RouteItemID to equal the requested item so a source id can't
// authorize a stream for a different item.
if playbackStore != nil {
switch chi.RouteContext(r.Context()).RoutePattern() {
case "/Videos/{id}/stream", "/Videos/{id}/stream.{container}":
routeItemID := chi.URLParam(r, "id")
if routeItemID != "" {
mediaSourceID := newCaseInsensitiveQuery(r.URL.Query()).Get("mediaSourceId")
lookupID := routeItemID
if mediaSourceID != "" {
lookupID = mediaSourceID
}
if playSession, _, found := playbackStore.FindByRoute("", lookupID); found && playSession.RouteItemID == routeItemID {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
return
}
}
}
}
}
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
})
}
@@ -0,0 +1,171 @@
package jellycompat
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
// directPlayRouter wraps the probe handler in PlaybackSessionAuth and mounts it
// on a chi router so RoutePattern() and URLParam("id") are populated exactly as
// they are in production. The same handler also fronts /Items/{id}/Download to
// prove the new token-less fallback does NOT leak into that route.
func directPlayRouter(t *testing.T, sessions *SessionStore, playback *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator, reached *bool) *chi.Mux {
t.Helper()
probe := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*reached = true
w.WriteHeader(http.StatusOK)
}))
r := chi.NewRouter()
r.Handle("/Videos/{id}/stream", probe)
r.Handle("/Videos/{id}/stream.{container}", probe)
r.Handle("/Items/{id}/Download", probe)
return r
}
// seedDirectPlaySession registers a resolvable compat session plus the
// PlaybackSession that PlaybackInfo would have negotiated for the item.
func seedDirectPlaySession(t *testing.T, clock func() time.Time) (*SessionStore, *PlaybackSessionStore) {
t.Helper()
sessions := NewSessionStore(time.Hour, clock)
if err := sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 7}); err != nil {
t.Fatalf("put session: %v", err)
}
playback := NewPlaybackSessionStore(time.Hour, clock)
playback.Put(PlaybackSession{
ID: "ps1",
CompatToken: "compat-tok",
RouteItemID: "item123",
MediaSources: []PlaybackMediaSource{{ID: "src9"}},
})
return sessions, playback
}
// TestPlaybackSessionAuth_DirectPlayNoToken: stock Jellyfin Android TV requests
// the stream with no auth header, no api_key/ApiKey, and no PlaySessionId. The
// negotiated PlaybackSession for the item authorizes it — across both stream
// route patterns and both lookup branches (by mediaSourceId, and by route item
// id when mediaSourceId is absent).
func TestPlaybackSessionAuth_DirectPlayNoToken(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
cases := []struct {
name string
url string
}{
{"stream + mediaSourceId", "/Videos/item123/stream?static=true&mediaSourceId=src9&container=mkv"},
{"stream.{container} + mediaSourceId", "/Videos/item123/stream.mkv?static=true&mediaSourceId=src9"},
{"stream, route-item lookup (no mediaSourceId)", "/Videos/item123/stream?static=true&container=mkv"},
{"stream.{container}, route-item lookup", "/Videos/item123/stream.mkv?static=true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sessions, playback := seedDirectPlaySession(t, clock)
var reached bool
router := directPlayRouter(t, sessions, playback, nil, &reached)
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !reached {
t.Fatal("expected inner handler to be reached via PlaybackSession fallback")
}
})
}
}
// TestPlaybackSessionAuth_DirectPlayNoMatchingSession: with a resolvable compat
// session present but NO PlaybackSession negotiated for the requested item, the
// token-less request stays a 401 (proves the playback-session match gates it,
// not a missing compat session).
func TestPlaybackSessionAuth_DirectPlayNoMatchingSession(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(time.Hour, clock)
if err := sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 7}); err != nil {
t.Fatalf("put session: %v", err)
}
playback := NewPlaybackSessionStore(time.Hour, clock)
// Session exists, but for a different item id and source id.
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "compat-tok", RouteItemID: "other"})
var reached bool
router := directPlayRouter(t, sessions, playback, nil, &reached)
req := httptest.NewRequest(http.MethodGet, "/Videos/item123/stream?static=true&mediaSourceId=src9", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
if reached {
t.Fatal("inner handler must not run without a matching PlaybackSession")
}
}
// TestPlaybackSessionAuth_DirectPlayCrossItemDenied: a mediaSourceId that resolves
// to a session for a DIFFERENT route item must not authorize the request — the
// session's RouteItemID must match the requested item.
func TestPlaybackSessionAuth_DirectPlayCrossItemDenied(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(time.Hour, clock)
if err := sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 7}); err != nil {
t.Fatalf("put session: %v", err)
}
playback := NewPlaybackSessionStore(time.Hour, clock)
// src9 belongs to itemB's session; the request targets itemA.
playback.Put(PlaybackSession{
ID: "psB",
CompatToken: "compat-tok",
RouteItemID: "itemB",
MediaSources: []PlaybackMediaSource{{ID: "src9"}},
})
var reached bool
router := directPlayRouter(t, sessions, playback, nil, &reached)
req := httptest.NewRequest(http.MethodGet, "/Videos/itemA/stream?static=true&mediaSourceId=src9", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
if reached {
t.Fatal("inner handler must not run when the matched session belongs to another item")
}
}
// TestPlaybackSessionAuth_DownloadNotLoosened: the new direct-play fallback must
// not apply to /Items/{id}/Download — a token-less download stays 401 even when a
// resolvable compat session AND a PlaybackSession exist for the same item id, so
// the 401 proves route scoping rather than a missing session.
func TestPlaybackSessionAuth_DownloadNotLoosened(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions, playback := seedDirectPlaySession(t, clock)
var reached bool
router := directPlayRouter(t, sessions, playback, nil, &reached)
req := httptest.NewRequest(http.MethodGet, "/Items/item123/Download", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
if reached {
t.Fatal("download route must not be served via the direct-play fallback")
}
}
+258
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"slices"
"strings"
"testing"
"time"
@@ -278,9 +279,18 @@ func (s *progressCountingStore) IsFavorite(context.Context, string, string) (boo
func (s *progressCountingStore) AddToWatchlist(context.Context, string, string) error {
panic("unused")
}
func (s *progressCountingStore) AddToWatchlistAt(context.Context, string, string, time.Time) error {
panic("unused")
}
func (s *progressCountingStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
return true, nil
}
func (s *progressCountingStore) RemoveFromWatchlist(context.Context, string, string) error {
panic("unused")
}
func (s *progressCountingStore) ReplaceWatchlistOrder(context.Context, string, []string) error {
panic("unused")
}
func (s *progressCountingStore) ListWatchlist(context.Context, string, int, int) ([]userstore.WatchlistEntry, error) {
panic("unused")
}
@@ -451,6 +461,44 @@ func (s *stubBrowseSource) ListGenres(_ context.Context, _ catalog.BrowseFilters
return nil, nil
}
type recordingCatalogSearchProvider struct {
requests []catalog.CatalogSearchRequest
result *catalog.CatalogSearchResult
}
func (p *recordingCatalogSearchProvider) Search(_ context.Context, req catalog.CatalogSearchRequest) (*catalog.CatalogSearchResult, error) {
p.requests = append(p.requests, req)
if p.result != nil {
return p.result, nil
}
return &catalog.CatalogSearchResult{}, nil
}
type recordingItemAccessSource struct {
searchQueries []string
searchTypes [][]string
searchLimits []int
searchOffsets []int
items []*models.MediaItem
total int
}
func (s *recordingItemAccessSource) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
return nil
}
func (s *recordingItemAccessSource) Search(_ context.Context, query string, itemTypes []string, limit, offset int, _ catalog.AccessFilter) ([]*models.MediaItem, int, error) {
s.searchQueries = append(s.searchQueries, query)
s.searchTypes = append(s.searchTypes, append([]string(nil), itemTypes...))
s.searchLimits = append(s.searchLimits, limit)
s.searchOffsets = append(s.searchOffsets, offset)
return append([]*models.MediaItem(nil), s.items...), s.total, nil
}
func (s *recordingItemAccessSource) GetByIDs(context.Context, []string) ([]*models.MediaItem, error) {
return nil, nil
}
// newDirectContentServiceForTest builds a directContentService with stubbed
// catalog dependencies. Useful for behavioral tests that don't need real
// Postgres state.
@@ -461,6 +509,216 @@ func newDirectContentServiceForTest(browse browseSource, provider userstore.User
}
}
func TestSearchItemsUsesCatalogSearchProviderWithCompatScope(t *testing.T) {
libraryID := 7
provider := &recordingCatalogSearchProvider{
result: &catalog.CatalogSearchResult{
Items: []*models.MediaItem{{
ContentID: "movie-1",
Type: "movie",
Title: "Dune",
}},
Total: 12,
HasMore: true,
},
}
svc := &directContentService{
searchProvider: provider,
accessFilter: func(context.Context, int, string) catalog.AccessFilter {
return catalog.AccessFilter{
AllowedLibraryIDs: []int{1, 2},
ExcludedMediaTypes: []string{"ebook"},
MaxContentRating: "PG-13",
}
},
}
result, err := svc.SearchItems(context.Background(), &Session{
StreamAppUserID: 22,
ProfileID: "profile-1",
}, SearchItemsOptions{
Query: "dune",
Limit: 5,
Offset: 10,
LibraryID: &libraryID,
SkipTotal: true,
})
if err != nil {
t.Fatalf("SearchItems error: %v", err)
}
if len(provider.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(provider.requests))
}
req := provider.requests[0]
if req.Query != "dune" || req.Limit != 5 || req.Offset != 10 || !req.SkipTotal {
t.Fatalf("provider request shape = %#v", req)
}
if want := []string{"movie", "series", "episode"}; !slices.Equal(req.ItemTypes, want) {
t.Fatalf("ItemTypes = %#v, want %#v", req.ItemTypes, want)
}
if req.Access.PresentationLibraryID == nil || *req.Access.PresentationLibraryID != libraryID {
t.Fatalf("PresentationLibraryID = %#v, want %d", req.Access.PresentationLibraryID, libraryID)
}
for _, mediaType := range []string{"ebook", "audiobook", "podcast"} {
if !slices.Contains(req.Access.ExcludedMediaTypes, mediaType) {
t.Fatalf("ExcludedMediaTypes = %#v, missing %q", req.Access.ExcludedMediaTypes, mediaType)
}
}
if result.Total != 12 || !result.HasMore || len(result.Items) != 1 || result.Items[0].Title != "Dune" {
t.Fatalf("result = %#v", result)
}
}
func TestSearchItemsFallsBackToItemRepoWhenProviderMissing(t *testing.T) {
itemRepo := &recordingItemAccessSource{
items: []*models.MediaItem{{
ContentID: "movie-1",
Type: "movie",
Title: "Fallback",
}},
total: 3,
}
svc := &directContentService{itemRepo: itemRepo}
result, err := svc.SearchItems(context.Background(), &Session{}, SearchItemsOptions{
Query: "fallback",
ItemTypes: []string{"MusicAlbum"},
Limit: 2,
Offset: 1,
})
if err != nil {
t.Fatalf("SearchItems error: %v", err)
}
if len(itemRepo.searchQueries) != 1 || itemRepo.searchQueries[0] != "fallback" {
t.Fatalf("search queries = %#v", itemRepo.searchQueries)
}
if want := []string{compatNoMatchType}; !slices.Equal(itemRepo.searchTypes[0], want) {
t.Fatalf("search types = %#v, want %#v", itemRepo.searchTypes[0], want)
}
if result.Total != 3 || !result.HasMore || len(result.Items) != 1 || result.Items[0].Title != "Fallback" {
t.Fatalf("result = %#v", result)
}
}
func TestSearchItemsProviderReturnsNilResult(t *testing.T) {
// explicitlyNilResultProvider returns (nil, nil) — no error, no result.
// This exercises the nil-guard in directContentService.SearchItems
// (the recordingCatalogSearchProvider stub always returns a non-nil result).
provider := &explicitlyNilResultProvider{}
svc := &directContentService{searchProvider: provider}
result, err := svc.SearchItems(context.Background(), &Session{}, SearchItemsOptions{
Query: "ghost",
Limit: 10,
})
if err != nil {
t.Fatalf("SearchItems error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if len(result.Items) != 0 {
t.Fatalf("Items = %d, want 0 when provider returns nil", len(result.Items))
}
if result.Total != 0 {
t.Fatalf("Total = %d, want 0 when provider returns nil", result.Total)
}
if result.HasMore {
t.Fatal("HasMore = true, want false when provider returns nil")
}
}
// explicitlyNilResultProvider is a CatalogSearchProvider that returns (nil, nil)
// on every call, exercising the nil-guard in directContentService.SearchItems.
type explicitlyNilResultProvider struct {
calls int
}
func (p *explicitlyNilResultProvider) Search(_ context.Context, _ catalog.CatalogSearchRequest) (*catalog.CatalogSearchResult, error) {
p.calls++
return nil, nil
}
func TestSearchItemsProviderErrorPropagated(t *testing.T) {
errProvider := &erroringCatalogSearchProvider{err: fmt.Errorf("search backend unavailable")}
svc := &directContentService{searchProvider: errProvider}
_, err := svc.SearchItems(context.Background(), &Session{}, SearchItemsOptions{
Query: "test",
Limit: 5,
})
if err == nil {
t.Fatal("expected error from provider, got nil")
}
if !strings.Contains(err.Error(), "search backend unavailable") {
t.Fatalf("error %q does not contain expected message", err.Error())
}
}
// erroringCatalogSearchProvider always returns an error from Search.
type erroringCatalogSearchProvider struct {
err error
}
func (p *erroringCatalogSearchProvider) Search(_ context.Context, _ catalog.CatalogSearchRequest) (*catalog.CatalogSearchResult, error) {
return nil, p.err
}
func TestSearchItemsFallbackHasMoreFalseAtBoundary(t *testing.T) {
// When offset + len(items) == total, hasMore must be false.
// e.g. offset=2, return 1 item, total=3 → 2+1 == 3 → hasMore=false
itemRepo := &recordingItemAccessSource{
items: []*models.MediaItem{{
ContentID: "movie-3",
Type: "movie",
Title: "Last",
}},
total: 3,
}
svc := &directContentService{itemRepo: itemRepo}
result, err := svc.SearchItems(context.Background(), &Session{}, SearchItemsOptions{
Query: "last",
Limit: 1,
Offset: 2,
})
if err != nil {
t.Fatalf("SearchItems error: %v", err)
}
if result.HasMore {
t.Fatalf("HasMore = true, want false when offset(%d)+len(%d) == total(%d)", 2, 1, 3)
}
if result.Total != 3 {
t.Fatalf("Total = %d, want 3", result.Total)
}
}
func TestSearchItemsFallbackHasMoreTrueWhenMoreRemain(t *testing.T) {
// When offset + len(items) < total, hasMore must be true.
// e.g. offset=0, return 1 item, total=3 → 0+1 < 3 → hasMore=true
itemRepo := &recordingItemAccessSource{
items: []*models.MediaItem{{
ContentID: "movie-1",
Type: "movie",
Title: "First",
}},
total: 3,
}
svc := &directContentService{itemRepo: itemRepo}
result, err := svc.SearchItems(context.Background(), &Session{}, SearchItemsOptions{
Query: "first",
Limit: 1,
Offset: 0,
})
if err != nil {
t.Fatalf("SearchItems error: %v", err)
}
if !result.HasMore {
t.Fatalf("HasMore = false, want true when offset(%d)+len(%d) < total(%d)", 0, 1, 3)
}
}
// TestBrowseItems_DoesNotFetchProgressWhenNoPlayedFilter verifies that
// BrowseItems does NOT call ListProgressByMediaItems on the user store when
// the is_played filter is empty. The handler-level resolveUserStateForContentIDs
+179 -1
View File
@@ -50,7 +50,7 @@ func (s *countingContentService) BrowseItems(context.Context, *Session, url.Valu
panic("unused")
}
func (s *countingContentService) SearchItems(context.Context, *Session, string, []string, int, int, *int) (*upstreamBrowseResponse, error) {
func (s *countingContentService) SearchItems(context.Context, *Session, SearchItemsOptions) (*upstreamBrowseResponse, error) {
panic("unused")
}
@@ -81,6 +81,20 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
panic("unused")
}
type recordingSearchContentService struct {
countingContentService
options []SearchItemsOptions
result *upstreamBrowseResponse
}
func (s *recordingSearchContentService) SearchItems(_ context.Context, _ *Session, opts SearchItemsOptions) (*upstreamBrowseResponse, error) {
s.options = append(s.options, opts)
if s.result != nil {
return s.result, nil
}
return &upstreamBrowseResponse{Items: []upstreamListItem{}}, nil
}
func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
codec := NewResourceIDCodec()
seriesContentID := "series-1"
@@ -140,6 +154,170 @@ func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
}
}
func TestHandleItemsSearchPropagatesEnableTotalRecordCount(t *testing.T) {
for _, tc := range []struct {
name string
querySuffix string
wantSkipTotal bool
}{
{name: "default includes total", wantSkipTotal: false},
{name: "disabled skips total", querySuffix: "&EnableTotalRecordCount=false", wantSkipTotal: true},
} {
t.Run(tc.name, func(t *testing.T) {
codec := NewResourceIDCodec()
contentSvc := &recordingSearchContentService{}
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
req := httptest.NewRequest("GET", "/Users/test/Items?SearchTerm=dune&Limit=5&StartIndex=2"+tc.querySuffix, nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 1,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
h.HandleItems(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if len(contentSvc.options) != 1 {
t.Fatalf("SearchItems calls = %d, want 1", len(contentSvc.options))
}
opts := contentSvc.options[0]
if opts.Query != "dune" || opts.Limit != 5 || opts.Offset != 2 {
t.Fatalf("SearchItems options = %#v", opts)
}
if opts.SkipTotal != tc.wantSkipTotal {
t.Fatalf("SkipTotal = %v, want %v", opts.SkipTotal, tc.wantSkipTotal)
}
})
}
}
// TestHandleSearchHints_PassesOnlyQueryAndLimit verifies that HandleSearchHints
// calls SearchItems with a SearchItemsOptions that carries only Query and Limit
// (no LibraryID, empty ItemTypes, and SkipTotal=false). The search-hints
// endpoint does not scope by library and never specifies item types — returning
// all video-compatible types — so it must not accidentally suppress the total
// or restrict the result set.
func TestHandleSearchHints_PassesOnlyQueryAndLimit(t *testing.T) {
codec := NewResourceIDCodec()
contentSvc := &recordingSearchContentService{
result: &upstreamBrowseResponse{
Items: []upstreamListItem{
{ContentID: "movie-1", Type: "movie", Title: "Dune"},
},
Total: 1,
},
}
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
req := httptest.NewRequest("GET", "/Search/Hints?SearchTerm=dune&Limit=7", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 1,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
h.HandleSearchHints(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if len(contentSvc.options) != 1 {
t.Fatalf("SearchItems calls = %d, want 1", len(contentSvc.options))
}
opts := contentSvc.options[0]
if opts.Query != "dune" {
t.Errorf("Query = %q, want %q", opts.Query, "dune")
}
if opts.Limit != 7 {
t.Errorf("Limit = %d, want 7", opts.Limit)
}
if len(opts.ItemTypes) != 0 {
t.Errorf("ItemTypes = %#v, want empty (HandleSearchHints must not restrict types)", opts.ItemTypes)
}
if opts.LibraryID != nil {
t.Errorf("LibraryID = %v, want nil", opts.LibraryID)
}
if opts.SkipTotal {
t.Error("SkipTotal = true, want false for search hints")
}
if opts.Offset != 0 {
t.Errorf("Offset = %d, want 0", opts.Offset)
}
}
// TestHandleSearchHints_EmptyQueryReturnsEmptyWithoutCallingSearch verifies
// that an empty or whitespace-only SearchTerm returns a 200 empty result
// without invoking the ContentService at all.
func TestHandleSearchHints_EmptyQueryReturnsEmptyWithoutCallingSearch(t *testing.T) {
codec := NewResourceIDCodec()
contentSvc := &recordingSearchContentService{}
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
req := httptest.NewRequest("GET", "/Search/Hints?SearchTerm= ", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 1,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
h.HandleSearchHints(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if len(contentSvc.options) != 0 {
t.Errorf("SearchItems called %d times, want 0 for empty query", len(contentSvc.options))
}
}
// TestHandleSearchHints_DefaultLimitIsTwenty verifies that when Limit is
// omitted from the query string, HandleSearchHints uses a default of 20.
func TestHandleSearchHints_DefaultLimitIsTwenty(t *testing.T) {
codec := NewResourceIDCodec()
contentSvc := &recordingSearchContentService{}
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
req := httptest.NewRequest("GET", "/Search/Hints?SearchTerm=inception", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 1,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
h.HandleSearchHints(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if len(contentSvc.options) == 1 && contentSvc.options[0].Limit != 20 {
t.Errorf("Limit = %d, want 20 (default)", contentSvc.options[0].Limit)
}
}
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
// that episode detail responses fetch parent series image metadata even when
// image URLs are already cached. Cached URLs are not enough to build stable
+134 -1
View File
@@ -1,6 +1,139 @@
package jellycompat
import "testing"
import (
"context"
"net/url"
"slices"
"testing"
)
// recordingPersonsContentService is a ContentService stub that records all
// SearchItems calls made by PersonsHandler. It is only used in
// handlers_persons_test.go and lives here to keep it close to the tests that
// rely on it.
type recordingPersonsContentService struct {
searchOptions []SearchItemsOptions
searchResult *upstreamBrowseResponse
}
func (s *recordingPersonsContentService) SearchItems(_ context.Context, _ *Session, opts SearchItemsOptions) (*upstreamBrowseResponse, error) {
s.searchOptions = append(s.searchOptions, opts)
if s.searchResult != nil {
return s.searchResult, nil
}
return &upstreamBrowseResponse{Items: []upstreamListItem{}}, nil
}
func (s *recordingPersonsContentService) ListUserLibraries(context.Context, *Session) ([]upstreamUserLibrary, error) {
panic("unused")
}
func (s *recordingPersonsContentService) BrowseItems(context.Context, *Session, url.Values) (*upstreamBrowseResponse, error) {
panic("unused")
}
func (s *recordingPersonsContentService) GetItemDetail(context.Context, *Session, string, *int) (*upstreamItemDetail, error) {
panic("unused")
}
func (s *recordingPersonsContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
panic("unused")
}
func (s *recordingPersonsContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
panic("unused")
}
func (s *recordingPersonsContentService) ListEpisodes(context.Context, *Session, string, int, *int) ([]upstreamEpisode, error) {
panic("unused")
}
func (s *recordingPersonsContentService) ListEpisodesBySeasonID(context.Context, *Session, string, *int) ([]upstreamEpisode, error) {
panic("unused")
}
func (s *recordingPersonsContentService) ListItemFilters(context.Context, *Session, url.Values) (*upstreamItemFiltersResponse, error) {
panic("unused")
}
// TestShouldSuppressSearchPeople_PassesSkipTotalTrue verifies that
// shouldSuppressSearchPeople always sets SkipTotal:true in the SearchItems call
// it issues to probe whether the query matches media titles. Fetching a total
// count on the hot people-search path is unnecessary overhead.
func TestShouldSuppressSearchPeople_PassesSkipTotalTrue(t *testing.T) {
// "Avatar 2" fails looksLikePersonName (has a digit) so the probe fires.
contentSvc := &recordingPersonsContentService{
searchResult: &upstreamBrowseResponse{
Items: []upstreamListItem{
{ContentID: "movie-1", Title: "Avatar 2"},
},
},
}
h := &PersonsHandler{content: contentSvc}
_ = h.shouldSuppressSearchPeople(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "p1"}, "Avatar 2")
if len(contentSvc.searchOptions) != 1 {
t.Fatalf("SearchItems call count = %d, want 1", len(contentSvc.searchOptions))
}
opts := contentSvc.searchOptions[0]
if !opts.SkipTotal {
t.Errorf("SkipTotal = false, want true (total is unnecessary on the probe path)")
}
if want := []string{"movie", "series"}; !slices.Equal(opts.ItemTypes, want) {
t.Errorf("ItemTypes = %#v, want %#v", opts.ItemTypes, want)
}
if opts.Limit != 5 {
t.Errorf("Limit = %d, want 5", opts.Limit)
}
}
// TestShouldSuppressSearchPeople_SkipsProbeForPersonNameShape verifies that
// when the query looks like a person name, SearchItems is never called —
// the handler short-circuits before reaching the media probe.
func TestShouldSuppressSearchPeople_SkipsProbeForPersonNameShape(t *testing.T) {
contentSvc := &recordingPersonsContentService{}
h := &PersonsHandler{content: contentSvc}
// "Tom Hanks" looks like a person name → no probe.
suppressed := h.shouldSuppressSearchPeople(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "p1"}, "Tom Hanks")
if len(contentSvc.searchOptions) != 0 {
t.Errorf("SearchItems called %d times, want 0 for person-name queries", len(contentSvc.searchOptions))
}
if suppressed {
t.Error("suppressed = true for a person-name query, want false")
}
}
// TestShouldSuppressSearchPeople_ReturnsFalseWhenNoMediaMatch verifies that
// the handler does NOT suppress when the media search returns no results.
func TestShouldSuppressSearchPeople_ReturnsFalseWhenNoMediaMatch(t *testing.T) {
// Empty result → should not suppress.
contentSvc := &recordingPersonsContentService{
searchResult: &upstreamBrowseResponse{Items: []upstreamListItem{}},
}
h := &PersonsHandler{content: contentSvc}
suppressed := h.shouldSuppressSearchPeople(context.Background(), &Session{}, "Avatar 2")
if suppressed {
t.Error("suppressed = true when no media matched, want false")
}
}
// TestShouldSuppressSearchPeople_ReturnsTrueOnExactTitleMatch verifies the
// core suppression logic: an exact title match causes the person search to
// be suppressed so clients receive an empty person list.
func TestShouldSuppressSearchPeople_ReturnsTrueOnExactTitleMatch(t *testing.T) {
contentSvc := &recordingPersonsContentService{
searchResult: &upstreamBrowseResponse{
Items: []upstreamListItem{
{ContentID: "movie-1", Title: "Avatar 2"},
},
},
}
h := &PersonsHandler{content: contentSvc}
suppressed := h.shouldSuppressSearchPeople(context.Background(), &Session{}, "Avatar 2")
if !suppressed {
t.Error("suppressed = false for exact title match, want true")
}
}
func TestLooksLikePersonName_AcceptsRealNames(t *testing.T) {
cases := []string{"Christopher Nolan", "Tom Hanks", "Émilie Dequenne", "O'Brien"}
+112 -9
View File
@@ -36,6 +36,7 @@ type playbackInfoRequest struct {
UserID string `json:"UserId"`
MediaSourceID string `json:"MediaSourceId"`
AudioStreamIndex *compatIntValue `json:"AudioStreamIndex,omitempty"`
SubtitleStreamIndex *compatIntValue `json:"SubtitleStreamIndex,omitempty"`
StartTimeTicks int64 `json:"StartTimeTicks"`
EnableDirectPlay *bool `json:"EnableDirectPlay"`
EnableDirectStream *bool `json:"EnableDirectStream"`
@@ -423,11 +424,40 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
continue
}
// Resolve the client's subtitle selection against both the
// embedded/external tracks and any downloaded subtitles before
// advertising the streams, so the chosen subtitle is marked default and
// starts with playback (mirrors the audio-selection plumbing).
var downloaded []subtitles.DownloadedSubtitle
downloadedKnown := true
if h.SubtitleRepo != nil {
var listErr error
downloaded, listErr = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), source.Version.FileID)
if listErr != nil {
// Don't treat a lookup failure as "no downloaded subtitles": that
// would silently downgrade a valid downloaded selection to the
// media default. Resolution falls back to honoring the request.
downloaded = nil
downloadedKnown = false
slog.Warn("jellycompat downloaded subtitle lookup failed",
"file_id", source.Version.FileID,
"error", listErr,
)
}
}
var requestedSubtitleIndex *int
if req.SubtitleStreamIndex != nil {
requestedSubtitleIndex = intPtr(int(*req.SubtitleStreamIndex))
}
source.SelectedSubtitleStreamIndex = resolveSelectedSubtitleStreamIndex(source.Version, len(downloaded), downloadedKnown, requestedSubtitleIndex, source.DefaultSubtitleStreamIndex)
sources = append(sources, source)
dto := h.mediaSourceDTO(routeItemID, playSessionID, session.Token, source)
// Append downloaded subtitles to the media streams.
if h.SubtitleRepo != nil {
downloaded, _ := h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), source.Version.FileID)
// Append downloaded subtitles to the media streams, honoring the selection.
if len(downloaded) > 0 {
selectedSubtitleStreamIndex := effectiveCompatSubtitleStreamIndex(source)
baseIndex := nextDownloadedSubtitleIndex(source.Version)
for i, dl := range downloaded {
streamIndex := baseIndex + i
@@ -440,7 +470,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
Language: dl.Language,
DisplayTitle: displayTitle,
Title: displayTitle,
IsDefault: false,
IsDefault: selectedSubtitleStreamIndex != nil && streamIndex == *selectedSubtitleStreamIndex,
IsExternal: true,
IsForced: false,
IsHearingImpaired: dl.HearingImpaired,
@@ -596,8 +626,8 @@ func (h *PlaybackHandler) mediaSourceDTO(routeItemID, playSessionID, compatToken
MediaAttachments: []map[string]any{},
Bitrate: source.Version.Bitrate * 1000,
DefaultAudioStreamIndex: selectedAudioStreamIndex,
DefaultSubtitleStreamIndex: source.DefaultSubtitleStreamIndex,
MediaStreams: buildMediaStreamsWithSelection(routeItemID, source.ID, source.Version, selectedAudioStreamIndex, compatToken, playSessionID),
DefaultSubtitleStreamIndex: effectiveCompatSubtitleStreamIndex(source),
MediaStreams: buildMediaStreamsWithSelection(routeItemID, source.ID, source.Version, selectedAudioStreamIndex, source.SelectedSubtitleStreamIndex, compatToken, playSessionID),
}
if source.SupportsDirectPlay || source.SupportsDirectStream {
dto.DirectStreamURL = fmt.Sprintf(
@@ -621,10 +651,10 @@ func (h *PlaybackHandler) mediaSourceDTO(routeItemID, playSessionID, compatToken
}
func buildMediaStreams(routeItemID, mediaSourceID string, version catalog.FileVersion) []mediaStreamDTO {
return buildMediaStreamsWithSelection(routeItemID, mediaSourceID, version, nil, "", "")
return buildMediaStreamsWithSelection(routeItemID, mediaSourceID, version, nil, nil, "", "")
}
func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version catalog.FileVersion, selectedAudioStreamIndex *int, compatToken, playSessionID string) []mediaStreamDTO {
func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version catalog.FileVersion, selectedAudioStreamIndex, selectedSubtitleStreamIndex *int, compatToken, playSessionID string) []mediaStreamDTO {
streams := make([]mediaStreamDTO, 0, len(version.VideoTracks)+len(version.AudioTracks)+len(version.SubtitleTracks))
effectiveAudioStreamIndex := selectedAudioStreamIndex
if effectiveAudioStreamIndex != nil && !isValidCompatAudioStreamIndex(version, *effectiveAudioStreamIndex) {
@@ -707,6 +737,13 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
streamIndex := subtitleTrackIndex(version, track, index)
format := subtitleRouteFormat(track.Codec)
displayTitle := compatSubtitleDisplayTitle(track)
// When the client has made an explicit subtitle selection, only that
// stream is the default. A negative selection ("subtitles off") matches
// no stream, which correctly clears every embedded default.
isDefault := track.Default
if selectedSubtitleStreamIndex != nil {
isDefault = streamIndex == *selectedSubtitleStreamIndex
}
streams = append(streams, mediaStreamDTO{
Index: streamIndex,
Type: "Subtitle",
@@ -715,7 +752,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
TimeBase: "1/1000",
DisplayTitle: displayTitle,
Title: displayTitle,
IsDefault: track.Default,
IsDefault: isDefault,
IsExternal: track.External,
IsForced: track.Forced,
IsHearingImpaired: track.HearingImpaired,
@@ -824,6 +861,69 @@ func subtitleTrackStreamable(codec string, external bool) bool {
return external || !playback.NeedsBurnIn(codec)
}
// isValidCompatSubtitleStreamIndex reports whether streamIndex addresses a
// deliverable subtitle: either a streamable embedded/external track (bitmap
// subs that require burn-in are excluded, matching buildMediaStreams) or one of
// the downloaded subtitles appended after the embedded streams.
func isValidCompatSubtitleStreamIndex(version catalog.FileVersion, downloadedCount, streamIndex int) bool {
for index, track := range version.SubtitleTracks {
if !subtitleTrackStreamable(track.Codec, track.External) {
continue
}
if subtitleTrackIndex(version, track, index) == streamIndex {
return true
}
}
if downloadedCount > 0 {
base := nextDownloadedSubtitleIndex(version)
if streamIndex >= base && streamIndex < base+downloadedCount {
return true
}
}
return false
}
// resolveSelectedSubtitleStreamIndex maps a client-requested subtitle stream
// index onto the selection stored for the session. A nil request keeps the
// media default; a negative request is preserved as an explicit "subtitles off"
// (-1); a valid request is honored; an invalid request falls back to the media
// default.
//
// downloadedKnown reports whether the downloaded-subtitle list was loaded
// successfully. When it is false, an index that does not match an
// embedded/external track is honored rather than downgraded, because it may be a
// downloaded subtitle we could not enumerate — losing the user's choice on a
// transient lookup failure is worse than echoing an index whose stream is
// temporarily absent.
func resolveSelectedSubtitleStreamIndex(version catalog.FileVersion, downloadedCount int, downloadedKnown bool, requested, mediaDefault *int) *int {
if requested == nil {
return mediaDefault
}
if *requested < 0 {
return intPtr(-1)
}
if isValidCompatSubtitleStreamIndex(version, downloadedCount, *requested) {
return intPtr(*requested)
}
if !downloadedKnown {
return intPtr(*requested)
}
return mediaDefault
}
// effectiveCompatSubtitleStreamIndex returns the subtitle stream index to
// advertise as the default for a source: the explicit selection when present
// (collapsing "subtitles off" to none), otherwise the media default.
func effectiveCompatSubtitleStreamIndex(source PlaybackMediaSource) *int {
if source.SelectedSubtitleStreamIndex != nil {
if *source.SelectedSubtitleStreamIndex < 0 {
return nil
}
return intPtr(*source.SelectedSubtitleStreamIndex)
}
return source.DefaultSubtitleStreamIndex
}
func anyDefaultAudioTrack(tracks []models.AudioTrack) bool {
for _, track := range tracks {
if track.Default {
@@ -1137,6 +1237,9 @@ func applyPlaybackQueryOverrides(req *playbackInfoRequest, query url.Values) {
if value, ok := parseOptionalInt(firstQueryValue(query, "AudioStreamIndex")); ok {
req.AudioStreamIndex = compatIntValuePtr(value)
}
if value, ok := parseOptionalInt(firstQueryValue(query, "SubtitleStreamIndex")); ok {
req.SubtitleStreamIndex = compatIntValuePtr(value)
}
if value, ok := parseOptionalBool(firstQueryValue(query, "EnableDirectPlay")); ok {
req.EnableDirectPlay = &value
}
+12 -11
View File
@@ -27,17 +27,18 @@ type PlaybackSession struct {
// PlaybackMediaSource stores one negotiated stream source within a compat play session.
type PlaybackMediaSource struct {
ID string
FileID int
Version catalog.FileVersion
SupportsDirectPlay bool
SupportsDirectStream bool
SupportsTranscoding bool
TranscodeAudio bool
DefaultAudioStreamIndex *int
SelectedAudioStreamIndex *int
DefaultSubtitleStreamIndex *int
ETag string
ID string
FileID int
Version catalog.FileVersion
SupportsDirectPlay bool
SupportsDirectStream bool
SupportsTranscoding bool
TranscodeAudio bool
DefaultAudioStreamIndex *int
SelectedAudioStreamIndex *int
DefaultSubtitleStreamIndex *int
SelectedSubtitleStreamIndex *int
ETag string
}
// PlaybackSessionStore keeps compat playback sessions in memory.
+1
View File
@@ -337,6 +337,7 @@ func withDefaults(deps Dependencies) Dependencies {
catalog.NewContinueWatchingProgressFilter(pool),
staler,
deps.RecWorker,
deps.WatchCompletionObserver,
)
}
+5
View File
@@ -19,6 +19,7 @@ import (
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
)
// Dependencies holds the pluggable pieces used by the compat server.
@@ -51,6 +52,10 @@ type Dependencies struct {
UserDataService UserDataService
AuthService *auth.Service
// WatchCompletionObserver is notified when a Jellyfin-compat mark-played
// completes a watch, so fully-watched items leave the watchlist. Optional.
WatchCompletionObserver watchstate.CompletionObserver
// Autoscan / admin compatibility support.
APIKeyValidator apiKeyValidator
APIKeyUserLoader apiKeyUserLoader
@@ -0,0 +1,355 @@
package jellycompat
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func ptrString(p *int) string {
if p == nil {
return "<nil>"
}
return strconv.Itoa(*p)
}
// subtitleSelectionVersion is a fixture with one video, one audio, one embedded
// default text subtitle (stream index 2), one external text subtitle (stream
// index 3), and one bitmap subtitle that requires burn-in (not streamable).
func subtitleSelectionVersion() catalog.FileVersion {
return catalog.FileVersion{
FileID: 42,
Duration: 3600,
Container: "mkv",
Bitrate: 8000,
VideoTracks: []models.VideoTrack{
{Codec: "h264", Width: 1920, Height: 1080},
},
AudioTracks: []models.AudioTrack{
{Codec: "aac", Default: true, Title: "Main"},
},
SubtitleTracks: []catalog.VersionSubtitleTrack{
{Index: 2, Codec: "subrip", Language: "eng", Title: "English", Default: true},
{Codec: "srt", Language: "spa", Title: "Spanish", External: true},
{Codec: "dvd_subtitle", Language: "fre", Title: "French (bitmap)"},
},
}
}
func TestPlaybackInfoRequest_AcceptsStringSubtitleStreamIndex(t *testing.T) {
var req playbackInfoRequest
if err := json.Unmarshal([]byte(`{"SubtitleStreamIndex":"3"}`), &req); err != nil {
t.Fatalf("unmarshal playback request: %v", err)
}
if req.SubtitleStreamIndex == nil {
t.Fatal("expected subtitle stream index")
}
if got := int(*req.SubtitleStreamIndex); got != 3 {
t.Fatalf("SubtitleStreamIndex = %d, want 3", got)
}
}
func TestIsValidCompatSubtitleStreamIndex(t *testing.T) {
version := subtitleSelectionVersion()
const downloadedCount = 1 // downloaded subtitle occupies stream index 5 (after the bitmap track at 4)
cases := []struct {
name string
streamIndex int
want bool
}{
{"video stream", 0, false},
{"audio stream", 1, false},
{"embedded text subtitle", 2, true},
{"external text subtitle", 3, true},
{"bitmap subtitle (needs burn-in)", 4, false},
{"downloaded subtitle", 5, true},
{"out of range", 6, false},
{"negative", -1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isValidCompatSubtitleStreamIndex(version, downloadedCount, tc.streamIndex); got != tc.want {
t.Fatalf("isValidCompatSubtitleStreamIndex(%d) = %v, want %v", tc.streamIndex, got, tc.want)
}
})
}
}
func TestIsValidCompatSubtitleStreamIndex_ExcludesBitmapSubtitle(t *testing.T) {
// A version whose only subtitle is bitmap (needs burn-in). Its computed
// stream index must not be selectable, because it is filtered out of the
// delivered streams.
version := catalog.FileVersion{
FileID: 7,
VideoTracks: []models.VideoTrack{
{Codec: "h264"},
},
AudioTracks: []models.AudioTrack{
{Codec: "aac"},
},
SubtitleTracks: []catalog.VersionSubtitleTrack{
{Codec: "dvd_subtitle", Language: "eng"},
},
}
// Bitmap track would otherwise compute to stream index 2.
if isValidCompatSubtitleStreamIndex(version, 0, 2) {
t.Fatal("bitmap subtitle stream index should not be selectable")
}
}
func TestResolveSelectedSubtitleStreamIndex(t *testing.T) {
version := subtitleSelectionVersion()
const downloadedCount = 1
mediaDefault := intPtr(2)
cases := []struct {
name string
downloadedKnown bool
requested *int
want *int
}{
{"no request falls back to media default", true, nil, intPtr(2)},
{"explicit off", true, intPtr(-1), intPtr(-1)},
{"valid embedded selection", true, intPtr(2), intPtr(2)},
{"valid external selection", true, intPtr(3), intPtr(3)},
{"valid downloaded selection", true, intPtr(5), intPtr(5)},
{"invalid selection falls back to media default", true, intPtr(99), intPtr(2)},
// When the downloaded list could not be loaded, an embedded/external
// selection still resolves, but an index we cannot validate is honored
// rather than downgraded to the media default.
{"lookup failure honors embedded selection", false, intPtr(3), intPtr(3)},
{"lookup failure honors unverifiable selection", false, intPtr(5), intPtr(5)},
{"lookup failure still respects off", false, intPtr(-1), intPtr(-1)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// A failed lookup yields no enumerable downloaded subtitles.
count := downloadedCount
if !tc.downloadedKnown {
count = 0
}
got := resolveSelectedSubtitleStreamIndex(version, count, tc.downloadedKnown, tc.requested, mediaDefault)
if (got == nil) != (tc.want == nil) {
t.Fatalf("resolveSelectedSubtitleStreamIndex = %v, want %v", ptrString(got), ptrString(tc.want))
}
if got != nil && *got != *tc.want {
t.Fatalf("resolveSelectedSubtitleStreamIndex = %d, want %d", *got, *tc.want)
}
})
}
}
func TestEffectiveCompatSubtitleStreamIndex(t *testing.T) {
cases := []struct {
name string
source PlaybackMediaSource
want *int
}{
{
name: "selected overrides default",
source: PlaybackMediaSource{SelectedSubtitleStreamIndex: intPtr(3), DefaultSubtitleStreamIndex: intPtr(2)},
want: intPtr(3),
},
{
name: "off yields none",
source: PlaybackMediaSource{SelectedSubtitleStreamIndex: intPtr(-1), DefaultSubtitleStreamIndex: intPtr(2)},
want: nil,
},
{
name: "no selection falls back to default",
source: PlaybackMediaSource{DefaultSubtitleStreamIndex: intPtr(2)},
want: intPtr(2),
},
{
name: "no selection no default",
source: PlaybackMediaSource{},
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := effectiveCompatSubtitleStreamIndex(tc.source)
if (got == nil) != (tc.want == nil) {
t.Fatalf("effectiveCompatSubtitleStreamIndex = %v, want %v", ptrString(got), ptrString(tc.want))
}
if got != nil && *got != *tc.want {
t.Fatalf("effectiveCompatSubtitleStreamIndex = %d, want %d", *got, *tc.want)
}
})
}
}
func newSubtitleSelectionHandler(t *testing.T) (*PlaybackHandler, string) {
t.Helper()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
version := subtitleSelectionVersion()
handler := &PlaybackHandler{
content: &stubContentService{detail: &upstreamItemDetail{
ContentID: contentID,
Versions: []catalog.FileVersion{version},
}},
codec: codec,
deviceProfiles: NewDeviceProfileStore(time.Hour, nil),
playbackStore: NewPlaybackSessionStore(time.Hour, nil),
SubtitleRepo: fakeSubtitleRepository{downloaded: map[int][]subtitles.DownloadedSubtitle{
42: {
{MediaFileID: 42, Language: "deu", Format: subtitles.FormatSRT, Provider: "opensubtitles"},
},
}},
}
return handler, routeID
}
func postPlaybackInfo(t *testing.T, handler *PlaybackHandler, routeID, body string) playbackInfoResponseDTO {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(body))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
rr := httptest.NewRecorder()
handler.HandlePlaybackInfo(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp playbackInfoResponseDTO
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(resp.MediaSources) != 1 {
t.Fatalf("media sources = %d, want 1", len(resp.MediaSources))
}
return resp
}
func defaultSubtitleStreamFromResponse(t *testing.T, resp playbackInfoResponseDTO) (index int, found bool) {
t.Helper()
for _, stream := range resp.MediaSources[0].MediaStreams {
if stream.Type == "Subtitle" && stream.IsDefault {
if found {
t.Fatal("more than one subtitle stream marked default")
}
index = stream.Index
found = true
}
}
return index, found
}
func TestHandlePlaybackInfo_DefaultsToEmbeddedDefaultSubtitle(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
resp := postPlaybackInfo(t, handler, routeID, `{}`)
if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil {
t.Fatal("expected DefaultSubtitleStreamIndex to be set")
}
if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 2 {
t.Fatalf("DefaultSubtitleStreamIndex = %d, want 2", got)
}
index, found := defaultSubtitleStreamFromResponse(t, resp)
if !found || index != 2 {
t.Fatalf("default subtitle stream = (%d, %v), want (2, true)", index, found)
}
}
func TestHandlePlaybackInfo_HonorsSelectedExternalSubtitle(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":3}`)
if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil {
t.Fatal("expected DefaultSubtitleStreamIndex to be set")
}
if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 3 {
t.Fatalf("DefaultSubtitleStreamIndex = %d, want 3", got)
}
index, found := defaultSubtitleStreamFromResponse(t, resp)
if !found || index != 3 {
t.Fatalf("default subtitle stream = (%d, %v), want (3, true)", index, found)
}
}
func TestHandlePlaybackInfo_HonorsSelectedDownloadedSubtitle(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
// The downloaded subtitle lands at stream index 5 (after the bitmap track at 4).
resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":5}`)
if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil {
t.Fatal("expected DefaultSubtitleStreamIndex to be set")
}
if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 5 {
t.Fatalf("DefaultSubtitleStreamIndex = %d, want 5", got)
}
index, found := defaultSubtitleStreamFromResponse(t, resp)
if !found || index != 5 {
t.Fatalf("default subtitle stream = (%d, %v), want (5, true)", index, found)
}
}
// erroringSubtitleRepository simulates a transient failure of the downloaded
// subtitle lookup while satisfying the rest of the repository interface.
type erroringSubtitleRepository struct {
fakeSubtitleRepository
}
func (erroringSubtitleRepository) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
return nil, errors.New("subtitle store unavailable")
}
func TestHandlePlaybackInfo_HonorsExternalSelectionWhenDownloadedLookupFails(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
handler := &PlaybackHandler{
content: &stubContentService{detail: &upstreamItemDetail{
ContentID: contentID,
Versions: []catalog.FileVersion{subtitleSelectionVersion()},
}},
codec: codec,
deviceProfiles: NewDeviceProfileStore(time.Hour, nil),
playbackStore: NewPlaybackSessionStore(time.Hour, nil),
SubtitleRepo: erroringSubtitleRepository{},
}
// A failed downloaded-subtitle lookup must not discard a valid
// embedded/external selection (the primary case for external SRT).
resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":3}`)
if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil {
t.Fatal("expected DefaultSubtitleStreamIndex to be set")
}
if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 3 {
t.Fatalf("DefaultSubtitleStreamIndex = %d, want 3", got)
}
index, found := defaultSubtitleStreamFromResponse(t, resp)
if !found || index != 3 {
t.Fatalf("default subtitle stream = (%d, %v), want (3, true)", index, found)
}
}
func TestHandlePlaybackInfo_SubtitlesOff(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":-1}`)
if resp.MediaSources[0].DefaultSubtitleStreamIndex != nil {
t.Fatalf("expected DefaultSubtitleStreamIndex to be unset, got %d", *resp.MediaSources[0].DefaultSubtitleStreamIndex)
}
if index, found := defaultSubtitleStreamFromResponse(t, resp); found {
t.Fatalf("expected no default subtitle stream, got index %d", index)
}
}
+4 -3
View File
@@ -31,14 +31,15 @@ func newDirectUserDataService(
resumeFilter *catalog.ContinueWatchingProgressFilter,
staler profileStaler,
requester profileRefreshRequester,
completionObserver watchstate.CompletionObserver,
) *directUserDataService {
return &directUserDataService{
storeProvider: storeProvider,
itemRepo: itemRepo,
detailSvc: detailSvc,
watchState: watchstate.NewService(storeProvider).WithStableIdentityResolver(
watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo),
),
watchState: watchstate.NewService(storeProvider).
WithStableIdentityResolver(watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo)).
WithCompletionObserver(completionObserver),
resumeFilter: resumeFilter,
profileStaler: staler,
profileRefreshRequester: requester,
+16 -13
View File
@@ -876,8 +876,8 @@ var mediaItemMergeSteps = []mediaItemMergeStep{
{"delete source stale media ids", `DELETE FROM stale_media_ids WHERE content_id = $1`},
{"move watch provider history exports", `UPDATE watch_provider_history_exports SET media_item_id = $2, updated_at = NOW() WHERE media_item_id = $1`},
{"move watch provider scrobble sessions", `UPDATE watch_provider_scrobble_sessions SET media_item_id = $2, updated_at = NOW() WHERE media_item_id = $1`},
{"merge watch provider favorites by provider key", `
UPDATE watch_provider_favorite_items dest
{"merge watch provider list items by provider key", `
UPDATE watch_provider_list_items dest
SET remote_present = dest.remote_present OR src.remote_present,
local_present = dest.local_present OR src.local_present,
last_seen_remote_at = GREATEST(dest.last_seen_remote_at, src.last_seen_remote_at),
@@ -885,14 +885,15 @@ var mediaItemMergeSteps = []mediaItemMergeStep{
last_exported_at = GREATEST(dest.last_exported_at, src.last_exported_at),
last_error = COALESCE(NULLIF(dest.last_error, ''), src.last_error),
updated_at = NOW()
FROM watch_provider_favorite_items src
FROM watch_provider_list_items src
WHERE src.media_item_id = $1
AND dest.media_item_id = $2
AND src.connection_id = dest.connection_id
AND src.list_kind = dest.list_kind
AND src.provider_item_key <> ''
AND src.provider_item_key = dest.provider_item_key`},
{"merge watch provider favorites by connection", `
UPDATE watch_provider_favorite_items dest
{"merge watch provider list items by connection", `
UPDATE watch_provider_list_items dest
SET provider_item_key = CASE
WHEN dest.provider_item_key = '' THEN src.provider_item_key
ELSE dest.provider_item_key
@@ -907,18 +908,20 @@ var mediaItemMergeSteps = []mediaItemMergeStep{
last_exported_at = GREATEST(dest.last_exported_at, src.last_exported_at),
last_error = COALESCE(NULLIF(dest.last_error, ''), src.last_error),
updated_at = NOW()
FROM watch_provider_favorite_items src
WHERE src.media_item_id = $1
AND dest.media_item_id = $2
AND src.connection_id = dest.connection_id`},
{"delete duplicate watch provider favorites", `
DELETE FROM watch_provider_favorite_items src
USING watch_provider_favorite_items dest
FROM watch_provider_list_items src
WHERE src.media_item_id = $1
AND dest.media_item_id = $2
AND src.connection_id = dest.connection_id
AND src.list_kind = dest.list_kind`},
{"delete duplicate watch provider list items", `
DELETE FROM watch_provider_list_items src
USING watch_provider_list_items dest
WHERE src.media_item_id = $1
AND dest.media_item_id = $2
AND src.connection_id = dest.connection_id
AND src.list_kind = dest.list_kind
AND (src.provider_item_key = dest.provider_item_key OR dest.media_item_id = $2)`},
{"move remaining watch provider favorites", `UPDATE watch_provider_favorite_items SET media_item_id = $2, updated_at = NOW() WHERE media_item_id = $1`},
{"move remaining watch provider list items", `UPDATE watch_provider_list_items SET media_item_id = $2, updated_at = NOW() WHERE media_item_id = $1`},
}
func canonicalizeMediaItemReferencesTx(ctx context.Context, tx pgx.Tx, sourceID, canonicalID string) error {
@@ -110,6 +110,37 @@ func TestMergeStepPlaceholdersAreBounded(t *testing.T) {
}
}
func TestMergeStepsUseCurrentWatchProviderListItemsTable(t *testing.T) {
for _, step := range mediaItemMergeSteps {
if strings.Contains(step.sql, "watch_provider_favorite_items") {
t.Fatalf("merge step %q still references renamed watch provider table", step.name)
}
}
for _, want := range []string{
"merge watch provider list items by provider key",
"merge watch provider list items by connection",
"delete duplicate watch provider list items",
} {
var stepSQL string
for _, step := range mediaItemMergeSteps {
if step.name == want {
stepSQL = normalizeMergeStepSQL(step.sql)
break
}
}
if stepSQL == "" {
t.Fatalf("missing merge step %q", want)
}
if !strings.Contains(stepSQL, "watch_provider_list_items") {
t.Fatalf("merge step %q does not reference watch_provider_list_items", want)
}
if !strings.Contains(stepSQL, "src.list_kind = dest.list_kind") {
t.Fatalf("merge step %q must preserve per-list shadow state", want)
}
}
}
func TestMergeStepsPreserveEbookReaderProgress(t *testing.T) {
var hasMerge bool
var hasDelete bool
@@ -129,6 +160,10 @@ func TestMergeStepsPreserveEbookReaderProgress(t *testing.T) {
}
}
func normalizeMergeStepSQL(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func TestBothCandidatesHaveContentDetectsRealUserData(t *testing.T) {
withFiles := canonicalCandidate{ContentID: "a", ActiveFiles: 2}
withLibraries := canonicalCandidate{ContentID: "b", LibraryCount: 1}
+43 -4
View File
@@ -2,6 +2,7 @@ package userdb
import (
"database/sql"
"fmt"
"strings"
"time"
@@ -115,9 +116,15 @@ func ListFavoritesByMediaItems(db *sql.DB, profileID string, mediaItemIDs []stri
// AddToWatchlist adds a media item to a profile's watchlist.
// If the item is already on the watchlist, the operation is a no-op.
func AddToWatchlist(db *sql.DB, profileID, mediaItemID string) error {
return AddToWatchlistAt(db, profileID, mediaItemID, time.Now().UTC())
}
// AddToWatchlistAt adds a media item to a profile's watchlist with an explicit
// added-at timestamp (used when importing from an external provider).
func AddToWatchlistAt(db *sql.DB, profileID, mediaItemID string, addedAt time.Time) error {
_, err := db.Exec(
`INSERT OR IGNORE INTO watchlist (profile_id, media_item_id, added_at) VALUES (?, ?, ?)`,
profileID, mediaItemID, nowUTC(),
profileID, mediaItemID, addedAt.UTC().Format(time.RFC3339),
)
return err
}
@@ -131,12 +138,44 @@ func RemoveFromWatchlist(db *sql.DB, profileID, mediaItemID string) error {
return err
}
// ListWatchlist returns a paginated list of watchlist entries for a profile,
// ordered by most-recently-added first.
// ReplaceWatchlistOrder assigns sort_index 0..N-1 to the given ids in order and
// clears sort_index on every other watchlist row for the profile. An empty
// slice clears all synced ordering.
func ReplaceWatchlistOrder(db *sql.DB, profileID string, orderedMediaItemIDs []string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin watchlist order tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(
`UPDATE watchlist SET sort_index = NULL WHERE profile_id = ? AND sort_index IS NOT NULL`,
profileID,
); err != nil {
return fmt.Errorf("clear watchlist order: %w", err)
}
for idx, mediaItemID := range orderedMediaItemIDs {
if _, err := tx.Exec(
`UPDATE watchlist SET sort_index = ? WHERE profile_id = ? AND media_item_id = ?`,
idx, profileID, mediaItemID,
); err != nil {
return fmt.Errorf("apply watchlist order: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit watchlist order: %w", err)
}
return nil
}
// ListWatchlist returns a paginated list of watchlist entries for a profile.
// Items with a synced sort_index (mirrored from a provider) come first in that
// order; locally-added items (sort_index NULL) fall back to newest-first.
func ListWatchlist(db *sql.DB, profileID string, limit, offset int) ([]WatchlistEntry, error) {
rows, err := db.Query(
`SELECT profile_id, media_item_id, added_at FROM watchlist
WHERE profile_id = ? ORDER BY added_at DESC LIMIT ? OFFSET ?`,
WHERE profile_id = ?
ORDER BY sort_index IS NULL, sort_index ASC, added_at DESC LIMIT ? OFFSET ?`,
profileID, limit, offset,
)
if err != nil {
+22 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
)
const schemaVersion = 11
const schemaVersion = 12
func runMigrations(db *sql.DB) error {
version, err := userVersion(db)
@@ -115,9 +115,30 @@ func runMigrations(db *sql.DB) error {
}
}
if version < 12 {
if err := migrateToV12(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 12"); err != nil {
return fmt.Errorf("setting sqlite user_version 12: %w", err)
}
}
return tx.Commit()
}
// migrateToV12 adds the nullable watchlist.sort_index column used to mirror a
// provider's watchlist order. NULL means "use added_at ordering".
func migrateToV12(tx *sql.Tx) error {
if columnExists(tx, "watchlist", "sort_index") {
return nil
}
if _, err := tx.Exec("ALTER TABLE watchlist ADD COLUMN sort_index INTEGER"); err != nil {
return fmt.Errorf("adding watchlist.sort_index: %w", err)
}
return nil
}
// migrateToV11 resets legacy completed watch_progress rows to
// position_seconds = 0. The watch-progress model keeps `completed` as a
// one-way watched latch with no resume point; the Continue Watching
+1
View File
@@ -95,6 +95,7 @@ CREATE TABLE IF NOT EXISTS watchlist (
profile_id TEXT NOT NULL,
media_item_id TEXT NOT NULL,
added_at TEXT NOT NULL,
sort_index INTEGER,
PRIMARY KEY (profile_id, media_item_id)
);
+14
View File
@@ -177,10 +177,18 @@ func (s *SQLiteUserStore) AddToWatchlist(_ context.Context, profileID, mediaItem
return AddToWatchlist(s.db, profileID, mediaItemID)
}
func (s *SQLiteUserStore) AddToWatchlistAt(_ context.Context, profileID, mediaItemID string, addedAt time.Time) error {
return AddToWatchlistAt(s.db, profileID, mediaItemID, addedAt)
}
func (s *SQLiteUserStore) RemoveFromWatchlist(_ context.Context, profileID, mediaItemID string) error {
return RemoveFromWatchlist(s.db, profileID, mediaItemID)
}
func (s *SQLiteUserStore) ReplaceWatchlistOrder(_ context.Context, profileID string, orderedMediaItemIDs []string) error {
return ReplaceWatchlistOrder(s.db, profileID, orderedMediaItemIDs)
}
func (s *SQLiteUserStore) ListWatchlist(_ context.Context, profileID string, limit, offset int) ([]userstore.WatchlistEntry, error) {
return ListWatchlist(s.db, profileID, limit, offset)
}
@@ -193,6 +201,12 @@ func (s *SQLiteUserStore) InWatchlist(_ context.Context, profileID, mediaItemID
return InWatchlist(s.db, profileID, mediaItemID)
}
// RemoveWatchedFromWatchlist defaults on for the embedded sqlite backend, which
// does not persist the per-profile preference.
func (s *SQLiteUserStore) RemoveWatchedFromWatchlist(_ context.Context, _ string) (bool, error) {
return true, nil
}
// --- Collections ---
func (s *SQLiteUserStore) CreateCollection(_ context.Context, input userstore.CreateCollectionInput) (*userstore.Collection, error) {
+91
View File
@@ -0,0 +1,91 @@
package userdb
import (
"database/sql"
"testing"
"time"
)
func watchlistIDs(t *testing.T, db *sql.DB, profileID string) []string {
t.Helper()
entries, err := ListWatchlist(db, profileID, 100, 0)
if err != nil {
t.Fatalf("ListWatchlist: %v", err)
}
ids := make([]string, 0, len(entries))
for _, e := range entries {
ids = append(ids, e.MediaItemID)
}
return ids
}
func TestWatchlistOrderMirrorsProviderSequence(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
const profile = "profile-1"
// Add in one order (added_at ascending a, b, c).
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i, id := range []string{"a", "b", "c"} {
if err := AddToWatchlistAt(db, profile, id, base.Add(time.Duration(i)*time.Hour)); err != nil {
t.Fatalf("AddToWatchlistAt %s: %v", id, err)
}
}
// Without a synced order, newest-added comes first.
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "b", "a"}) {
t.Fatalf("default order = %v, want [c b a]", got)
}
// Mirror a provider order of c, a, b.
if err := ReplaceWatchlistOrder(db, profile, []string{"c", "a", "b"}); err != nil {
t.Fatalf("ReplaceWatchlistOrder: %v", err)
}
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "a", "b"}) {
t.Fatalf("synced order = %v, want [c a b]", got)
}
// A locally-added item (no synced index) sorts after the ordered ones.
if err := AddToWatchlistAt(db, profile, "d", base.Add(10*time.Hour)); err != nil {
t.Fatalf("AddToWatchlistAt d: %v", err)
}
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "a", "b", "d"}) {
t.Fatalf("order with local add = %v, want [c a b d]", got)
}
// Re-syncing a shorter provider list drops removed items' index and keeps
// the new order; items no longer present fall back to added_at.
if err := ReplaceWatchlistOrder(db, profile, []string{"b", "a"}); err != nil {
t.Fatalf("ReplaceWatchlistOrder re-sync: %v", err)
}
// b, a are positioned; c and d are unpositioned → after, newest-first (d then c).
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"b", "a", "d", "c"}) {
t.Fatalf("re-synced order = %v, want [b a d c]", got)
}
// Clearing the order reverts everything to added_at DESC.
if err := ReplaceWatchlistOrder(db, profile, nil); err != nil {
t.Fatalf("ReplaceWatchlistOrder clear: %v", err)
}
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"d", "c", "b", "a"}) {
t.Fatalf("cleared order = %v, want [d c b a]", got)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+79 -2
View File
@@ -2,10 +2,13 @@ package pgstore
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -103,11 +106,15 @@ func (s *PostgresUserStore) ListFavoritesByMediaItems(ctx context.Context, profi
// --- Watchlist ---
func (s *PostgresUserStore) AddToWatchlist(ctx context.Context, profileID, mediaItemID string) error {
return s.AddToWatchlistAt(ctx, profileID, mediaItemID, time.Now().UTC())
}
func (s *PostgresUserStore) AddToWatchlistAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO user_watchlist (user_id, profile_id, media_item_id, added_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING`,
s.userID, profileID, mediaItemID, nowUTC(),
s.userID, profileID, mediaItemID, addedAt.UTC(),
)
return err
}
@@ -121,9 +128,12 @@ func (s *PostgresUserStore) RemoveFromWatchlist(ctx context.Context, profileID,
}
func (s *PostgresUserStore) ListWatchlist(ctx context.Context, profileID string, limit, offset int) ([]userstore.WatchlistEntry, error) {
// Items with a synced sort_index (mirrored from a provider) come first in
// that order; locally-added items (sort_index NULL) fall back to newest-first.
rows, err := s.pool.Query(ctx,
`SELECT profile_id, media_item_id, added_at FROM user_watchlist
WHERE user_id = $1 AND profile_id = $2 ORDER BY added_at DESC LIMIT $3 OFFSET $4`,
WHERE user_id = $1 AND profile_id = $2
ORDER BY sort_index ASC NULLS LAST, added_at DESC LIMIT $3 OFFSET $4`,
s.userID, profileID, limit, offset,
)
if err != nil {
@@ -153,6 +163,73 @@ func (s *PostgresUserStore) InWatchlist(ctx context.Context, profileID, mediaIte
return count > 0, err
}
// ReplaceWatchlistOrder mirrors a provider's watchlist order: the given media
// item ids get sort_index 0..N-1 in order, and every other watchlist row for
// the profile is reset to NULL (falling back to added_at ordering). Pass an
// empty slice to clear all synced ordering.
func (s *PostgresUserStore) ReplaceWatchlistOrder(ctx context.Context, profileID string, orderedMediaItemIDs []string) error {
if len(orderedMediaItemIDs) == 0 {
// Clear all synced ordering — revert to added_at ordering.
if _, err := s.pool.Exec(ctx,
`UPDATE user_watchlist SET sort_index = NULL
WHERE user_id = $1 AND profile_id = $2 AND sort_index IS NOT NULL`,
s.userID, profileID,
); err != nil {
return fmt.Errorf("clear watchlist order: %w", err)
}
return nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin watchlist order tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`UPDATE user_watchlist SET sort_index = NULL
WHERE user_id = $1 AND profile_id = $2 AND sort_index IS NOT NULL
AND NOT (media_item_id = ANY($3))`,
s.userID, profileID, orderedMediaItemIDs,
); err != nil {
return fmt.Errorf("clear stale watchlist order: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE user_watchlist w
SET sort_index = v.idx
FROM (
SELECT unnest($3::text[]) AS media_item_id,
generate_subscripts($3::text[], 1) - 1 AS idx
) v
WHERE w.user_id = $1 AND w.profile_id = $2 AND w.media_item_id = v.media_item_id`,
s.userID, profileID, orderedMediaItemIDs,
); err != nil {
return fmt.Errorf("apply watchlist order: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit watchlist order: %w", err)
}
return nil
}
func (s *PostgresUserStore) RemoveWatchedFromWatchlist(ctx context.Context, profileID string) (bool, error) {
var enabled bool
err := s.pool.QueryRow(ctx,
`SELECT remove_watched_from_watchlist FROM user_profiles WHERE user_id = $1 AND id = $2`,
s.userID, profileID,
).Scan(&enabled)
if errors.Is(err, pgx.ErrNoRows) {
// Default-on when the profile row is unexpectedly absent.
return true, nil
}
if err != nil {
return true, fmt.Errorf("get remove_watched_from_watchlist: %w", err)
}
return enabled, nil
}
func (s *PostgresUserStore) ListWatchlistByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error) {
result := make(map[string]bool, len(mediaItemIDs))
if len(mediaItemIDs) == 0 {
+7
View File
@@ -51,10 +51,17 @@ type UserStore interface {
ListFavoritesByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error)
IsFavorite(ctx context.Context, profileID, mediaItemID string) (bool, error)
AddToWatchlist(ctx context.Context, profileID, mediaItemID string) error
AddToWatchlistAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) error
RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error
// ReplaceWatchlistOrder mirrors a provider's watchlist order: the given ids
// get sort_index 0..N-1 in order; all other rows reset to added_at ordering.
ReplaceWatchlistOrder(ctx context.Context, profileID string, orderedMediaItemIDs []string) error
ListWatchlist(ctx context.Context, profileID string, limit, offset int) ([]WatchlistEntry, error)
ListWatchlistByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error)
InWatchlist(ctx context.Context, profileID, mediaItemID string) (bool, error)
// RemoveWatchedFromWatchlist reports the profile's preference for auto-removing
// fully-watched items from the watchlist (defaults true).
RemoveWatchedFromWatchlist(ctx context.Context, profileID string) (bool, error)
// Collections
CreateCollection(ctx context.Context, input CreateCollectionInput) (*Collection, error)
+240
View File
@@ -0,0 +1,240 @@
// Package watchlist holds cross-cutting watchlist behavior that doesn't belong
// to a single handler — currently the auto-removal of fully-watched items.
package watchlist
import (
"context"
"log/slog"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
type itemLookup interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
}
type episodeLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error)
}
type listEventDispatcher interface {
HandleLocalListEvent(ctx context.Context, event watchsync.LocalListEvent) error
}
// maintainerStore is the narrow slice of the user store the maintainer needs.
type maintainerStore interface {
RemoveWatchedFromWatchlist(ctx context.Context, profileID string) (bool, error)
InWatchlist(ctx context.Context, profileID, mediaItemID string) (bool, error)
RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error
ListProgressByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]userstore.WatchProgress, error)
}
// Maintainer removes fully-watched items from a profile's watchlist when a watch
// completes: a movie when it is watched, a series once every episode is watched.
// It implements watchstate.CompletionObserver. Removals route through the same
// local-list-event path as a manual removal, so connected watchlist providers
// mirror the change.
type Maintainer struct {
storeFor func(ctx context.Context, userID int) (maintainerStore, error)
items itemLookup
episodes episodeLookup
dispatcher listEventDispatcher
}
func NewMaintainer(stores userstore.UserStoreProvider, items itemLookup, episodes episodeLookup) *Maintainer {
return &Maintainer{
storeFor: func(ctx context.Context, userID int) (maintainerStore, error) {
return stores.ForUser(ctx, userID)
},
items: items,
episodes: episodes,
}
}
func (m *Maintainer) WithListEventDispatcher(d listEventDispatcher) *Maintainer {
if m == nil {
return nil
}
m.dispatcher = d
return m
}
// HandleWatchedCompleted reacts to completed watches asynchronously so it never
// blocks the caller's watch-recording path.
func (m *Maintainer) HandleWatchedCompleted(_ context.Context, userID int, profileID string, mediaItemIDs []string) {
if m == nil || m.storeFor == nil || userID == 0 || profileID == "" || len(mediaItemIDs) == 0 {
return
}
ids := append([]string(nil), mediaItemIDs...)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := m.process(ctx, userID, profileID, ids); err != nil {
slog.Warn("watchlist auto-remove failed", "user_id", userID, "profile_id", profileID, "error", err)
}
}()
}
func (m *Maintainer) process(ctx context.Context, userID int, profileID string, mediaItemIDs []string) error {
store, err := m.storeFor(ctx, userID)
if err != nil {
return err
}
enabled, err := store.RemoveWatchedFromWatchlist(ctx, profileID)
if err != nil {
return err
}
if !enabled {
return nil
}
// Resolve each completed item to the watchlist entry it clears (a movie
// clears itself; an episode clears its series only once the series is fully
// watched), deduping so a batch completing many episodes of one series
// checks that series once.
seen := make(map[string]struct{})
for _, id := range mediaItemIDs {
candidate, ok, err := m.resolveCandidate(ctx, store, profileID, id)
if err != nil {
return err
}
if !ok {
continue
}
if _, dup := seen[candidate]; dup {
continue
}
seen[candidate] = struct{}{}
if err := m.removeFromWatchlist(ctx, store, userID, profileID, candidate); err != nil {
return err
}
}
return nil
}
// resolveCandidate maps a completed media item id to the watchlist entry id to
// clear, or ok=false when nothing should be removed.
func (m *Maintainer) resolveCandidate(ctx context.Context, store maintainerStore, profileID, mediaItemID string) (string, bool, error) {
// A media-items miss is the normal path for episodes (they live in their own
// table), but a real repo error must not be silently swallowed — remember it
// and surface it if the episode path also can't resolve the id.
var itemLookupErr error
if m.items != nil {
item, err := m.items.GetByID(ctx, mediaItemID)
switch {
case err != nil:
itemLookupErr = err
case item != nil:
switch item.Type {
case "movie":
return item.ContentID, true, nil
case "series":
// A series id can only reach here if the series itself was marked
// watched; still require every episode to be watched before
// clearing it, so a partially-watched series is never dropped.
return m.seriesCandidate(ctx, store, profileID, item.ContentID)
default:
return "", false, nil
}
}
}
// Not a catalog item — treat as an episode and clear the parent series once
// every episode has been watched.
if m.episodes == nil {
return "", false, itemLookupErr
}
episode, err := m.episodes.GetByID(ctx, mediaItemID)
if err != nil {
return "", false, err
}
if episode == nil {
return "", false, itemLookupErr
}
return m.seriesCandidate(ctx, store, profileID, episode.SeriesID)
}
// seriesCandidate returns the series id as a removal candidate only if every
// episode is watched.
func (m *Maintainer) seriesCandidate(ctx context.Context, store maintainerStore, profileID, seriesID string) (string, bool, error) {
fully, err := m.seriesFullyWatched(ctx, store, profileID, seriesID)
if err != nil {
return "", false, err
}
if !fully {
return "", false, nil
}
return seriesID, true, nil
}
func (m *Maintainer) seriesFullyWatched(ctx context.Context, store maintainerStore, profileID, seriesID string) (bool, error) {
if seriesID == "" {
return false, nil
}
episodes, err := m.episodes.ListBySeries(ctx, seriesID)
if err != nil {
return false, err
}
ids := make([]string, 0, len(episodes))
for _, ep := range episodes {
if ep != nil && ep.ContentID != "" {
ids = append(ids, ep.ContentID)
}
}
if len(ids) == 0 {
return false, nil
}
progress, err := store.ListProgressByMediaItems(ctx, profileID, ids)
if err != nil {
return false, err
}
for _, id := range ids {
if p, ok := progress[id]; !ok || !p.Completed {
return false, nil
}
}
return true, nil
}
func (m *Maintainer) removeFromWatchlist(ctx context.Context, store maintainerStore, userID int, profileID, mediaItemID string) error {
in, err := store.InWatchlist(ctx, profileID, mediaItemID)
if err != nil {
return err
}
if !in {
return nil
}
if err := store.RemoveFromWatchlist(ctx, profileID, mediaItemID); err != nil {
return err
}
m.dispatchRemoval(ctx, userID, profileID, mediaItemID)
return nil
}
func (m *Maintainer) dispatchRemoval(ctx context.Context, userID int, profileID, mediaItemID string) {
if m.dispatcher == nil || m.items == nil {
return
}
item, err := m.items.GetByID(ctx, mediaItemID)
if err != nil || item == nil {
return
}
_ = m.dispatcher.HandleLocalListEvent(ctx, watchsync.LocalListEvent{
List: watchsync.ListKindWatchlist,
Change: watchsync.ListChangeRemoved,
UserID: userID,
ProfileID: profileID,
Items: []watchsync.LocalFavorite{{
MediaItemID: item.ContentID,
Kind: item.Type,
Title: item.Title,
Year: item.Year,
IMDbID: item.ImdbID,
TMDBID: item.TmdbID,
TVDBID: item.TvdbID,
FavoritedAt: time.Now().UTC(),
}},
})
}
+233
View File
@@ -0,0 +1,233 @@
package watchlist
import (
"context"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
var errNotFound = errors.New("not found")
type fakeStore struct {
removeWatched bool
watchlist map[string]bool
progress map[string]userstore.WatchProgress
removed []string
}
func (f *fakeStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
return f.removeWatched, nil
}
func (f *fakeStore) InWatchlist(_ context.Context, _ string, mediaItemID string) (bool, error) {
return f.watchlist[mediaItemID], nil
}
func (f *fakeStore) RemoveFromWatchlist(_ context.Context, _ string, mediaItemID string) error {
delete(f.watchlist, mediaItemID)
f.removed = append(f.removed, mediaItemID)
return nil
}
func (f *fakeStore) ListProgressByMediaItems(_ context.Context, _ string, ids []string) (map[string]userstore.WatchProgress, error) {
out := make(map[string]userstore.WatchProgress, len(ids))
for _, id := range ids {
if p, ok := f.progress[id]; ok {
out[id] = p
}
}
return out, nil
}
type fakeItems map[string]*models.MediaItem
func (f fakeItems) GetByID(_ context.Context, id string) (*models.MediaItem, error) {
if item, ok := f[id]; ok {
return item, nil
}
return nil, errNotFound
}
type fakeEpisodes struct {
byID map[string]*models.Episode
bySeries map[string][]*models.Episode
}
func (f fakeEpisodes) GetByID(_ context.Context, id string) (*models.Episode, error) {
if ep, ok := f.byID[id]; ok {
return ep, nil
}
return nil, errNotFound
}
func (f fakeEpisodes) ListBySeries(_ context.Context, seriesID string) ([]*models.Episode, error) {
return f.bySeries[seriesID], nil
}
type fakeDispatcher struct {
events []watchsync.LocalListEvent
}
func (f *fakeDispatcher) HandleLocalListEvent(_ context.Context, event watchsync.LocalListEvent) error {
f.events = append(f.events, event)
return nil
}
func newMaintainer(store *fakeStore, items fakeItems, episodes fakeEpisodes, dispatcher *fakeDispatcher) *Maintainer {
return &Maintainer{
storeFor: func(context.Context, int) (maintainerStore, error) { return store, nil },
items: items,
episodes: episodes,
dispatcher: dispatcher,
}
}
func completed() userstore.WatchProgress { return userstore.WatchProgress{Completed: true} }
func TestMaintainerRemovesWatchedMovie(t *testing.T) {
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{"movie-1": true}}
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie", Title: "M", ImdbID: "tt1"}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, fakeEpisodes{}, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if store.watchlist["movie-1"] {
t.Fatal("watched movie should be removed from the watchlist")
}
if len(dispatcher.events) != 1 || dispatcher.events[0].List != watchsync.ListKindWatchlist ||
dispatcher.events[0].Change != watchsync.ListChangeRemoved {
t.Fatalf("expected one watchlist-removed event, got %+v", dispatcher.events)
}
}
func TestMaintainerSkipsMovieNotOnWatchlist(t *testing.T) {
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{}}
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie"}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, fakeEpisodes{}, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if len(store.removed) != 0 || len(dispatcher.events) != 0 {
t.Fatalf("nothing should happen for a movie not on the watchlist: removed=%v events=%v", store.removed, dispatcher.events)
}
}
func TestMaintainerRemovesSeriesOnlyWhenFullyWatched(t *testing.T) {
episodes := fakeEpisodes{
byID: map[string]*models.Episode{
"ep-1": {ContentID: "ep-1", SeriesID: "series-1"},
"ep-2": {ContentID: "ep-2", SeriesID: "series-1"},
},
bySeries: map[string][]*models.Episode{
"series-1": {{ContentID: "ep-1"}, {ContentID: "ep-2"}},
},
}
items := fakeItems{"series-1": {ContentID: "series-1", Type: "series", TvdbID: "99"}}
// Only ep-1 watched: series stays on the watchlist.
partial := &fakeStore{
removeWatched: true,
watchlist: map[string]bool{"series-1": true},
progress: map[string]userstore.WatchProgress{"ep-1": completed()},
}
m := newMaintainer(partial, items, episodes, &fakeDispatcher{})
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if !partial.watchlist["series-1"] {
t.Fatal("a partially-watched series must stay on the watchlist")
}
// Both episodes watched: series is removed.
full := &fakeStore{
removeWatched: true,
watchlist: map[string]bool{"series-1": true},
progress: map[string]userstore.WatchProgress{"ep-1": completed(), "ep-2": completed()},
}
dispatcher := &fakeDispatcher{}
m = newMaintainer(full, items, episodes, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-2"}); err != nil {
t.Fatalf("process: %v", err)
}
if full.watchlist["series-1"] {
t.Fatal("a fully-watched series must leave the watchlist")
}
if len(dispatcher.events) != 1 {
t.Fatalf("expected one removal event for the series, got %+v", dispatcher.events)
}
}
func TestMaintainerRespectsPreferenceOff(t *testing.T) {
store := &fakeStore{removeWatched: false, watchlist: map[string]bool{"movie-1": true}}
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie"}}
dispatcher := &fakeDispatcher{}
m := newMaintainer(store, items, fakeEpisodes{}, dispatcher)
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
t.Fatalf("process: %v", err)
}
if !store.watchlist["movie-1"] || len(store.removed) != 0 {
t.Fatal("preference off must leave the watchlist untouched")
}
}
func TestMaintainerDedupesEpisodesOfSameSeries(t *testing.T) {
episodes := fakeEpisodes{
byID: map[string]*models.Episode{
"ep-1": {ContentID: "ep-1", SeriesID: "series-1"},
"ep-2": {ContentID: "ep-2", SeriesID: "series-1"},
},
bySeries: map[string][]*models.Episode{
"series-1": {{ContentID: "ep-1"}, {ContentID: "ep-2"}},
},
}
items := fakeItems{"series-1": {ContentID: "series-1", Type: "series"}}
store := &fakeStore{
removeWatched: true,
watchlist: map[string]bool{"series-1": true},
progress: map[string]userstore.WatchProgress{"ep-1": completed(), "ep-2": completed()},
}
m := newMaintainer(store, items, episodes, &fakeDispatcher{})
// Both episodes complete in one batch; the series should be removed once.
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-1", "ep-2"}); err != nil {
t.Fatalf("process: %v", err)
}
if got := len(store.removed); got != 1 {
t.Fatalf("series should be removed exactly once, got %d removals (%v)", got, store.removed)
}
}
type erroringItems struct{ err error }
func (e erroringItems) GetByID(context.Context, string) (*models.MediaItem, error) {
return nil, e.err
}
type emptyEpisodes struct{}
func (emptyEpisodes) GetByID(context.Context, string) (*models.Episode, error) { return nil, nil }
func (emptyEpisodes) ListBySeries(context.Context, string) ([]*models.Episode, error) {
return nil, nil
}
func TestMaintainerPropagatesItemLookupError(t *testing.T) {
boom := errors.New("db down")
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{}}
m := &Maintainer{
storeFor: func(context.Context, int) (maintainerStore, error) { return store, nil },
items: erroringItems{err: boom},
episodes: emptyEpisodes{},
}
if err := m.process(context.Background(), 7, "profile-1", []string{"x"}); err == nil {
t.Fatal("a transient catalog lookup error must propagate, not be swallowed")
}
}
+36 -2
View File
@@ -16,8 +16,18 @@ type LeafWatchTarget struct {
}
type Service struct {
storeProvider userstore.UserStoreProvider
identity *StableIdentityResolver
storeProvider userstore.UserStoreProvider
identity *StableIdentityResolver
completionObserver CompletionObserver
}
// CompletionObserver is notified after a watch is recorded as completed via a
// local action (manual mark-watched, playback completion, or a Jellyfin-compat
// mark-played). It powers cross-cutting reactions such as removing watched
// items from the watchlist. It is invoked best-effort and must not block the
// recording path.
type CompletionObserver interface {
HandleWatchedCompleted(ctx context.Context, userID int, profileID string, mediaItemIDs []string)
}
type PlaybackStopResult struct {
@@ -45,6 +55,21 @@ func (s *Service) WithStableIdentityResolver(identity *StableIdentityResolver) *
return s
}
func (s *Service) WithCompletionObserver(observer CompletionObserver) *Service {
if s == nil {
return nil
}
s.completionObserver = observer
return s
}
func (s *Service) notifyWatchedCompleted(ctx context.Context, userID int, profileID string, mediaItemIDs []string) {
if s == nil || s.completionObserver == nil || len(mediaItemIDs) == 0 {
return
}
s.completionObserver.HandleWatchedCompleted(ctx, userID, profileID, mediaItemIDs)
}
func (s *Service) RecordManualMarkWatched(ctx context.Context, userID int, profileID string, targets []LeafWatchTarget, watchedAt time.Time) error {
_, err := s.RecordManualMarkWatchedWithResult(ctx, userID, profileID, targets, watchedAt)
return err
@@ -114,6 +139,9 @@ func (s *Service) RecordPlaybackStop(
}
result.Completed = entry.Completed
result.HistoryID = historyID
if entry.Completed {
s.notifyWatchedCompleted(ctx, userID, profileID, []string{targetID})
}
return result, nil
}
@@ -318,6 +346,11 @@ func (s *Service) recordMarkWatched(
}
result.Entries = append(result.Entries, histEntry)
}
completedIDs := make([]string, 0, len(targets))
for _, target := range targets {
completedIDs = append(completedIDs, target.MediaItemID)
}
s.notifyWatchedCompleted(ctx, userID, profileID, completedIDs)
return result, nil
}
@@ -431,6 +464,7 @@ func (s *Service) recordMarkWatchedBatch(
return err
}
}
s.notifyWatchedCompleted(ctx, userID, profileID, targetIDs)
return nil
}
+694
View File
@@ -0,0 +1,694 @@
package watchsync
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// This file generalizes the personal-list sync pipeline (import, export,
// removal, and real-time local-change propagation) so it runs once for both the
// favorites list and the watchlist. The only things that differ between the two
// lists are the provider endpoints, the local store operations, the connection
// toggles, and which counters/timestamps a run records — all captured by a
// listBinding.
// listRow is the kind-neutral shape of a local list membership row (a favorite
// or a watchlist entry): just the media item id and its added-at timestamp.
type listRow struct {
MediaItemID string
AddedAt string
}
// listBinding captures everything that differs between the favorites and
// watchlist pipelines so the shared machinery can operate on either list.
type listBinding struct {
kind ListKind
importEnabled func(Connection) bool
exportEnabled func(Connection) bool
removalsEnabled func(Connection) bool
capImport func(Capabilities) bool
capExport func(Capabilities) bool
capRemove func(Capabilities) bool
// Provider interface resolution. ok=false means the provider does not
// implement the capability for this list kind.
fetchBatch func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider) (batch FavoriteImportBatch, ok bool, err error)
exportItems func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider, items []LocalFavorite) (result ExportResult, ok bool, err error)
removeItems func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider, items []LocalFavorite) (result ExportResult, ok bool, err error)
// Local list operations against the per-user store.
localAdd func(ctx context.Context, store userstore.UserStore, profileID, mediaItemID string, at time.Time) error
localRemove func(ctx context.Context, store userstore.UserStore, profileID, mediaItemID string) error
localList func(ctx context.Context, store userstore.UserStore, profileID string, limit, offset int) ([]listRow, error)
setLastSync func(conn *Connection, at time.Time)
setImportCounts func(run *SyncRun, found, imported int)
setExportCounts func(run *SyncRun, found, sent int)
setRemovalCount func(run *SyncRun, removed int)
// Optional order mirroring (watchlist only): when all three are set and
// gated true, importList mirrors the provider's returned item order locally.
orderEnabled func(Connection) bool
capOrder func(Capabilities) bool
applyOrder func(ctx context.Context, store userstore.UserStore, profileID string, orderedIDs []string) error
}
func (s *Service) favoritesBinding() listBinding {
return listBinding{
kind: ListKindFavorites,
importEnabled: func(c Connection) bool { return c.ImportFavoritesEnabled },
exportEnabled: func(c Connection) bool { return c.ExportFavoritesEnabled },
removalsEnabled: func(c Connection) bool { return c.SyncFavoriteRemovalsEnabled },
capImport: func(c Capabilities) bool { return c.ImportFavorites },
capExport: func(c Capabilities) bool { return c.ExportFavorites },
capRemove: func(c Capabilities) bool { return c.RemoveFavorites },
fetchBatch: func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider) (FavoriteImportBatch, bool, error) {
importer, ok := provider.(FavoriteImporter)
if !ok {
return FavoriteImportBatch{}, false, nil
}
if batchImporter, ok := provider.(FavoriteBatchImporter); ok {
batch, err := batchImporter.FetchFavoritesBatch(ctx, cfg, conn)
return batch, true, err
}
rows, err := importer.FetchFavorites(ctx, cfg, conn)
if err != nil {
return FavoriteImportBatch{}, true, err
}
return FavoriteImportBatch{Rows: rows}, true, nil
},
exportItems: func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider, items []LocalFavorite) (ExportResult, bool, error) {
exporter, ok := provider.(FavoriteExporter)
if !ok {
return ExportResult{}, false, nil
}
res, err := exporter.ExportFavorites(ctx, cfg, conn, items)
return res, true, err
},
removeItems: func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider, items []LocalFavorite) (ExportResult, bool, error) {
remover, ok := provider.(FavoriteRemover)
if !ok {
return ExportResult{}, false, nil
}
res, err := remover.RemoveFavorites(ctx, cfg, conn, items)
return res, true, err
},
localAdd: func(ctx context.Context, store userstore.UserStore, profileID, mediaItemID string, at time.Time) error {
return store.AddFavoriteAt(ctx, profileID, mediaItemID, at)
},
localRemove: func(ctx context.Context, store userstore.UserStore, profileID, mediaItemID string) error {
return store.RemoveFavorite(ctx, profileID, mediaItemID)
},
localList: func(ctx context.Context, store userstore.UserStore, profileID string, limit, offset int) ([]listRow, error) {
favorites, err := store.ListFavorites(ctx, profileID, limit, offset)
if err != nil {
return nil, err
}
rows := make([]listRow, 0, len(favorites))
for _, fav := range favorites {
rows = append(rows, listRow{MediaItemID: fav.MediaItemID, AddedAt: fav.AddedAt})
}
return rows, nil
},
setLastSync: func(conn *Connection, at time.Time) { conn.LastFavoritesSyncAt = &at },
setImportCounts: func(run *SyncRun, found, imported int) {
run.InboundFavoritesFound, run.InboundFavoritesImported = found, imported
},
setExportCounts: func(run *SyncRun, found, sent int) {
run.OutboundFavoritesFound, run.OutboundFavoritesSent = found, sent
},
setRemovalCount: func(run *SyncRun, removed int) { run.FavoriteRemovalsSent = removed },
}
}
func (s *Service) watchlistBinding() listBinding {
return listBinding{
kind: ListKindWatchlist,
importEnabled: func(c Connection) bool { return c.ImportWatchlistEnabled },
exportEnabled: func(c Connection) bool { return c.ExportWatchlistEnabled },
removalsEnabled: func(c Connection) bool { return c.SyncWatchlistRemovalsEnabled },
capImport: func(c Capabilities) bool { return c.ImportWatchlist },
capExport: func(c Capabilities) bool { return c.ExportWatchlist },
capRemove: func(c Capabilities) bool { return c.RemoveWatchlist },
fetchBatch: func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider) (FavoriteImportBatch, bool, error) {
importer, ok := provider.(WatchlistImporter)
if !ok {
return FavoriteImportBatch{}, false, nil
}
if batchImporter, ok := provider.(WatchlistBatchImporter); ok {
batch, err := batchImporter.FetchWatchlistBatch(ctx, cfg, conn)
return batch, true, err
}
rows, err := importer.FetchWatchlist(ctx, cfg, conn)
if err != nil {
return FavoriteImportBatch{}, true, err
}
return FavoriteImportBatch{Rows: rows}, true, nil
},
exportItems: func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider, items []LocalFavorite) (ExportResult, bool, error) {
exporter, ok := provider.(WatchlistExporter)
if !ok {
return ExportResult{}, false, nil
}
res, err := exporter.ExportWatchlist(ctx, cfg, conn, items)
return res, true, err
},
removeItems: func(ctx context.Context, cfg ServerConfig, conn Connection, provider Provider, items []LocalFavorite) (ExportResult, bool, error) {
remover, ok := provider.(WatchlistRemover)
if !ok {
return ExportResult{}, false, nil
}
res, err := remover.RemoveWatchlist(ctx, cfg, conn, items)
return res, true, err
},
localAdd: func(ctx context.Context, store userstore.UserStore, profileID, mediaItemID string, at time.Time) error {
return store.AddToWatchlistAt(ctx, profileID, mediaItemID, at)
},
localRemove: func(ctx context.Context, store userstore.UserStore, profileID, mediaItemID string) error {
return store.RemoveFromWatchlist(ctx, profileID, mediaItemID)
},
localList: func(ctx context.Context, store userstore.UserStore, profileID string, limit, offset int) ([]listRow, error) {
entries, err := store.ListWatchlist(ctx, profileID, limit, offset)
if err != nil {
return nil, err
}
rows := make([]listRow, 0, len(entries))
for _, entry := range entries {
rows = append(rows, listRow{MediaItemID: entry.MediaItemID, AddedAt: entry.AddedAt})
}
return rows, nil
},
setLastSync: func(conn *Connection, at time.Time) { conn.LastWatchlistSyncAt = &at },
setImportCounts: func(run *SyncRun, found, imported int) {
run.InboundWatchlistFound, run.InboundWatchlistImported = found, imported
},
setExportCounts: func(run *SyncRun, found, sent int) {
run.OutboundWatchlistFound, run.OutboundWatchlistSent = found, sent
},
setRemovalCount: func(run *SyncRun, removed int) { run.WatchlistRemovalsSent = removed },
orderEnabled: func(c Connection) bool { return c.SyncWatchlistOrderEnabled },
capOrder: func(c Capabilities) bool { return c.ProvidesWatchlistOrder },
applyOrder: func(ctx context.Context, store userstore.UserStore, profileID string, orderedIDs []string) error {
return store.ReplaceWatchlistOrder(ctx, profileID, orderedIDs)
},
}
}
func (s *Service) listBindings() []listBinding {
return []listBinding{s.favoritesBinding(), s.watchlistBinding()}
}
func (s *Service) bindingForKind(kind ListKind) (listBinding, bool) {
switch kind {
case ListKindFavorites:
return s.favoritesBinding(), true
case ListKindWatchlist:
return s.watchlistBinding(), true
default:
return listBinding{}, false
}
}
type ImportListResult struct {
Found int
Imported int
Unmatched int
Removed int
Warnings []string
}
// importList pulls the provider's list (favorites or watchlist) and mirrors it
// into the local list, recording shadow state and reconciling remote removals.
func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerConfig, provider Provider, b listBinding) (ImportListResult, error) {
if s.matcher == nil {
return ImportListResult{}, fmt.Errorf("watch provider matcher is not configured")
}
if s.storeProvider == nil {
return ImportListResult{}, fmt.Errorf("user store provider is not configured")
}
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
if err != nil {
return ImportListResult{}, fmt.Errorf("open user store: %w", err)
}
batch, ok, err := b.fetchBatch(ctx, cfg, conn, provider)
if err != nil {
return ImportListResult{}, err
}
if !ok {
return ImportListResult{}, fmt.Errorf("provider %q does not implement %s import", conn.Provider, b.kind)
}
rows := batch.Rows
result := ImportListResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
seenRemoteKeys := make(map[string]bool, len(rows))
states := make([]ListItemState, 0, len(rows))
// orderedIDs records matched local ids in the order the provider returned
// them, so an order-providing list (e.g. MDBList watchlist) can mirror its
// sort locally.
orderedIDs := make([]string, 0, len(rows))
for _, row := range rows {
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
if err != nil {
return result, err
}
if match == nil {
result.Unmatched++
if reason != "" {
result.Warnings = append(result.Warnings, reason)
}
continue
}
if err := b.localAdd(ctx, store, conn.ProfileID, match.MediaItemID, row.FavoritedAt); err != nil {
return result, err
}
orderedIDs = append(orderedIDs, match.MediaItemID)
result.Imported++
key := row.ProviderItemKey
if key == "" {
key = providerItemKeyForRemoteFavorite(row)
}
if key != "" {
seenRemoteKeys[key] = true
}
listedAt := row.FavoritedAt
states = append(states, ListItemState{
ConnectionID: conn.ID,
ListKind: b.kind,
MediaItemID: match.MediaItemID,
ProviderItemKey: key,
Kind: row.Kind,
Title: row.Title,
Year: row.Year,
RemotePresent: true,
LocalPresent: true,
LastSeenRemoteAt: &listedAt,
LastSeenLocalAt: &listedAt,
})
}
if err := s.repo.UpsertListItemStates(ctx, states); err != nil {
return result, err
}
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
return result, err
}
if b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
b.orderEnabled(conn) && b.capOrder(provider.Capabilities()) {
if err := b.applyOrder(ctx, store, conn.ProfileID, orderedIDs); err != nil {
return result, err
}
}
now := s.now()
b.setLastSync(&conn, now)
conn.LastError = ""
conn.SyncCursors = mergeSyncCursors(conn.SyncCursors, batch.UpdatedCursors)
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
return result, err
}
return result, nil
}
func (s *Service) reconcileMissingRemoteListItems(ctx context.Context, conn Connection, store userstore.UserStore, b listBinding, seenRemoteKeys map[string]bool, result *ImportListResult) error {
states, err := s.repo.ListListItemStates(ctx, conn.ID, b.kind)
if err != nil {
return err
}
now := s.now()
for _, state := range states {
if !state.RemotePresent || state.ProviderItemKey == "" || seenRemoteKeys[state.ProviderItemKey] {
continue
}
if b.removalsEnabled(conn) && state.LocalPresent {
if err := b.localRemove(ctx, store, conn.ProfileID, state.MediaItemID); err != nil {
return err
}
if err := s.repo.MarkListItemLocalRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
return err
}
result.Removed++
}
if err := s.repo.MarkListItemRemoteRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
return err
}
}
return nil
}
type ExportListResult struct {
LocalFound int
Queued int
Sent int
Failed int
Warnings []string
}
// exportList pushes the local list (favorites or watchlist) to the provider,
// sending only items not yet known-present remotely.
func (s *Service) exportList(ctx context.Context, conn Connection, cfg ServerConfig, provider Provider, b listBinding) (ExportListResult, error) {
if s.storeProvider == nil {
return ExportListResult{}, fmt.Errorf("user store provider is not configured")
}
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
if err != nil {
return ExportListResult{}, fmt.Errorf("open user store: %w", err)
}
rows, err := b.localList(ctx, store, conn.ProfileID, 10000, 0)
if err != nil {
return ExportListResult{}, err
}
result := ExportListResult{LocalFound: len(rows)}
items, states, warnings, err := s.localItemsFromRows(ctx, conn, b, rows)
if err != nil {
return result, err
}
result.Warnings = append(result.Warnings, warnings...)
if err := s.repo.UpsertListItemStates(ctx, states); err != nil {
return result, err
}
for {
pending, err := s.repo.ListPendingListItemExports(ctx, conn.ID, b.kind, 100)
if err != nil {
return result, err
}
if len(pending) == 0 {
break
}
byMedia := make(map[string]LocalFavorite, len(items))
for _, item := range items {
byMedia[item.MediaItemID] = item
}
toSend := make([]LocalFavorite, 0, len(pending))
for _, state := range pending {
item, ok := byMedia[state.MediaItemID]
if !ok {
if err := s.repo.MarkListItemLocalRemoved(ctx, conn.ID, b.kind, state.MediaItemID, s.now()); err != nil {
return result, err
}
continue
}
toSend = append(toSend, item)
}
if len(toSend) == 0 {
continue
}
result.Queued += len(toSend)
exportResult, ok, err := b.exportItems(ctx, cfg, conn, provider, toSend)
if !ok {
return result, fmt.Errorf("provider %q does not implement %s export", conn.Provider, b.kind)
}
if err != nil {
for _, item := range toSend {
_ = s.repo.MarkListItemError(ctx, conn.ID, b.kind, item.MediaItemID, err.Error())
}
result.Failed += len(toSend)
return result, err
}
now := s.now()
sent := exportResultSentSet(exportResult)
for _, item := range toSend {
if sent[item.MediaItemID] || sent[item.ProviderItemKey] {
if err := s.repo.MarkListItemExported(ctx, conn.ID, b.kind, item.MediaItemID, now); err != nil {
return result, err
}
result.Sent++
continue
}
// Anything not confirmed sent (not_found, failed, or omitted from the
// provider response) is marked with an error so it leaves the pending
// set this run; the next run's UpsertListItemStates clears the error
// and re-attempts, so transient failures still retry.
msg := exportFailureReason(exportResult, item, b.kind)
if err := s.repo.MarkListItemError(ctx, conn.ID, b.kind, item.MediaItemID, msg); err != nil {
return result, err
}
result.Warnings = append(result.Warnings, msg+": "+item.MediaItemID)
}
}
now := s.now()
b.setLastSync(&conn, now)
conn.LastError = ""
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
return result, err
}
return result, nil
}
// removePendingListItems pushes pending local removals to the provider (items
// dropped locally but still present remotely).
func (s *Service) removePendingListItems(ctx context.Context, conn Connection, cfg ServerConfig, provider Provider, b listBinding) (int, error) {
removed := 0
// Removal failures intentionally do not set last_error (which would strand
// the row from ListPendingListItemRemovals, since there is no per-run upsert
// to clear it). Instead we track items attempted this run in memory so the
// loop terminates, and leave failed rows pending for the next scheduled run.
attempted := make(map[string]bool)
for {
pending, err := s.repo.ListPendingListItemRemovals(ctx, conn.ID, b.kind, 100)
if err != nil {
return removed, err
}
items := make([]LocalFavorite, 0, len(pending))
for _, state := range pending {
if attempted[state.MediaItemID] {
continue
}
items = append(items, LocalFavorite{
MediaItemID: state.MediaItemID,
ProviderItemKey: state.ProviderItemKey,
Kind: state.Kind,
Title: state.Title,
Year: state.Year,
})
}
if len(items) == 0 {
return removed, nil
}
result, ok, err := b.removeItems(ctx, cfg, conn, provider, items)
if !ok {
return removed, fmt.Errorf("provider %q does not implement %s removal", conn.Provider, b.kind)
}
if err != nil {
// Leave the rows pending so the next scheduled run retries them.
return removed, err
}
now := s.now()
sent := exportResultSentSet(result)
for _, item := range items {
attempted[item.MediaItemID] = true
// Sent (removed) and NotFound (already absent remotely) both reconcile
// the row; true failures stay pending for the next run.
if sent[item.MediaItemID] || sent[item.ProviderItemKey] ||
containsString(result.NotFound, item.MediaItemID) || containsString(result.NotFound, item.ProviderItemKey) {
if err := s.repo.MarkListItemRemoteRemoved(ctx, conn.ID, b.kind, item.MediaItemID, now); err != nil {
return removed, err
}
removed++
}
}
}
}
func (s *Service) localItemsFromRows(ctx context.Context, conn Connection, b listBinding, rows []listRow) ([]LocalFavorite, []ListItemState, []string, error) {
ids := make([]string, 0, len(rows))
addedAtByID := make(map[string]time.Time, len(rows))
for _, row := range rows {
ids = append(ids, row.MediaItemID)
if addedAt, err := time.Parse(time.RFC3339, row.AddedAt); err == nil {
addedAtByID[row.MediaItemID] = addedAt
}
}
type listMediaResolver interface {
GetListMediaItems(ctx context.Context, mediaItemIDs []string) (map[string]LocalFavorite, error)
}
resolver, ok := s.repo.(listMediaResolver)
if !ok {
return nil, nil, nil, fmt.Errorf("list media resolver is not configured")
}
resolved, err := resolver.GetListMediaItems(ctx, ids)
if err != nil {
return nil, nil, nil, err
}
items := make([]LocalFavorite, 0, len(rows))
states := make([]ListItemState, 0, len(rows))
var warnings []string
for _, row := range rows {
item, ok := resolved[row.MediaItemID]
if !ok {
warnings = append(warnings, string(b.kind)+" media item not found: "+row.MediaItemID)
continue
}
item.FavoritedAt = addedAtByID[row.MediaItemID]
if item.FavoritedAt.IsZero() {
item.FavoritedAt = s.now()
}
if item.Kind != historyimport.KindMovie && item.Kind != historyimport.KindSeries {
warnings = append(warnings, string(b.kind)+" kind is not supported by provider: "+row.MediaItemID)
continue
}
if item.ProviderItemKey == "" {
warnings = append(warnings, string(b.kind)+" item has no provider ids: "+row.MediaItemID)
continue
}
items = append(items, item)
listedAt := item.FavoritedAt
states = append(states, ListItemState{
ConnectionID: conn.ID,
ListKind: b.kind,
MediaItemID: item.MediaItemID,
ProviderItemKey: item.ProviderItemKey,
Kind: item.Kind,
Title: item.Title,
Year: item.Year,
RemotePresent: false,
LocalPresent: true,
LastSeenLocalAt: &listedAt,
})
}
return items, states, warnings, nil
}
// HandleLocalListEvent mirrors a real-time local list change (add/remove of a
// favorite or watchlist item) to the providers bound to that list kind. It is
// fire-and-forget so the originating API request is never blocked on provider
// I/O.
func (s *Service) HandleLocalListEvent(ctx context.Context, event LocalListEvent) error {
if event.UserID == 0 || event.ProfileID == "" || len(event.Items) == 0 {
return nil
}
if event.List == "" {
event.List = ListKindFavorites
}
go func() {
bg, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.processLocalListEvent(bg, event); err != nil {
slog.Warn("failed to dispatch local list provider event", "list", event.List, "change", event.Change, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err)
}
}()
return nil
}
func (s *Service) processLocalListEvent(ctx context.Context, event LocalListEvent) error {
b, ok := s.bindingForKind(event.List)
if !ok {
return fmt.Errorf("unknown list kind %q", event.List)
}
conns, err := s.repo.ListListEventConnections(ctx, event.UserID, event.ProfileID, event.List)
if err != nil {
return err
}
for _, conn := range conns {
provider, ok := s.registry.Get(conn.Provider)
if !ok {
continue
}
cfg, err := s.serverConfig(ctx, conn.Provider)
if err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
continue
}
conn, err = s.refreshConnectionIfNeeded(ctx, provider, cfg, conn)
if err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
continue
}
switch event.Change {
case ListChangeAdded:
if !b.capExport(provider.Capabilities()) {
continue
}
if err := s.exportLocalListItems(ctx, conn, cfg, provider, b, event.Items); err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
}
case ListChangeRemoved:
now := s.now()
for _, item := range event.Items {
if err := s.repo.MarkListItemLocalRemoved(ctx, conn.ID, b.kind, item.MediaItemID, now); err != nil {
return err
}
}
if !b.removalsEnabled(conn) || !b.capRemove(provider.Capabilities()) {
continue
}
result, ok, err := b.removeItems(ctx, cfg, conn, provider, event.Items)
if !ok {
continue
}
if err != nil {
// Leave remote_present set so the scheduled reconcile retries.
s.recordLocalWatchEventError(ctx, conn, err)
continue
}
sent := exportResultSentSet(result)
for _, item := range event.Items {
if sent[item.MediaItemID] || sent[item.ProviderItemKey] ||
containsString(result.NotFound, item.MediaItemID) || containsString(result.NotFound, item.ProviderItemKey) {
if err := s.repo.MarkListItemRemoteRemoved(ctx, conn.ID, b.kind, item.MediaItemID, now); err != nil {
return err
}
}
}
}
}
return nil
}
func (s *Service) exportLocalListItems(ctx context.Context, conn Connection, cfg ServerConfig, provider Provider, b listBinding, items []LocalFavorite) error {
states := make([]ListItemState, 0, len(items))
// toSend carries the normalized items (with a computed ProviderItemKey) so
// the provider receives the same keys we record in shadow state.
toSend := make([]LocalFavorite, 0, len(items))
for _, item := range items {
if item.ProviderItemKey == "" {
item.ProviderItemKey = providerItemKeyForLocalFavorite(item)
}
if item.ProviderItemKey == "" {
continue
}
listedAt := item.FavoritedAt
if listedAt.IsZero() {
listedAt = s.now()
}
states = append(states, ListItemState{
ConnectionID: conn.ID,
ListKind: b.kind,
MediaItemID: item.MediaItemID,
ProviderItemKey: item.ProviderItemKey,
Kind: item.Kind,
Title: item.Title,
Year: item.Year,
RemotePresent: false,
LocalPresent: true,
LastSeenLocalAt: &listedAt,
})
toSend = append(toSend, item)
}
if err := s.repo.UpsertListItemStates(ctx, states); err != nil {
return err
}
result, ok, err := b.exportItems(ctx, cfg, conn, provider, toSend)
if !ok {
return nil
}
if err != nil {
return err
}
now := s.now()
sent := exportResultSentSet(result)
for _, item := range toSend {
if sent[item.MediaItemID] || sent[item.ProviderItemKey] {
if err := s.repo.MarkListItemExported(ctx, conn.ID, b.kind, item.MediaItemID, now); err != nil {
return err
}
}
}
b.setLastSync(&conn, now)
conn.LastError = ""
_, err = s.repo.UpsertConnection(ctx, conn)
return err
}
@@ -52,15 +52,18 @@ func (p *Provider) DisplayName() string {
}
func (p *Provider) Capabilities() watchsync.Capabilities {
// MDBList exposes a single list — its watchlist — so it binds to Silo's
// watchlist rather than favorites.
return watchsync.Capabilities{
ImportWatched: true,
ImportProgress: true,
ExportWatched: true,
ExportUnwatched: true,
ImportFavorites: true,
ExportFavorites: true,
RemoveFavorites: true,
ScrobblePlayback: true,
ImportWatched: true,
ImportProgress: true,
ExportWatched: true,
ExportUnwatched: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ProvidesWatchlistOrder: true,
ScrobblePlayback: true,
}
}
@@ -305,7 +308,7 @@ func (p *Provider) sendWatched(ctx context.Context, conn watchsync.Connection, p
return result, nil
}
func (p *Provider) FetchFavorites(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) {
func (p *Provider) FetchWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) {
now := time.Now().UTC()
var rows []watchsync.RemoteFavorite
for offset := 0; ; {
@@ -365,12 +368,12 @@ func watchlistRowsFromPayload(providerKey string, payload mdblistWatchlistRespon
return rows
}
func (p *Provider) ExportFavorites(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, favorites []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, conn, favorites, "/watchlist/items/add")
func (p *Provider) ExportWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, conn, items, "/watchlist/items/add")
}
func (p *Provider) RemoveFavorites(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, favorites []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, conn, favorites, "/watchlist/items/remove")
func (p *Provider) RemoveWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, conn, items, "/watchlist/items/remove")
}
func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string) (watchsync.ExportResult, error) {
@@ -23,9 +23,15 @@ func TestProviderIdentityAndCapabilities(t *testing.T) {
t.Fatalf("got display name %q, want MDBList", p.DisplayName())
}
caps := p.Capabilities()
if !caps.ScrobblePlayback || !caps.ImportWatched || !caps.ExportFavorites {
if !caps.ScrobblePlayback || !caps.ImportWatched || !caps.ExportWatchlist {
t.Fatalf("unexpected capabilities: %#v", caps)
}
if caps.ImportFavorites || caps.ExportFavorites || caps.RemoveFavorites {
t.Fatalf("mdblist should bind to watchlist, not favorites: %#v", caps)
}
if !caps.ProvidesWatchlistOrder {
t.Fatalf("mdblist should provide watchlist order: %#v", caps)
}
var _ watchsync.APIKeyAuthProvider = p
}
@@ -274,7 +280,7 @@ func TestScrobbleStartUsesEpisodeShape(t *testing.T) {
}
}
func TestExportFavoritesSendsToWatchlistAdd(t *testing.T) {
func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) {
var gotPath string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -291,9 +297,9 @@ func TestExportFavoritesSendsToWatchlistAdd(t *testing.T) {
{MediaItemID: "m1", Kind: historyimport.KindMovie, IMDbID: "tt0111161"},
{MediaItemID: "s1", Kind: historyimport.KindSeries, IMDbID: "tt0903747"},
}
result, err := p.ExportFavorites(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, favorites)
result, err := p.ExportWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, favorites)
if err != nil {
t.Fatalf("export favorites: %v", err)
t.Fatalf("export watchlist: %v", err)
}
if !strings.HasPrefix(gotPath, "/watchlist/items/add") {
t.Fatalf("got path %q, want /watchlist/items/add", gotPath)
@@ -65,6 +65,9 @@ func (p *Provider) Capabilities() watchsync.Capabilities {
ImportProgress: true,
ExportWatched: true,
ExportUnwatched: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ScrobblePlayback: true,
}
}
@@ -325,6 +328,91 @@ func (p *Provider) Stop(ctx context.Context, cfg watchsync.ServerConfig, conn wa
return err
}
// FetchWatchlist pulls Simkl's "plan to watch" list across movies, shows and
// anime. Simkl has no separate favorites concept; plan-to-watch is its
// watchlist.
func (p *Provider) FetchWatchlist(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) {
now := time.Now().UTC()
var rows []watchsync.RemoteFavorite
var movies simklAllItemsResponse
if err := p.do(ctx, http.MethodGet, "/sync/all-items/movies/plantowatch?extended=full", cfg, conn.AccessToken, nil, &movies); err != nil {
return nil, err
}
for _, movie := range movies.Movies {
key := movieKey(movie.Movie.IDs)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteFavorite{
Provider: p.Key(),
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: movie.Movie.Title,
Year: movie.Movie.Year,
IMDbID: movie.Movie.IDs.IMDb,
TMDBID: intString(movie.Movie.IDs.TMDB),
TVDBID: intString(movie.Movie.IDs.TVDB),
FavoritedAt: now,
})
}
for _, path := range []string{
"/sync/all-items/shows/plantowatch?extended=full",
"/sync/all-items/anime/plantowatch?extended=full",
} {
var payload simklAllItemsResponse
if err := p.do(ctx, http.MethodGet, path, cfg, conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
for _, show := range append(payload.Shows, payload.Anime...) {
key := showKey(show.Show.IDs)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteFavorite{
Provider: p.Key(),
ProviderItemKey: key,
Kind: historyimport.KindSeries,
Title: show.Show.Title,
Year: show.Show.Year,
IMDbID: show.Show.IDs.IMDb,
TMDBID: intString(show.Show.IDs.TMDB),
TVDBID: intString(show.Show.IDs.TVDB),
FavoritedAt: now,
})
}
}
return rows, nil
}
func (p *Provider) ExportWatchlist(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendListChange(ctx, "/sync/add-to-list", cfg, conn, items, "plantowatch")
}
func (p *Provider) RemoveWatchlist(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendListChange(ctx, "/sync/remove-from-list", cfg, conn, items, "")
}
func (p *Provider) sendListChange(ctx context.Context, path string, cfg watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite, to string) (watchsync.ExportResult, error) {
payload := buildSimklListPayload(items, to)
if len(payload.Movies) == 0 && len(payload.Shows) == 0 {
return watchsync.ExportResult{}, nil
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return watchsync.ExportResult{}, fmt.Errorf("encode simkl list payload: %w", err)
}
if err := p.do(ctx, http.MethodPost, path, cfg, conn.AccessToken, &body, nil); err != nil {
return watchsync.ExportResult{}, err
}
result := watchsync.ExportResult{Sent: make([]string, 0, len(items)*2)}
for _, item := range items {
result.Sent = append(result.Sent, item.MediaItemID, item.ProviderItemKey)
}
return result, nil
}
func (p *Provider) fetchActivities(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) (simklActivities, error) {
var activities simklActivities
if err := p.do(ctx, http.MethodGet, "/sync/activities", cfg, conn.AccessToken, nil, &activities); err != nil {
@@ -1048,6 +1136,71 @@ func movieKey(ids simklIDs) string {
}
}
func showKey(ids simklIDs) string {
switch {
case ids.TVDB > 0:
return "tvdb:" + strconv.Itoa(ids.TVDB)
case ids.TMDB > 0:
return "tmdb:" + strconv.Itoa(ids.TMDB)
case ids.IMDb != "":
return "imdb:" + ids.IMDb
case ids.Simkl > 0:
return "simkl:" + strconv.Itoa(ids.Simkl)
default:
return ""
}
}
type simklListPayload struct {
Movies []simklListItem `json:"movies,omitempty"`
Shows []simklListItem `json:"shows,omitempty"`
}
type simklListItem struct {
To string `json:"to,omitempty"`
IDs simklIDs `json:"ids"`
}
func buildSimklListPayload(items []watchsync.LocalFavorite, to string) simklListPayload {
var payload simklListPayload
for _, item := range items {
ids := idsFromLocal(item.IMDbID, item.TMDBID, item.TVDBID)
if ids == (simklIDs{}) {
ids = idsFromProviderItemKey(item.ProviderItemKey)
}
if ids == (simklIDs{}) {
continue
}
ref := simklListItem{To: to, IDs: ids}
switch item.Kind {
case historyimport.KindMovie:
payload.Movies = append(payload.Movies, ref)
case historyimport.KindSeries:
payload.Shows = append(payload.Shows, ref)
}
}
return payload
}
func idsFromProviderItemKey(key string) simklIDs {
prefix, value, ok := strings.Cut(key, ":")
if !ok || value == "" {
return simklIDs{}
}
switch prefix {
case "imdb":
return simklIDs{IMDb: value}
case "tmdb":
return simklIDs{TMDB: parseInt(value)}
case "tvdb":
return simklIDs{TVDB: parseInt(value)}
case "simkl":
return simklIDs{Simkl: parseInt(value)}
default:
return simklIDs{}
}
}
func episodeKey(showIDs simklIDs, season, episode int, episodeIDs simklIDs) string {
switch {
case episodeIDs.TVDB > 0:
@@ -30,6 +30,9 @@ func TestProviderIdentityAndCapabilities(t *testing.T) {
ImportProgress: true,
ExportWatched: true,
ExportUnwatched: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ScrobblePlayback: true,
}) {
t.Fatalf("capabilities = %#v", provider.Capabilities())
+70 -1
View File
@@ -55,6 +55,9 @@ func (p *Provider) Capabilities() watchsync.Capabilities {
ImportFavorites: true,
ExportFavorites: true,
RemoveFavorites: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ScrobblePlayback: true,
}
}
@@ -317,6 +320,30 @@ func (p *Provider) FetchFavorites(
if err := p.do(ctx, http.MethodGet, "/users/me/favorites/shows/added", cfg, conn.AccessToken, nil, &shows); err != nil {
return nil, err
}
return p.remoteListItems(movies, shows), nil
}
// FetchWatchlist pulls Trakt's watchlist — a distinct list from favorites. The
// watchlist endpoints return the same item shape, so the mapping is shared.
func (p *Provider) FetchWatchlist(
ctx context.Context,
cfg watchsync.ServerConfig,
conn watchsync.Connection,
) ([]watchsync.RemoteFavorite, error) {
var movies []traktFavoriteMovie
if err := p.do(ctx, http.MethodGet, "/sync/watchlist/movies", cfg, conn.AccessToken, nil, &movies); err != nil {
return nil, err
}
var shows []traktFavoriteShow
if err := p.do(ctx, http.MethodGet, "/sync/watchlist/shows", cfg, conn.AccessToken, nil, &shows); err != nil {
return nil, err
}
return p.remoteListItems(movies, shows), nil
}
// remoteListItems maps Trakt movie/show list rows (favorites or watchlist) into
// the kind-neutral RemoteFavorite carrier.
func (p *Provider) remoteListItems(movies []traktFavoriteMovie, shows []traktFavoriteShow) []watchsync.RemoteFavorite {
rows := make([]watchsync.RemoteFavorite, 0, len(movies)+len(shows))
for _, item := range movies {
rows = append(rows, watchsync.RemoteFavorite{
@@ -344,7 +371,7 @@ func (p *Provider) FetchFavorites(
FavoritedAt: item.ListedAt,
})
}
return rows, nil
return rows
}
func (p *Provider) FetchHistory(
@@ -455,6 +482,48 @@ func (p *Provider) RemoveFavorites(
return favoriteExportResult(favorites, response.NotFound), nil
}
// Trakt's watchlist accepts and returns the same {movies, shows} id payload as
// favorites, so the payload builder and not-found parser are shared.
func (p *Provider) ExportWatchlist(
ctx context.Context,
cfg watchsync.ServerConfig,
conn watchsync.Connection,
items []watchsync.LocalFavorite,
) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, "/sync/watchlist", cfg, conn, items)
}
func (p *Provider) RemoveWatchlist(
ctx context.Context,
cfg watchsync.ServerConfig,
conn watchsync.Connection,
items []watchsync.LocalFavorite,
) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, "/sync/watchlist/remove", cfg, conn, items)
}
func (p *Provider) sendWatchlist(
ctx context.Context,
path string,
cfg watchsync.ServerConfig,
conn watchsync.Connection,
items []watchsync.LocalFavorite,
) (watchsync.ExportResult, error) {
payload := buildFavoritesPayload(items)
if len(payload.Movies) == 0 && len(payload.Shows) == 0 {
return watchsync.ExportResult{}, nil
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return watchsync.ExportResult{}, fmt.Errorf("encode trakt watchlist payload: %w", err)
}
var response traktFavoritesResponse
if err := p.do(ctx, http.MethodPost, path, cfg, conn.AccessToken, &body, &response); err != nil {
return watchsync.ExportResult{}, err
}
return favoriteExportResult(items, response.NotFound), nil
}
func (p *Provider) RemoveHistory(
ctx context.Context,
cfg watchsync.ServerConfig,
@@ -29,6 +29,9 @@ func TestProviderIdentityAndCapabilities(t *testing.T) {
ImportFavorites: true,
ExportFavorites: true,
RemoveFavorites: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ScrobblePlayback: true,
}) {
t.Fatalf("unexpected capabilities: %#v", provider.Capabilities())
+190 -192
View File
@@ -29,24 +29,58 @@ type Repository interface {
GetActiveSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error)
ListSyncRuns(ctx context.Context, connectionID string, limit int) ([]SyncRun, error)
ListLocalWatchEventConnections(ctx context.Context, userID int, profileID string, kind LocalWatchEventKind) ([]Connection, error)
ListFavoriteEventConnections(ctx context.Context, userID int, profileID string, kind LocalFavoriteEventKind) ([]Connection, error)
ListListEventConnections(ctx context.Context, userID int, profileID string, list ListKind) ([]Connection, error)
UpsertHistoryExports(ctx context.Context, exports []HistoryExport) error
ListPendingHistoryExports(ctx context.Context, connectionID string, limit int) ([]HistoryExport, error)
MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error
UpsertFavoriteStates(ctx context.Context, states []FavoriteState) error
ListFavoriteStates(ctx context.Context, connectionID string) ([]FavoriteState, error)
ListPendingFavoriteExports(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error)
ListPendingFavoriteRemovals(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error)
MarkFavoriteExported(ctx context.Context, connectionID, mediaItemID string, exportedAt time.Time) error
MarkFavoriteRemoteRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error
MarkFavoriteLocalRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error
MarkFavoriteError(ctx context.Context, connectionID, mediaItemID, lastError string) error
UpsertListItemStates(ctx context.Context, states []ListItemState) error
ListListItemStates(ctx context.Context, connectionID string, kind ListKind) ([]ListItemState, error)
ListPendingListItemExports(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error)
ListPendingListItemRemovals(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error)
MarkListItemExported(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error
MarkListItemRemoteRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error
MarkListItemLocalRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error
MarkListItemError(ctx context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error
ListScrobbleConnections(ctx context.Context, userID int, profileID string) ([]Connection, error)
UpsertScrobbleSession(ctx context.Context, event ScrobbleEvent, connectionID string, action string) error
UpdateScrobbleSession(ctx context.Context, playbackSessionID string, connectionID string, action string, progress float64, historyID string, lastError string, stopSentAt *time.Time) error
ListOpenScrobbleSessions(ctx context.Context) ([]ScrobbleSession, error)
}
// connectionColumns is the canonical select/returning column list for
// watch_provider_connections, in the exact order scanConnection reads. Sharing
// it across every read query keeps the column set and scan order in lockstep.
const connectionColumns = `
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
sync_watchlist_order_enabled, scrobble_enabled, last_inbound_sync_at,
last_progress_sync_at, last_outbound_sync_at, last_favorites_sync_at,
last_watchlist_sync_at, last_scrobble_error_at, last_error,
sync_cursors, created_at, updated_at`
// syncRunColumns is the canonical select/returning column list for
// watch_provider_sync_runs, in the exact order scanSyncRun reads.
const syncRunColumns = `
id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
inbound_watchlist_found, inbound_watchlist_imported,
outbound_watchlist_found, outbound_watchlist_sent, watchlist_removals_sent,
warning, error, started_at, completed_at, created_at`
// listItemStateColumns is the canonical select column list for
// watch_provider_list_items, in the exact order scanListItemStates reads.
const listItemStateColumns = `
id::text, connection_id::text, list_kind, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error, created_at, updated_at`
type PostgresRepository struct {
pool *pgxpool.Pool
cipher *secret.Cipher
@@ -161,13 +195,15 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
sync_watchlist_order_enabled, scrobble_enabled, last_inbound_sync_at, last_progress_sync_at,
last_outbound_sync_at, last_favorites_sync_at, last_watchlist_sync_at, last_scrobble_error_at,
last_error, sync_cursors
)
VALUES (
COALESCE(NULLIF($1, '')::uuid, gen_random_uuid()),
$2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24::jsonb
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29::jsonb
)
ON CONFLICT (provider, user_id, profile_id) DO UPDATE SET
provider_account_id = EXCLUDED.provider_account_id,
@@ -182,22 +218,21 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
import_favorites_enabled = EXCLUDED.import_favorites_enabled,
export_favorites_enabled = EXCLUDED.export_favorites_enabled,
sync_favorite_removals_enabled = EXCLUDED.sync_favorite_removals_enabled,
import_watchlist_enabled = EXCLUDED.import_watchlist_enabled,
export_watchlist_enabled = EXCLUDED.export_watchlist_enabled,
sync_watchlist_removals_enabled = EXCLUDED.sync_watchlist_removals_enabled,
sync_watchlist_order_enabled = EXCLUDED.sync_watchlist_order_enabled,
scrobble_enabled = EXCLUDED.scrobble_enabled,
last_inbound_sync_at = EXCLUDED.last_inbound_sync_at,
last_progress_sync_at = EXCLUDED.last_progress_sync_at,
last_outbound_sync_at = EXCLUDED.last_outbound_sync_at,
last_favorites_sync_at = EXCLUDED.last_favorites_sync_at,
last_watchlist_sync_at = EXCLUDED.last_watchlist_sync_at,
last_scrobble_error_at = EXCLUDED.last_scrobble_error_at,
last_error = EXCLUDED.last_error,
sync_cursors = EXCLUDED.sync_cursors,
updated_at = now()
RETURNING
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
RETURNING `+connectionColumns+`
`,
conn.ID,
conn.Provider,
@@ -215,11 +250,16 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
conn.ImportFavoritesEnabled,
conn.ExportFavoritesEnabled,
conn.SyncFavoriteRemovalsEnabled,
conn.ImportWatchlistEnabled,
conn.ExportWatchlistEnabled,
conn.SyncWatchlistRemovalsEnabled,
conn.SyncWatchlistOrderEnabled,
conn.ScrobbleEnabled,
conn.LastInboundSyncAt,
conn.LastProgressSyncAt,
conn.LastOutboundSyncAt,
conn.LastFavoritesSyncAt,
conn.LastWatchlistSyncAt,
conn.LastScrobbleErrorAt,
conn.LastError,
encodeSyncCursors(conn.SyncCursors),
@@ -238,13 +278,7 @@ func (r *PostgresRepository) GetConnection(
profileID string,
) (Connection, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE provider = $1 AND user_id = $2 AND profile_id = $3
`, provider, userID, profileID)
@@ -260,13 +294,7 @@ func (r *PostgresRepository) GetConnection(
func (r *PostgresRepository) GetConnectionByID(ctx context.Context, id string) (Connection, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE id = $1::uuid
`, id)
@@ -301,13 +329,7 @@ func (r *PostgresRepository) ListConnectionsDueForSync(
_ time.Time,
) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE provider <> ''
AND (
@@ -318,6 +340,9 @@ func (r *PostgresRepository) ListConnectionsDueForSync(
OR import_favorites_enabled
OR export_favorites_enabled
OR sync_favorite_removals_enabled
OR import_watchlist_enabled
OR export_watchlist_enabled
OR sync_watchlist_removals_enabled
OR scrobble_enabled
)
ORDER BY provider, user_id, profile_id
@@ -356,26 +381,25 @@ func (r *PostgresRepository) CreateSyncRun(ctx context.Context, run SyncRun) (Sy
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
inbound_watchlist_found, inbound_watchlist_imported,
outbound_watchlist_found, outbound_watchlist_sent, watchlist_removals_sent,
warning, error, started_at, completed_at
)
VALUES (
$1::uuid, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
$5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24
)
RETURNING id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
warning, error, started_at, completed_at, created_at
RETURNING `+syncRunColumns+`
`, run.ConnectionID, run.Trigger, run.Status, run.Provider,
run.InboundWatchedFound, run.InboundWatchedImported,
run.InboundProgressFound, run.InboundProgressImported,
run.OutboundFound, run.OutboundSent, run.InboundFavoritesFound,
run.InboundFavoritesImported, run.OutboundFavoritesFound,
run.OutboundFavoritesSent, run.FavoriteRemovalsSent, run.Warning, run.Error,
run.StartedAt, run.CompletedAt)
run.OutboundFavoritesSent, run.FavoriteRemovalsSent,
run.InboundWatchlistFound, run.InboundWatchlistImported,
run.OutboundWatchlistFound, run.OutboundWatchlistSent, run.WatchlistRemovalsSent,
run.Warning, run.Error, run.StartedAt, run.CompletedAt)
created, err := scanSyncRun(row)
if err != nil {
return SyncRun{}, fmt.Errorf("scan created watch provider sync run: %w", err)
@@ -398,21 +422,23 @@ func (r *PostgresRepository) CompleteSyncRun(ctx context.Context, run SyncRun) (
outbound_favorites_found = $11,
outbound_favorites_sent = $12,
favorite_removals_sent = $13,
warning = $14,
error = $15,
completed_at = $16
inbound_watchlist_found = $14,
inbound_watchlist_imported = $15,
outbound_watchlist_found = $16,
outbound_watchlist_sent = $17,
watchlist_removals_sent = $18,
warning = $19,
error = $20,
completed_at = $21
WHERE id = $1::uuid
RETURNING id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
warning, error, started_at, completed_at, created_at
RETURNING `+syncRunColumns+`
`, run.ID, run.Status, run.InboundWatchedFound, run.InboundWatchedImported,
run.InboundProgressFound, run.InboundProgressImported, run.OutboundFound, run.OutboundSent,
run.InboundFavoritesFound, run.InboundFavoritesImported, run.OutboundFavoritesFound,
run.OutboundFavoritesSent, run.FavoriteRemovalsSent, run.Warning, run.Error, run.CompletedAt)
run.OutboundFavoritesSent, run.FavoriteRemovalsSent,
run.InboundWatchlistFound, run.InboundWatchlistImported, run.OutboundWatchlistFound,
run.OutboundWatchlistSent, run.WatchlistRemovalsSent,
run.Warning, run.Error, run.CompletedAt)
completed, err := scanSyncRun(row)
if err != nil {
return SyncRun{}, fmt.Errorf("complete watch provider sync run: %w", err)
@@ -422,13 +448,7 @@ func (r *PostgresRepository) CompleteSyncRun(ctx context.Context, run SyncRun) (
func (r *PostgresRepository) GetLatestSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
warning, error, started_at, completed_at, created_at
SELECT `+syncRunColumns+`
FROM watch_provider_sync_runs
WHERE connection_id = $1::uuid
ORDER BY started_at DESC, created_at DESC
@@ -446,13 +466,7 @@ func (r *PostgresRepository) GetLatestSyncRun(ctx context.Context, connectionID
func (r *PostgresRepository) GetActiveSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
warning, error, started_at, completed_at, created_at
SELECT `+syncRunColumns+`
FROM watch_provider_sync_runs
WHERE connection_id = $1::uuid
AND status IN ('queued', 'running')
@@ -474,13 +488,7 @@ func (r *PostgresRepository) ListSyncRuns(ctx context.Context, connectionID stri
limit = 10
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
warning, error, started_at, completed_at, created_at
SELECT `+syncRunColumns+`
FROM watch_provider_sync_runs
WHERE connection_id = $1::uuid
ORDER BY started_at DESC, created_at DESC
@@ -520,13 +528,7 @@ func (r *PostgresRepository) ListLocalWatchEventConnections(
return nil, nil
}
rows, err := r.pool.Query(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE user_id = $1 AND profile_id = $2 AND `+predicate+`
ORDER BY provider
@@ -550,35 +552,26 @@ func (r *PostgresRepository) ListLocalWatchEventConnections(
return conns, nil
}
func (r *PostgresRepository) ListFavoriteEventConnections(
// ListListEventConnections returns connections that export the given list kind,
// i.e. should mirror a local add/remove on that list to the provider.
func (r *PostgresRepository) ListListEventConnections(
ctx context.Context,
userID int,
profileID string,
kind LocalFavoriteEventKind,
list ListKind,
) ([]Connection, error) {
var predicate string
switch kind {
case LocalFavoriteEventAdded:
predicate = "export_favorites_enabled = true"
case LocalFavoriteEventRemoved:
predicate = "export_favorites_enabled = true"
default:
return nil, nil
column := "export_favorites_enabled"
if list == ListKindWatchlist {
column = "export_watchlist_enabled"
}
rows, err := r.pool.Query(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE user_id = $1 AND profile_id = $2 AND `+predicate+`
WHERE user_id = $1 AND profile_id = $2 AND `+column+` = true
ORDER BY provider
`, userID, profileID)
if err != nil {
return nil, fmt.Errorf("list favorite event connections: %w", err)
return nil, fmt.Errorf("list %s event connections: %w", list, err)
}
defer rows.Close()
@@ -586,12 +579,12 @@ func (r *PostgresRepository) ListFavoriteEventConnections(
for rows.Next() {
conn, scanErr := r.scanConnection(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan favorite event connection: %w", scanErr)
return nil, fmt.Errorf("scan %s event connection: %w", list, scanErr)
}
conns = append(conns, conn)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite event connections: %w", err)
return nil, fmt.Errorf("iterate %s event connections: %w", list, err)
}
return conns, nil
}
@@ -605,7 +598,7 @@ func (r *PostgresRepository) GetMediaDuration(ctx context.Context, mediaItemID s
return duration, nil
}
func (r *PostgresRepository) GetFavoriteMediaItems(ctx context.Context, mediaItemIDs []string) (map[string]LocalFavorite, error) {
func (r *PostgresRepository) GetListMediaItems(ctx context.Context, mediaItemIDs []string) (map[string]LocalFavorite, error) {
result := make(map[string]LocalFavorite, len(mediaItemIDs))
if len(mediaItemIDs) == 0 {
return result, nil
@@ -616,19 +609,19 @@ func (r *PostgresRepository) GetFavoriteMediaItems(ctx context.Context, mediaIte
WHERE content_id = ANY($1)
`, mediaItemIDs)
if err != nil {
return nil, fmt.Errorf("get favorite media items: %w", err)
return nil, fmt.Errorf("get list media items: %w", err)
}
defer rows.Close()
for rows.Next() {
var fav LocalFavorite
if err := rows.Scan(&fav.MediaItemID, &fav.Kind, &fav.Title, &fav.Year, &fav.IMDbID, &fav.TMDBID, &fav.TVDBID); err != nil {
return nil, fmt.Errorf("scan favorite media item: %w", err)
return nil, fmt.Errorf("scan list media item: %w", err)
}
fav.ProviderItemKey = providerItemKeyForLocalFavorite(fav)
result[fav.MediaItemID] = fav
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite media items: %w", err)
return nil, fmt.Errorf("iterate list media items: %w", err)
}
return result, nil
}
@@ -724,163 +717,157 @@ func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id str
return nil
}
func (r *PostgresRepository) UpsertFavoriteStates(ctx context.Context, states []FavoriteState) error {
func (r *PostgresRepository) UpsertListItemStates(ctx context.Context, states []ListItemState) error {
for _, state := range states {
kind := state.ListKind
if kind == "" {
kind = ListKindFavorites
}
_, err := r.pool.Exec(ctx, `
INSERT INTO watch_provider_favorite_items (
connection_id, media_item_id, provider_item_key, kind, title, year,
INSERT INTO watch_provider_list_items (
connection_id, list_kind, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (connection_id, media_item_id) DO UPDATE SET
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (connection_id, list_kind, media_item_id) DO UPDATE SET
provider_item_key = CASE
WHEN EXCLUDED.provider_item_key <> '' THEN EXCLUDED.provider_item_key
ELSE watch_provider_favorite_items.provider_item_key
ELSE watch_provider_list_items.provider_item_key
END,
kind = CASE WHEN EXCLUDED.kind <> '' THEN EXCLUDED.kind ELSE watch_provider_favorite_items.kind END,
title = CASE WHEN EXCLUDED.title <> '' THEN EXCLUDED.title ELSE watch_provider_favorite_items.title END,
year = CASE WHEN EXCLUDED.year <> 0 THEN EXCLUDED.year ELSE watch_provider_favorite_items.year END,
remote_present = watch_provider_favorite_items.remote_present OR EXCLUDED.remote_present,
kind = CASE WHEN EXCLUDED.kind <> '' THEN EXCLUDED.kind ELSE watch_provider_list_items.kind END,
title = CASE WHEN EXCLUDED.title <> '' THEN EXCLUDED.title ELSE watch_provider_list_items.title END,
year = CASE WHEN EXCLUDED.year <> 0 THEN EXCLUDED.year ELSE watch_provider_list_items.year END,
remote_present = watch_provider_list_items.remote_present OR EXCLUDED.remote_present,
local_present = EXCLUDED.local_present,
last_seen_remote_at = COALESCE(EXCLUDED.last_seen_remote_at, watch_provider_favorite_items.last_seen_remote_at),
last_seen_local_at = COALESCE(EXCLUDED.last_seen_local_at, watch_provider_favorite_items.last_seen_local_at),
last_exported_at = COALESCE(EXCLUDED.last_exported_at, watch_provider_favorite_items.last_exported_at),
last_removed_remote_at = COALESCE(EXCLUDED.last_removed_remote_at, watch_provider_favorite_items.last_removed_remote_at),
last_removed_local_at = COALESCE(EXCLUDED.last_removed_local_at, watch_provider_favorite_items.last_removed_local_at),
last_seen_remote_at = COALESCE(EXCLUDED.last_seen_remote_at, watch_provider_list_items.last_seen_remote_at),
last_seen_local_at = COALESCE(EXCLUDED.last_seen_local_at, watch_provider_list_items.last_seen_local_at),
last_exported_at = COALESCE(EXCLUDED.last_exported_at, watch_provider_list_items.last_exported_at),
last_removed_remote_at = COALESCE(EXCLUDED.last_removed_remote_at, watch_provider_list_items.last_removed_remote_at),
last_removed_local_at = COALESCE(EXCLUDED.last_removed_local_at, watch_provider_list_items.last_removed_local_at),
last_error = EXCLUDED.last_error,
updated_at = now()
`, state.ConnectionID, state.MediaItemID, state.ProviderItemKey, state.Kind, state.Title, state.Year,
`, state.ConnectionID, string(kind), state.MediaItemID, state.ProviderItemKey, state.Kind, state.Title, state.Year,
state.RemotePresent, state.LocalPresent, state.LastSeenRemoteAt, state.LastSeenLocalAt,
state.LastExportedAt, state.LastRemovedRemoteAt, state.LastRemovedLocalAt, state.LastError)
if err != nil {
return fmt.Errorf("upsert favorite state: %w", err)
return fmt.Errorf("upsert list item state: %w", err)
}
}
return nil
}
func (r *PostgresRepository) ListFavoriteStates(ctx context.Context, connectionID string) ([]FavoriteState, error) {
func (r *PostgresRepository) ListListItemStates(ctx context.Context, connectionID string, kind ListKind) ([]ListItemState, error) {
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error, created_at, updated_at
FROM watch_provider_favorite_items
WHERE connection_id = $1::uuid
`, connectionID)
SELECT `+listItemStateColumns+`
FROM watch_provider_list_items
WHERE connection_id = $1::uuid AND list_kind = $2
`, connectionID, string(kind))
if err != nil {
return nil, fmt.Errorf("list favorite states: %w", err)
return nil, fmt.Errorf("list list item states: %w", err)
}
defer rows.Close()
return scanFavoriteStates(rows)
return scanListItemStates(rows)
}
func (r *PostgresRepository) ListPendingFavoriteExports(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error) {
func (r *PostgresRepository) ListPendingListItemExports(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error, created_at, updated_at
FROM watch_provider_favorite_items
SELECT `+listItemStateColumns+`
FROM watch_provider_list_items
WHERE connection_id = $1::uuid
AND list_kind = $2
AND local_present = true
AND remote_present = false
AND last_error = ''
ORDER BY last_seen_local_at ASC NULLS LAST, created_at ASC
LIMIT $2
`, connectionID, limit)
LIMIT $3
`, connectionID, string(kind), limit)
if err != nil {
return nil, fmt.Errorf("list pending favorite exports: %w", err)
return nil, fmt.Errorf("list pending list item exports: %w", err)
}
defer rows.Close()
return scanFavoriteStates(rows)
return scanListItemStates(rows)
}
func (r *PostgresRepository) ListPendingFavoriteRemovals(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error) {
func (r *PostgresRepository) ListPendingListItemRemovals(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error, created_at, updated_at
FROM watch_provider_favorite_items
SELECT `+listItemStateColumns+`
FROM watch_provider_list_items
WHERE connection_id = $1::uuid
AND list_kind = $2
AND local_present = false
AND remote_present = true
AND last_error = ''
ORDER BY last_removed_local_at ASC NULLS LAST, updated_at ASC
LIMIT $2
`, connectionID, limit)
LIMIT $3
`, connectionID, string(kind), limit)
if err != nil {
return nil, fmt.Errorf("list pending favorite removals: %w", err)
return nil, fmt.Errorf("list pending list item removals: %w", err)
}
defer rows.Close()
return scanFavoriteStates(rows)
return scanListItemStates(rows)
}
func (r *PostgresRepository) MarkFavoriteExported(ctx context.Context, connectionID, mediaItemID string, exportedAt time.Time) error {
return r.updateFavoriteState(ctx, connectionID, mediaItemID, `
func (r *PostgresRepository) MarkListItemExported(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error {
return r.updateListItemState(ctx, connectionID, kind, mediaItemID, `
remote_present = true,
local_present = true,
last_exported_at = $3,
last_seen_remote_at = COALESCE(last_seen_remote_at, $3),
last_exported_at = $4,
last_seen_remote_at = COALESCE(last_seen_remote_at, $4),
last_error = ''
`, exportedAt)
}
func (r *PostgresRepository) MarkFavoriteRemoteRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error {
return r.updateFavoriteState(ctx, connectionID, mediaItemID, `
func (r *PostgresRepository) MarkListItemRemoteRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
return r.updateListItemState(ctx, connectionID, kind, mediaItemID, `
remote_present = false,
last_removed_remote_at = $3,
last_removed_remote_at = $4,
last_error = ''
`, removedAt)
}
func (r *PostgresRepository) MarkFavoriteLocalRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error {
return r.updateFavoriteState(ctx, connectionID, mediaItemID, `
func (r *PostgresRepository) MarkListItemLocalRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
return r.updateListItemState(ctx, connectionID, kind, mediaItemID, `
local_present = false,
last_removed_local_at = $3,
last_removed_local_at = $4,
last_error = ''
`, removedAt)
}
func (r *PostgresRepository) MarkFavoriteError(ctx context.Context, connectionID, mediaItemID, lastError string) error {
func (r *PostgresRepository) MarkListItemError(ctx context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_favorite_items
SET last_error = $3, updated_at = now()
WHERE connection_id = $1::uuid AND media_item_id = $2
`, connectionID, mediaItemID, lastError)
UPDATE watch_provider_list_items
SET last_error = $4, updated_at = now()
WHERE connection_id = $1::uuid AND list_kind = $2 AND media_item_id = $3
`, connectionID, string(kind), mediaItemID, lastError)
if err != nil {
return fmt.Errorf("mark favorite error: %w", err)
return fmt.Errorf("mark list item error: %w", err)
}
return nil
}
func (r *PostgresRepository) updateFavoriteState(ctx context.Context, connectionID, mediaItemID, setClause string, at time.Time) error {
func (r *PostgresRepository) updateListItemState(ctx context.Context, connectionID string, kind ListKind, mediaItemID, setClause string, at time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_favorite_items
UPDATE watch_provider_list_items
SET `+setClause+`,
updated_at = now()
WHERE connection_id = $1::uuid AND media_item_id = $2
`, connectionID, mediaItemID, at)
WHERE connection_id = $1::uuid AND list_kind = $2 AND media_item_id = $3
`, connectionID, string(kind), mediaItemID, at)
if err != nil {
return fmt.Errorf("update favorite state: %w", err)
return fmt.Errorf("update list item state: %w", err)
}
return nil
}
func (r *PostgresRepository) ListScrobbleConnections(ctx context.Context, userID int, profileID string) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE user_id = $1 AND profile_id = $2 AND scrobble_enabled = true
ORDER BY provider
@@ -1050,6 +1037,11 @@ func scanSyncRun(row pgx.Row) (SyncRun, error) {
&run.OutboundFavoritesFound,
&run.OutboundFavoritesSent,
&run.FavoriteRemovalsSent,
&run.InboundWatchlistFound,
&run.InboundWatchlistImported,
&run.OutboundWatchlistFound,
&run.OutboundWatchlistSent,
&run.WatchlistRemovalsSent,
&run.Warning,
&run.Error,
&run.StartedAt,
@@ -1082,11 +1074,16 @@ func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
&conn.ImportFavoritesEnabled,
&conn.ExportFavoritesEnabled,
&conn.SyncFavoriteRemovalsEnabled,
&conn.ImportWatchlistEnabled,
&conn.ExportWatchlistEnabled,
&conn.SyncWatchlistRemovalsEnabled,
&conn.SyncWatchlistOrderEnabled,
&conn.ScrobbleEnabled,
&conn.LastInboundSyncAt,
&conn.LastProgressSyncAt,
&conn.LastOutboundSyncAt,
&conn.LastFavoritesSyncAt,
&conn.LastWatchlistSyncAt,
&conn.LastScrobbleErrorAt,
&conn.LastError,
&rawSyncCursors,
@@ -1108,19 +1105,20 @@ func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
return conn, nil
}
type favoriteStateRows interface {
type listItemStateRows interface {
Next() bool
Scan(dest ...any) error
Err() error
}
func scanFavoriteStates(rows favoriteStateRows) ([]FavoriteState, error) {
var states []FavoriteState
func scanListItemStates(rows listItemStateRows) ([]ListItemState, error) {
var states []ListItemState
for rows.Next() {
var state FavoriteState
var state ListItemState
if err := rows.Scan(
&state.ID,
&state.ConnectionID,
&state.ListKind,
&state.MediaItemID,
&state.ProviderItemKey,
&state.Kind,
@@ -1137,12 +1135,12 @@ func scanFavoriteStates(rows favoriteStateRows) ([]FavoriteState, error) {
&state.CreatedAt,
&state.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan favorite state: %w", err)
return nil, fmt.Errorf("scan list item state: %w", err)
}
states = append(states, state)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite states: %w", err)
return nil, fmt.Errorf("iterate list item states: %w", err)
}
return states, nil
}
+108 -491
View File
@@ -98,20 +98,24 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
}
missingAccessToken := connected && strings.TrimSpace(conn.AccessToken) == ""
status := ConnectionStatus{
Provider: providerKey,
DisplayName: provider.DisplayName(),
Capabilities: provider.Capabilities(),
AuthMethod: authMethod,
Connected: connected && !missingAccessToken,
CredentialsConfigured: credentialsConfigured,
ImportWatchedEnabled: true,
ImportProgressEnabled: true,
ExportWatchedEnabled: true,
ExportUnwatchedEnabled: false,
ImportFavoritesEnabled: true,
ExportFavoritesEnabled: true,
SyncFavoriteRemovalsEnabled: false,
ScrobbleEnabled: true,
Provider: providerKey,
DisplayName: provider.DisplayName(),
Capabilities: provider.Capabilities(),
AuthMethod: authMethod,
Connected: connected && !missingAccessToken,
CredentialsConfigured: credentialsConfigured,
ImportWatchedEnabled: true,
ImportProgressEnabled: true,
ExportWatchedEnabled: true,
ExportUnwatchedEnabled: false,
ImportFavoritesEnabled: true,
ExportFavoritesEnabled: true,
SyncFavoriteRemovalsEnabled: false,
ImportWatchlistEnabled: true,
ExportWatchlistEnabled: true,
SyncWatchlistRemovalsEnabled: false,
SyncWatchlistOrderEnabled: false,
ScrobbleEnabled: true,
}
if connected {
status.ProviderUsername = conn.ProviderUsername
@@ -122,11 +126,16 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
status.ImportFavoritesEnabled = conn.ImportFavoritesEnabled
status.ExportFavoritesEnabled = conn.ExportFavoritesEnabled
status.SyncFavoriteRemovalsEnabled = conn.SyncFavoriteRemovalsEnabled
status.ImportWatchlistEnabled = conn.ImportWatchlistEnabled
status.ExportWatchlistEnabled = conn.ExportWatchlistEnabled
status.SyncWatchlistRemovalsEnabled = conn.SyncWatchlistRemovalsEnabled
status.SyncWatchlistOrderEnabled = conn.SyncWatchlistOrderEnabled
status.ScrobbleEnabled = conn.ScrobbleEnabled
status.LastInboundSyncAt = conn.LastInboundSyncAt
status.LastProgressSyncAt = conn.LastProgressSyncAt
status.LastOutboundSyncAt = conn.LastOutboundSyncAt
status.LastFavoritesSyncAt = conn.LastFavoritesSyncAt
status.LastWatchlistSyncAt = conn.LastWatchlistSyncAt
status.LastScrobbleErrorAt = conn.LastScrobbleErrorAt
status.LastError = conn.LastError
}
@@ -165,15 +174,52 @@ func (s *Service) UpdateConnection(ctx context.Context, userID int, profileID st
if update.SyncFavoriteRemovalsEnabled != nil {
conn.SyncFavoriteRemovalsEnabled = *update.SyncFavoriteRemovalsEnabled
}
if update.ImportWatchlistEnabled != nil {
conn.ImportWatchlistEnabled = *update.ImportWatchlistEnabled
}
if update.ExportWatchlistEnabled != nil {
conn.ExportWatchlistEnabled = *update.ExportWatchlistEnabled
}
if update.SyncWatchlistRemovalsEnabled != nil {
conn.SyncWatchlistRemovalsEnabled = *update.SyncWatchlistRemovalsEnabled
}
watchlistOrderDisabled := false
if update.SyncWatchlistOrderEnabled != nil {
watchlistOrderDisabled = conn.SyncWatchlistOrderEnabled && !*update.SyncWatchlistOrderEnabled
conn.SyncWatchlistOrderEnabled = *update.SyncWatchlistOrderEnabled
}
if update.ScrobbleEnabled != nil {
conn.ScrobbleEnabled = *update.ScrobbleEnabled
}
// Turning order mirroring off reverts the watchlist to added_at ordering.
// Clear the stored order *before* persisting the disable so a failure leaves
// both the order and the toggle intact (retriable) rather than reporting
// "disabled" while sort_index ordering is still active.
if watchlistOrderDisabled {
if err := s.clearWatchlistOrder(ctx, conn); err != nil {
return ConnectionStatus{}, err
}
}
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
return ConnectionStatus{}, err
}
return s.GetConnectionStatus(ctx, userID, profileID, providerKey)
}
func (s *Service) clearWatchlistOrder(ctx context.Context, conn Connection) error {
if s.storeProvider == nil {
return nil
}
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
if err != nil {
return fmt.Errorf("open user store to clear watchlist order: %w", err)
}
if err := store.ReplaceWatchlistOrder(ctx, conn.ProfileID, nil); err != nil {
return fmt.Errorf("clear watchlist order: %w", err)
}
return nil
}
func (s *Service) DeleteConnection(ctx context.Context, userID int, profileID string, providerKey string) error {
return s.repo.DeleteConnection(ctx, providerKey, userID, profileID)
}
@@ -332,124 +378,6 @@ func (s *Service) processLocalWatchEvent(ctx context.Context, event LocalWatchEv
return nil
}
func (s *Service) HandleLocalFavoriteEvent(ctx context.Context, event LocalFavoriteEvent) error {
if event.UserID == 0 || event.ProfileID == "" || len(event.Favorites) == 0 {
return nil
}
go func() {
bg, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.processLocalFavoriteEvent(bg, event); err != nil {
slog.Warn("failed to dispatch local favorite provider event", "kind", event.Kind, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err)
}
}()
return nil
}
func (s *Service) processLocalFavoriteEvent(ctx context.Context, event LocalFavoriteEvent) error {
conns, err := s.repo.ListFavoriteEventConnections(ctx, event.UserID, event.ProfileID, event.Kind)
if err != nil {
return err
}
for _, conn := range conns {
provider, ok := s.registry.Get(conn.Provider)
if !ok {
continue
}
cfg, err := s.serverConfig(ctx, conn.Provider)
if err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
continue
}
conn, err = s.refreshConnectionIfNeeded(ctx, provider, cfg, conn)
if err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
continue
}
switch event.Kind {
case LocalFavoriteEventAdded:
exporter, ok := provider.(FavoriteExporter)
if !ok || !provider.Capabilities().ExportFavorites {
continue
}
if err := s.exportLocalFavorites(ctx, conn, cfg, exporter, event.Favorites); err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
}
case LocalFavoriteEventRemoved:
now := s.now()
for _, favorite := range event.Favorites {
if err := s.repo.MarkFavoriteLocalRemoved(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
return err
}
}
if !conn.SyncFavoriteRemovalsEnabled || !provider.Capabilities().RemoveFavorites {
continue
}
remover, ok := provider.(FavoriteRemover)
if !ok {
continue
}
if _, err := remover.RemoveFavorites(ctx, cfg, conn, event.Favorites); err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
continue
}
for _, favorite := range event.Favorites {
if err := s.repo.MarkFavoriteRemoteRemoved(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
return err
}
}
}
}
return nil
}
func (s *Service) exportLocalFavorites(ctx context.Context, conn Connection, cfg ServerConfig, exporter FavoriteExporter, favorites []LocalFavorite) error {
states := make([]FavoriteState, 0, len(favorites))
for _, favorite := range favorites {
if favorite.ProviderItemKey == "" {
favorite.ProviderItemKey = providerItemKeyForLocalFavorite(favorite)
}
if favorite.ProviderItemKey == "" {
continue
}
favoritedAt := favorite.FavoritedAt
if favoritedAt.IsZero() {
favoritedAt = s.now()
}
states = append(states, FavoriteState{
ConnectionID: conn.ID,
MediaItemID: favorite.MediaItemID,
ProviderItemKey: favorite.ProviderItemKey,
Kind: favorite.Kind,
Title: favorite.Title,
Year: favorite.Year,
RemotePresent: false,
LocalPresent: true,
LastSeenLocalAt: &favoritedAt,
})
}
if err := s.repo.UpsertFavoriteStates(ctx, states); err != nil {
return err
}
result, err := exporter.ExportFavorites(ctx, cfg, conn, favorites)
if err != nil {
return err
}
now := s.now()
sent := exportResultSentSet(result)
for _, favorite := range favorites {
if sent[favorite.MediaItemID] || sent[favorite.ProviderItemKey] {
if err := s.repo.MarkFavoriteExported(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
return err
}
}
}
conn.LastFavoritesSyncAt = &now
conn.LastError = ""
_, err = s.repo.UpsertConnection(ctx, conn)
return err
}
func (s *Service) recordLocalWatchEventError(ctx context.Context, conn Connection, err error) {
if err == nil {
return
@@ -611,12 +539,17 @@ func (s *Service) persistConnection(
return Connection{}, err
}
if !ok {
// Enable every bidirectional sync by default; per-list removal stays
// opt-in. Toggles a provider can't serve are skipped at sync time via
// the capability check, so enabling them here is harmless.
conn = Connection{
ImportWatchedEnabled: true,
ImportProgressEnabled: true,
ExportWatchedEnabled: true,
ImportFavoritesEnabled: true,
ExportFavoritesEnabled: true,
ImportWatchlistEnabled: true,
ExportWatchlistEnabled: true,
ScrobbleEnabled: true,
}
}
@@ -778,24 +711,6 @@ func (s *Service) executeSyncRun(ctx context.Context, conn Connection, run SyncR
}
}
}
if conn.ImportFavoritesEnabled && provider.Capabilities().ImportFavorites {
importer, ok := provider.(FavoriteImporter)
if !ok {
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement favorites import", conn.Provider))
} else {
result, err := s.ImportFavorites(ctx, conn, cfg, importer)
run.InboundFavoritesFound = result.Found
run.InboundFavoritesImported = result.Imported
run.Warning = appendWarning(run.Warning, result.Warnings)
if err != nil {
flowErrors = append(flowErrors, "favorites import: "+err.Error())
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
flowErrors = append(flowErrors, "favorites import connection refresh: "+refreshErr.Error())
} else {
conn = refreshed
}
}
}
if conn.ExportWatchedEnabled && provider.Capabilities().ExportWatched {
exporter, ok := provider.(WatchedExporter)
if !ok {
@@ -813,33 +728,38 @@ func (s *Service) executeSyncRun(ctx context.Context, conn Connection, run SyncR
}
}
}
if conn.ExportFavoritesEnabled && provider.Capabilities().ExportFavorites {
exporter, ok := provider.(FavoriteExporter)
if !ok {
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement favorites export", conn.Provider))
} else {
result, err := s.ExportFavorites(ctx, conn, cfg, exporter)
run.OutboundFavoritesFound = result.LocalFound
run.OutboundFavoritesSent = result.Sent
// Favorites and watchlist share one pipeline, run per list kind.
for _, b := range s.listBindings() {
caps := provider.Capabilities()
if b.importEnabled(conn) && b.capImport(caps) {
result, err := s.importList(ctx, conn, cfg, provider, b)
b.setImportCounts(&run, result.Found, result.Imported)
run.Warning = appendWarning(run.Warning, result.Warnings)
if err != nil {
flowErrors = append(flowErrors, "favorites export: "+err.Error())
flowErrors = append(flowErrors, string(b.kind)+" import: "+err.Error())
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
flowErrors = append(flowErrors, "favorites export connection refresh: "+refreshErr.Error())
flowErrors = append(flowErrors, string(b.kind)+" import connection refresh: "+refreshErr.Error())
} else {
conn = refreshed
}
}
}
if conn.SyncFavoriteRemovalsEnabled && provider.Capabilities().RemoveFavorites {
remover, ok := provider.(FavoriteRemover)
if !ok {
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement favorites removal", conn.Provider))
} else {
removed, err := s.RemovePendingFavorites(ctx, conn, cfg, remover)
run.FavoriteRemovalsSent = removed
if b.exportEnabled(conn) && b.capExport(caps) {
result, err := s.exportList(ctx, conn, cfg, provider, b)
b.setExportCounts(&run, result.LocalFound, result.Sent)
run.Warning = appendWarning(run.Warning, result.Warnings)
if err != nil {
flowErrors = append(flowErrors, "favorites removal: "+err.Error())
flowErrors = append(flowErrors, string(b.kind)+" export: "+err.Error())
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
flowErrors = append(flowErrors, string(b.kind)+" export connection refresh: "+refreshErr.Error())
} else {
conn = refreshed
}
}
if b.removalsEnabled(conn) && b.capRemove(caps) {
removed, err := s.removePendingListItems(ctx, conn, cfg, provider, b)
b.setRemovalCount(&run, removed)
if err != nil {
flowErrors = append(flowErrors, string(b.kind)+" removal: "+err.Error())
}
}
}
@@ -879,6 +799,9 @@ func providerSyncNeedsAccessToken(caps Capabilities) bool {
caps.ImportFavorites ||
caps.ExportFavorites ||
caps.RemoveFavorites ||
caps.ImportWatchlist ||
caps.ExportWatchlist ||
caps.RemoveWatchlist ||
caps.ScrobblePlayback
}
@@ -1154,133 +1077,6 @@ func fetchProgressImportBatch(
return ProgressImportBatch{Rows: rows}, nil
}
type ImportFavoritesResult struct {
Found int
Imported int
Unmatched int
Removed int
Warnings []string
}
func (s *Service) ImportFavorites(
ctx context.Context,
conn Connection,
cfg ServerConfig,
importer FavoriteImporter,
) (ImportFavoritesResult, error) {
if s.matcher == nil {
return ImportFavoritesResult{}, fmt.Errorf("watch provider matcher is not configured")
}
if s.storeProvider == nil {
return ImportFavoritesResult{}, fmt.Errorf("user store provider is not configured")
}
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
if err != nil {
return ImportFavoritesResult{}, fmt.Errorf("open user store: %w", err)
}
batch, err := fetchFavoriteImportBatch(ctx, cfg, conn, importer)
if err != nil {
return ImportFavoritesResult{}, err
}
rows := batch.Rows
result := ImportFavoritesResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
seenRemoteKeys := make(map[string]bool, len(rows))
states := make([]FavoriteState, 0, len(rows))
for _, row := range rows {
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
if err != nil {
return result, err
}
if match == nil {
result.Unmatched++
if reason != "" {
result.Warnings = append(result.Warnings, reason)
}
continue
}
if err := store.AddFavoriteAt(ctx, conn.ProfileID, match.MediaItemID, row.FavoritedAt); err != nil {
return result, err
}
result.Imported++
key := row.ProviderItemKey
if key == "" {
key = providerItemKeyForRemoteFavorite(row)
}
if key != "" {
seenRemoteKeys[key] = true
}
favoritedAt := row.FavoritedAt
states = append(states, FavoriteState{
ConnectionID: conn.ID,
MediaItemID: match.MediaItemID,
ProviderItemKey: key,
Kind: row.Kind,
Title: row.Title,
Year: row.Year,
RemotePresent: true,
LocalPresent: true,
LastSeenRemoteAt: &favoritedAt,
LastSeenLocalAt: &favoritedAt,
})
}
if err := s.repo.UpsertFavoriteStates(ctx, states); err != nil {
return result, err
}
if err := s.reconcileMissingRemoteFavorites(ctx, conn, store, seenRemoteKeys, &result); err != nil {
return result, err
}
now := s.now()
conn.LastFavoritesSyncAt = &now
conn.LastError = ""
conn.SyncCursors = mergeSyncCursors(conn.SyncCursors, batch.UpdatedCursors)
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
return result, err
}
return result, nil
}
func fetchFavoriteImportBatch(
ctx context.Context,
cfg ServerConfig,
conn Connection,
importer FavoriteImporter,
) (FavoriteImportBatch, error) {
if batchImporter, ok := importer.(FavoriteBatchImporter); ok {
return batchImporter.FetchFavoritesBatch(ctx, cfg, conn)
}
rows, err := importer.FetchFavorites(ctx, cfg, conn)
if err != nil {
return FavoriteImportBatch{}, err
}
return FavoriteImportBatch{Rows: rows}, nil
}
func (s *Service) reconcileMissingRemoteFavorites(ctx context.Context, conn Connection, store userstore.UserStore, seenRemoteKeys map[string]bool, result *ImportFavoritesResult) error {
states, err := s.repo.ListFavoriteStates(ctx, conn.ID)
if err != nil {
return err
}
now := s.now()
for _, state := range states {
if !state.RemotePresent || state.ProviderItemKey == "" || seenRemoteKeys[state.ProviderItemKey] {
continue
}
if conn.SyncFavoriteRemovalsEnabled && state.LocalPresent {
if err := store.RemoveFavorite(ctx, conn.ProfileID, state.MediaItemID); err != nil {
return err
}
if err := s.repo.MarkFavoriteLocalRemoved(ctx, conn.ID, state.MediaItemID, now); err != nil {
return err
}
result.Removed++
}
if err := s.repo.MarkFavoriteRemoteRemoved(ctx, conn.ID, state.MediaItemID, now); err != nil {
return err
}
}
return nil
}
func mergeSyncCursors(existing map[string]string, updates map[string]string) map[string]string {
merged := make(map[string]string, len(existing)+len(updates))
for key, value := range existing {
@@ -1382,14 +1178,6 @@ type ExportWatchedResult struct {
Failed int
}
type ExportFavoritesResult struct {
LocalFound int
Queued int
Sent int
Failed int
Warnings []string
}
func (s *Service) ExportWatched(
ctx context.Context,
conn Connection,
@@ -1522,193 +1310,6 @@ func (s *Service) ExportWatched(
return result, nil
}
func (s *Service) ExportFavorites(
ctx context.Context,
conn Connection,
cfg ServerConfig,
exporter FavoriteExporter,
) (ExportFavoritesResult, error) {
if s.storeProvider == nil {
return ExportFavoritesResult{}, fmt.Errorf("user store provider is not configured")
}
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
if err != nil {
return ExportFavoritesResult{}, fmt.Errorf("open user store: %w", err)
}
rows, err := store.ListFavorites(ctx, conn.ProfileID, 10000, 0)
if err != nil {
return ExportFavoritesResult{}, err
}
result := ExportFavoritesResult{LocalFound: len(rows)}
favorites, states, warnings, err := s.localFavoritesFromRows(ctx, conn, rows)
if err != nil {
return result, err
}
result.Warnings = append(result.Warnings, warnings...)
if err := s.repo.UpsertFavoriteStates(ctx, states); err != nil {
return result, err
}
for {
pending, err := s.repo.ListPendingFavoriteExports(ctx, conn.ID, 100)
if err != nil {
return result, err
}
if len(pending) == 0 {
break
}
byMedia := make(map[string]LocalFavorite, len(favorites))
for _, favorite := range favorites {
byMedia[favorite.MediaItemID] = favorite
}
toSend := make([]LocalFavorite, 0, len(pending))
for _, state := range pending {
favorite, ok := byMedia[state.MediaItemID]
if !ok {
if err := s.repo.MarkFavoriteLocalRemoved(ctx, conn.ID, state.MediaItemID, s.now()); err != nil {
return result, err
}
continue
}
toSend = append(toSend, favorite)
}
if len(toSend) == 0 {
continue
}
result.Queued += len(toSend)
exportResult, err := exporter.ExportFavorites(ctx, cfg, conn, toSend)
if err != nil {
for _, favorite := range toSend {
_ = s.repo.MarkFavoriteError(ctx, conn.ID, favorite.MediaItemID, err.Error())
}
result.Failed += len(toSend)
return result, err
}
now := s.now()
sent := exportResultSentSet(exportResult)
for _, favorite := range toSend {
if sent[favorite.MediaItemID] || sent[favorite.ProviderItemKey] {
if err := s.repo.MarkFavoriteExported(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
return result, err
}
result.Sent++
continue
}
if containsString(exportResult.NotFound, favorite.MediaItemID) || containsString(exportResult.NotFound, favorite.ProviderItemKey) {
msg := "favorite not found by provider"
if err := s.repo.MarkFavoriteError(ctx, conn.ID, favorite.MediaItemID, msg); err != nil {
return result, err
}
result.Warnings = append(result.Warnings, msg+": "+favorite.MediaItemID)
}
}
}
now := s.now()
conn.LastFavoritesSyncAt = &now
conn.LastError = ""
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
return result, err
}
return result, nil
}
func (s *Service) RemovePendingFavorites(ctx context.Context, conn Connection, cfg ServerConfig, remover FavoriteRemover) (int, error) {
removed := 0
for {
pending, err := s.repo.ListPendingFavoriteRemovals(ctx, conn.ID, 100)
if err != nil {
return removed, err
}
if len(pending) == 0 {
return removed, nil
}
favorites := make([]LocalFavorite, 0, len(pending))
for _, state := range pending {
favorites = append(favorites, LocalFavorite{
MediaItemID: state.MediaItemID,
ProviderItemKey: state.ProviderItemKey,
Kind: state.Kind,
Title: state.Title,
Year: state.Year,
})
}
result, err := remover.RemoveFavorites(ctx, cfg, conn, favorites)
if err != nil {
for _, favorite := range favorites {
_ = s.repo.MarkFavoriteError(ctx, conn.ID, favorite.MediaItemID, err.Error())
}
return removed, err
}
now := s.now()
sent := exportResultSentSet(result)
for _, favorite := range favorites {
if sent[favorite.MediaItemID] || sent[favorite.ProviderItemKey] {
if err := s.repo.MarkFavoriteRemoteRemoved(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
return removed, err
}
removed++
}
}
}
}
func (s *Service) localFavoritesFromRows(ctx context.Context, conn Connection, rows []userstore.Favorite) ([]LocalFavorite, []FavoriteState, []string, error) {
ids := make([]string, 0, len(rows))
addedAtByID := make(map[string]time.Time, len(rows))
for _, row := range rows {
ids = append(ids, row.MediaItemID)
if addedAt, err := time.Parse(time.RFC3339, row.AddedAt); err == nil {
addedAtByID[row.MediaItemID] = addedAt
}
}
type favoriteMediaResolver interface {
GetFavoriteMediaItems(ctx context.Context, mediaItemIDs []string) (map[string]LocalFavorite, error)
}
resolver, ok := s.repo.(favoriteMediaResolver)
if !ok {
return nil, nil, nil, fmt.Errorf("favorite media resolver is not configured")
}
items, err := resolver.GetFavoriteMediaItems(ctx, ids)
if err != nil {
return nil, nil, nil, err
}
favorites := make([]LocalFavorite, 0, len(rows))
states := make([]FavoriteState, 0, len(rows))
var warnings []string
for _, row := range rows {
favorite, ok := items[row.MediaItemID]
if !ok {
warnings = append(warnings, "favorite media item not found: "+row.MediaItemID)
continue
}
favorite.FavoritedAt = addedAtByID[row.MediaItemID]
if favorite.FavoritedAt.IsZero() {
favorite.FavoritedAt = s.now()
}
if favorite.Kind != historyimport.KindMovie && favorite.Kind != historyimport.KindSeries {
warnings = append(warnings, "favorite kind is not supported by provider: "+row.MediaItemID)
continue
}
if favorite.ProviderItemKey == "" {
warnings = append(warnings, "favorite has no provider ids: "+row.MediaItemID)
continue
}
favorites = append(favorites, favorite)
favoritedAt := favorite.FavoritedAt
states = append(states, FavoriteState{
ConnectionID: conn.ID,
MediaItemID: favorite.MediaItemID,
ProviderItemKey: favorite.ProviderItemKey,
Kind: favorite.Kind,
Title: favorite.Title,
Year: favorite.Year,
RemotePresent: false,
LocalPresent: true,
LastSeenLocalAt: &favoritedAt,
})
}
return favorites, states, warnings, nil
}
func (s *Service) exportLocalPlays(
ctx context.Context,
conn Connection,
@@ -1943,6 +1544,22 @@ func containsString(values []string, candidate string) bool {
return false
}
// exportFailureReason explains why an item was not confirmed sent, keyed by the
// provider's failed/not-found result sets, so callers can record a per-item
// error and let the pending queue advance.
func exportFailureReason(result ExportResult, item LocalFavorite, kind ListKind) string {
if msg, ok := result.Failed[item.MediaItemID]; ok && msg != "" {
return msg
}
if msg, ok := result.Failed[item.ProviderItemKey]; ok && msg != "" {
return msg
}
if containsString(result.NotFound, item.MediaItemID) || containsString(result.NotFound, item.ProviderItemKey) {
return string(kind) + " item not found by provider"
}
return string(kind) + " item not confirmed by provider"
}
func historySourceForProvider(provider any) userstore.WatchHistorySource {
if sourceProvider, ok := provider.(HistorySourceProvider); ok {
if source := sourceProvider.HistorySource(); source != "" {
+62 -56
View File
@@ -20,7 +20,7 @@ type serviceFakeRepo struct {
settings map[string]string
syncRuns []SyncRun
historyExports []HistoryExport
favoriteStates []FavoriteState
listItemStates []ListItemState
scrobbleConnections []Connection
scrobbleSessions []ScrobbleSession
scrobbleUpdates []scrobbleUpdate
@@ -211,21 +211,18 @@ func (r *serviceFakeRepo) ListLocalWatchEventConnections(_ context.Context, user
return conns, nil
}
func (r *serviceFakeRepo) ListFavoriteEventConnections(_ context.Context, userID int, profileID string, kind LocalFavoriteEventKind) ([]Connection, error) {
func (r *serviceFakeRepo) ListListEventConnections(_ context.Context, userID int, profileID string, list ListKind) ([]Connection, error) {
var conns []Connection
for _, conn := range r.connections {
if conn.UserID != userID || conn.ProfileID != profileID {
continue
}
switch kind {
case LocalFavoriteEventAdded:
if conn.ExportFavoritesEnabled {
conns = append(conns, cloneConnectionForTest(conn))
}
case LocalFavoriteEventRemoved:
if conn.ExportFavoritesEnabled {
conns = append(conns, cloneConnectionForTest(conn))
}
enabled := conn.ExportFavoritesEnabled
if list == ListKindWatchlist {
enabled = conn.ExportWatchlistEnabled
}
if enabled {
conns = append(conns, cloneConnectionForTest(conn))
}
}
return conns, nil
@@ -276,43 +273,48 @@ func (r *serviceFakeRepo) MarkHistoryExportStatus(_ context.Context, id string,
return nil
}
func (r *serviceFakeRepo) UpsertFavoriteStates(_ context.Context, states []FavoriteState) error {
func (r *serviceFakeRepo) UpsertListItemStates(_ context.Context, states []ListItemState) error {
for _, state := range states {
if state.ListKind == "" {
state.ListKind = ListKindFavorites
}
replaced := false
for i := range r.favoriteStates {
if r.favoriteStates[i].ConnectionID == state.ConnectionID && r.favoriteStates[i].MediaItemID == state.MediaItemID {
for i := range r.listItemStates {
if r.listItemStates[i].ConnectionID == state.ConnectionID &&
r.listItemStates[i].ListKind == state.ListKind &&
r.listItemStates[i].MediaItemID == state.MediaItemID {
if state.ID == "" {
state.ID = r.favoriteStates[i].ID
state.ID = r.listItemStates[i].ID
}
r.favoriteStates[i] = state
r.listItemStates[i] = state
replaced = true
break
}
}
if !replaced {
if state.ID == "" {
state.ID = "favorite-" + strconv.Itoa(len(r.favoriteStates)+1)
state.ID = "list-item-" + strconv.Itoa(len(r.listItemStates)+1)
}
r.favoriteStates = append(r.favoriteStates, state)
r.listItemStates = append(r.listItemStates, state)
}
}
return nil
}
func (r *serviceFakeRepo) ListFavoriteStates(_ context.Context, connectionID string) ([]FavoriteState, error) {
var states []FavoriteState
for _, state := range r.favoriteStates {
if state.ConnectionID == connectionID {
func (r *serviceFakeRepo) ListListItemStates(_ context.Context, connectionID string, kind ListKind) ([]ListItemState, error) {
var states []ListItemState
for _, state := range r.listItemStates {
if state.ConnectionID == connectionID && state.ListKind == kind {
states = append(states, state)
}
}
return states, nil
}
func (r *serviceFakeRepo) ListPendingFavoriteExports(_ context.Context, connectionID string, limit int) ([]FavoriteState, error) {
var states []FavoriteState
for _, state := range r.favoriteStates {
if state.ConnectionID == connectionID && state.LocalPresent && !state.RemotePresent && state.LastError == "" {
func (r *serviceFakeRepo) ListPendingListItemExports(_ context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
var states []ListItemState
for _, state := range r.listItemStates {
if state.ConnectionID == connectionID && state.ListKind == kind && state.LocalPresent && !state.RemotePresent && state.LastError == "" {
states = append(states, state)
if limit > 0 && len(states) >= limit {
break
@@ -322,10 +324,10 @@ func (r *serviceFakeRepo) ListPendingFavoriteExports(_ context.Context, connecti
return states, nil
}
func (r *serviceFakeRepo) ListPendingFavoriteRemovals(_ context.Context, connectionID string, limit int) ([]FavoriteState, error) {
var states []FavoriteState
for _, state := range r.favoriteStates {
if state.ConnectionID == connectionID && !state.LocalPresent && state.RemotePresent && state.LastError == "" {
func (r *serviceFakeRepo) ListPendingListItemRemovals(_ context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
var states []ListItemState
for _, state := range r.listItemStates {
if state.ConnectionID == connectionID && state.ListKind == kind && !state.LocalPresent && state.RemotePresent && state.LastError == "" {
states = append(states, state)
if limit > 0 && len(states) >= limit {
break
@@ -335,43 +337,47 @@ func (r *serviceFakeRepo) ListPendingFavoriteRemovals(_ context.Context, connect
return states, nil
}
func (r *serviceFakeRepo) MarkFavoriteExported(_ context.Context, connectionID, mediaItemID string, exportedAt time.Time) error {
for i := range r.favoriteStates {
if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID {
r.favoriteStates[i].RemotePresent = true
r.favoriteStates[i].LocalPresent = true
r.favoriteStates[i].LastExportedAt = &exportedAt
func (r *serviceFakeRepo) markListItem(connectionID string, kind ListKind, mediaItemID string, apply func(*ListItemState)) {
for i := range r.listItemStates {
if r.listItemStates[i].ConnectionID == connectionID && r.listItemStates[i].ListKind == kind && r.listItemStates[i].MediaItemID == mediaItemID {
apply(&r.listItemStates[i])
}
}
}
func (r *serviceFakeRepo) MarkListItemExported(_ context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error {
// Mirror Postgres: successful transitions clear last_error.
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.RemotePresent = true
s.LocalPresent = true
s.LastExportedAt = &exportedAt
s.LastError = ""
})
return nil
}
func (r *serviceFakeRepo) MarkFavoriteRemoteRemoved(_ context.Context, connectionID, mediaItemID string, removedAt time.Time) error {
for i := range r.favoriteStates {
if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID {
r.favoriteStates[i].RemotePresent = false
r.favoriteStates[i].LastRemovedRemoteAt = &removedAt
}
}
func (r *serviceFakeRepo) MarkListItemRemoteRemoved(_ context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.RemotePresent = false
s.LastRemovedRemoteAt = &removedAt
s.LastError = ""
})
return nil
}
func (r *serviceFakeRepo) MarkFavoriteLocalRemoved(_ context.Context, connectionID, mediaItemID string, removedAt time.Time) error {
for i := range r.favoriteStates {
if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID {
r.favoriteStates[i].LocalPresent = false
r.favoriteStates[i].LastRemovedLocalAt = &removedAt
}
}
func (r *serviceFakeRepo) MarkListItemLocalRemoved(_ context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.LocalPresent = false
s.LastRemovedLocalAt = &removedAt
s.LastError = ""
})
return nil
}
func (r *serviceFakeRepo) MarkFavoriteError(_ context.Context, connectionID, mediaItemID, lastError string) error {
for i := range r.favoriteStates {
if r.favoriteStates[i].ConnectionID == connectionID && r.favoriteStates[i].MediaItemID == mediaItemID {
r.favoriteStates[i].LastError = lastError
}
}
func (r *serviceFakeRepo) MarkListItemError(_ context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error {
r.markListItem(connectionID, kind, mediaItemID, func(s *ListItemState) {
s.LastError = lastError
})
return nil
}
+133 -70
View File
@@ -9,16 +9,31 @@ import (
)
type Capabilities struct {
ImportWatched bool `json:"import_watched"`
ImportProgress bool `json:"import_progress"`
ExportWatched bool `json:"export_watched"`
ExportUnwatched bool `json:"export_unwatched"`
ImportFavorites bool `json:"import_favorites"`
ExportFavorites bool `json:"export_favorites"`
RemoveFavorites bool `json:"remove_favorites"`
ScrobblePlayback bool `json:"scrobble_playback"`
ImportWatched bool `json:"import_watched"`
ImportProgress bool `json:"import_progress"`
ExportWatched bool `json:"export_watched"`
ExportUnwatched bool `json:"export_unwatched"`
ImportFavorites bool `json:"import_favorites"`
ExportFavorites bool `json:"export_favorites"`
RemoveFavorites bool `json:"remove_favorites"`
ImportWatchlist bool `json:"import_watchlist"`
ExportWatchlist bool `json:"export_watchlist"`
RemoveWatchlist bool `json:"remove_watchlist"`
// ProvidesWatchlistOrder is true when the provider returns its watchlist in a
// user-configurable order that Silo can mirror locally.
ProvidesWatchlistOrder bool `json:"provides_watchlist_order"`
ScrobblePlayback bool `json:"scrobble_playback"`
}
// ListKind identifies which personal list a sync operates on. The favorites and
// watchlist pipelines share the same machinery, parameterized by this kind.
type ListKind string
const (
ListKindFavorites ListKind = "favorites"
ListKindWatchlist ListKind = "watchlist"
)
const (
AuthMethodDeviceCode = "device_code"
AuthMethodAPIKey = "api_key"
@@ -109,6 +124,26 @@ type FavoriteRemover interface {
RemoveFavorites(ctx context.Context, cfg ServerConfig, conn Connection, favorites []LocalFavorite) (ExportResult, error)
}
// Watchlist provider interfaces mirror the favorite ones but target the
// provider's watchlist ("want to watch") list rather than its favorites. They
// reuse the RemoteFavorite/LocalFavorite/FavoriteImportBatch carriers, which
// hold only generic item identity (external ids, title, year, kind).
type WatchlistImporter interface {
FetchWatchlist(ctx context.Context, cfg ServerConfig, conn Connection) ([]RemoteFavorite, error)
}
type WatchlistBatchImporter interface {
FetchWatchlistBatch(ctx context.Context, cfg ServerConfig, conn Connection) (FavoriteImportBatch, error)
}
type WatchlistExporter interface {
ExportWatchlist(ctx context.Context, cfg ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error)
}
type WatchlistRemover interface {
RemoveWatchlist(ctx context.Context, cfg ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error)
}
type Scrobbler interface {
Start(ctx context.Context, cfg ServerConfig, conn Connection, event ScrobbleEvent) error
Pause(ctx context.Context, cfg ServerConfig, conn Connection, event ScrobbleEvent) error
@@ -129,32 +164,37 @@ func (c ServerConfig) Configured() bool {
}
type Connection struct {
ID string
Provider string
UserID int
ProfileID string
ProviderAccountID string
ProviderUsername string
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
ImportWatchedEnabled bool
ImportProgressEnabled bool
ExportWatchedEnabled bool
ExportUnwatchedEnabled bool
ImportFavoritesEnabled bool
ExportFavoritesEnabled bool
SyncFavoriteRemovalsEnabled bool
ScrobbleEnabled bool
LastInboundSyncAt *time.Time
LastProgressSyncAt *time.Time
LastOutboundSyncAt *time.Time
LastFavoritesSyncAt *time.Time
LastScrobbleErrorAt *time.Time
LastError string
SyncCursors map[string]string `json:"-"`
CreatedAt time.Time
UpdatedAt time.Time
ID string
Provider string
UserID int
ProfileID string
ProviderAccountID string
ProviderUsername string
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
ImportWatchedEnabled bool
ImportProgressEnabled bool
ExportWatchedEnabled bool
ExportUnwatchedEnabled bool
ImportFavoritesEnabled bool
ExportFavoritesEnabled bool
SyncFavoriteRemovalsEnabled bool
ImportWatchlistEnabled bool
ExportWatchlistEnabled bool
SyncWatchlistRemovalsEnabled bool
SyncWatchlistOrderEnabled bool
ScrobbleEnabled bool
LastInboundSyncAt *time.Time
LastProgressSyncAt *time.Time
LastOutboundSyncAt *time.Time
LastFavoritesSyncAt *time.Time
LastWatchlistSyncAt *time.Time
LastScrobbleErrorAt *time.Time
LastError string
SyncCursors map[string]string `json:"-"`
CreatedAt time.Time
UpdatedAt time.Time
}
type SyncRun struct {
@@ -174,6 +214,11 @@ type SyncRun struct {
OutboundFavoritesFound int `json:"outbound_favorites_found"`
OutboundFavoritesSent int `json:"outbound_favorites_sent"`
FavoriteRemovalsSent int `json:"favorite_removals_sent"`
InboundWatchlistFound int `json:"inbound_watchlist_found"`
InboundWatchlistImported int `json:"inbound_watchlist_imported"`
OutboundWatchlistFound int `json:"outbound_watchlist_found"`
OutboundWatchlistSent int `json:"outbound_watchlist_sent"`
WatchlistRemovalsSent int `json:"watchlist_removals_sent"`
Warning string `json:"warning,omitempty"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
@@ -357,18 +402,22 @@ type LocalWatchEvent struct {
Plays []LocalPlay
}
type LocalFavoriteEventKind string
type ListChange string
const (
LocalFavoriteEventAdded LocalFavoriteEventKind = "favorite_added"
LocalFavoriteEventRemoved LocalFavoriteEventKind = "favorite_removed"
ListChangeAdded ListChange = "added"
ListChangeRemoved ListChange = "removed"
)
type LocalFavoriteEvent struct {
Kind LocalFavoriteEventKind
// LocalListEvent reports that a profile's local list membership changed (an item
// added or removed), so the watch providers bound to that list kind can mirror
// the change. It serves both the favorites and watchlist lists via List.
type LocalListEvent struct {
List ListKind
Change ListChange
UserID int
ProfileID string
Favorites []LocalFavorite
Items []LocalFavorite
}
type HistoryExport struct {
@@ -386,9 +435,14 @@ type HistoryExport struct {
UpdatedAt time.Time
}
type FavoriteState struct {
// ListItemState is the per-connection, per-list shadow record tracking whether
// an item is present locally and/or remotely, used to drive incremental
// export/removal reconciliation. It serves both the favorites and watchlist
// lists, discriminated by ListKind.
type ListItemState struct {
ID string
ConnectionID string
ListKind ListKind
MediaItemID string
ProviderItemKey string
Kind string
@@ -524,36 +578,45 @@ type ProviderSummary struct {
}
type ConnectionStatus struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
AuthMethod string `json:"auth_method"`
Connected bool `json:"connected"`
ProviderUsername string `json:"provider_username,omitempty"`
ImportWatchedEnabled bool `json:"import_watched_enabled"`
ImportProgressEnabled bool `json:"import_progress_enabled"`
ExportWatchedEnabled bool `json:"export_watched_enabled"`
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
ScrobbleEnabled bool `json:"scrobble_enabled"`
CredentialsConfigured bool `json:"credentials_configured"`
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
LastError string `json:"last_error,omitempty"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
AuthMethod string `json:"auth_method"`
Connected bool `json:"connected"`
ProviderUsername string `json:"provider_username,omitempty"`
ImportWatchedEnabled bool `json:"import_watched_enabled"`
ImportProgressEnabled bool `json:"import_progress_enabled"`
ExportWatchedEnabled bool `json:"export_watched_enabled"`
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
ScrobbleEnabled bool `json:"scrobble_enabled"`
CredentialsConfigured bool `json:"credentials_configured"`
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type ConnectionUpdate struct {
ImportWatchedEnabled *bool `json:"import_watched_enabled,omitempty"`
ImportProgressEnabled *bool `json:"import_progress_enabled,omitempty"`
ExportWatchedEnabled *bool `json:"export_watched_enabled,omitempty"`
ExportUnwatchedEnabled *bool `json:"export_unwatched_enabled,omitempty"`
ImportFavoritesEnabled *bool `json:"import_favorites_enabled,omitempty"`
ExportFavoritesEnabled *bool `json:"export_favorites_enabled,omitempty"`
SyncFavoriteRemovalsEnabled *bool `json:"sync_favorite_removals_enabled,omitempty"`
ScrobbleEnabled *bool `json:"scrobble_enabled,omitempty"`
ImportWatchedEnabled *bool `json:"import_watched_enabled,omitempty"`
ImportProgressEnabled *bool `json:"import_progress_enabled,omitempty"`
ExportWatchedEnabled *bool `json:"export_watched_enabled,omitempty"`
ExportUnwatchedEnabled *bool `json:"export_unwatched_enabled,omitempty"`
ImportFavoritesEnabled *bool `json:"import_favorites_enabled,omitempty"`
ExportFavoritesEnabled *bool `json:"export_favorites_enabled,omitempty"`
SyncFavoriteRemovalsEnabled *bool `json:"sync_favorite_removals_enabled,omitempty"`
ImportWatchlistEnabled *bool `json:"import_watchlist_enabled,omitempty"`
ExportWatchlistEnabled *bool `json:"export_watchlist_enabled,omitempty"`
SyncWatchlistRemovalsEnabled *bool `json:"sync_watchlist_removals_enabled,omitempty"`
SyncWatchlistOrderEnabled *bool `json:"sync_watchlist_order_enabled,omitempty"`
ScrobbleEnabled *bool `json:"scrobble_enabled,omitempty"`
}
@@ -0,0 +1,15 @@
-- +goose Up
UPDATE server_settings
SET value = '0.50'
WHERE key = 'catalog.search.meilisearch.semantic_ratio'
AND value = '0.30';
INSERT INTO server_settings (key, value)
VALUES ('catalog.search.meilisearch.semantic_ratio', '0.50')
ON CONFLICT (key) DO NOTHING;
-- +goose Down
UPDATE server_settings
SET value = '0.30'
WHERE key = 'catalog.search.meilisearch.semantic_ratio'
AND value = '0.50';
@@ -0,0 +1,127 @@
-- +goose Up
-- +goose StatementBegin
-- Profile preference: remove an item from the watchlist once it is fully
-- watched (a movie on completion, a series once every episode is watched).
-- Defaults on; it is a standalone behavior that also propagates to connected
-- watchlist providers.
ALTER TABLE public.user_profiles
ADD COLUMN remove_watched_from_watchlist boolean NOT NULL DEFAULT true;
-- Per-connection watchlist sync toggles, mirroring the favorites toggles.
ALTER TABLE public.watch_provider_connections
ADD COLUMN import_watchlist_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN export_watchlist_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN sync_watchlist_removals_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN last_watchlist_sync_at timestamptz;
-- Per-run watchlist counters, mirroring the favorites counters.
ALTER TABLE public.watch_provider_sync_runs
ADD COLUMN inbound_watchlist_found integer NOT NULL DEFAULT 0,
ADD COLUMN inbound_watchlist_imported integer NOT NULL DEFAULT 0,
ADD COLUMN outbound_watchlist_found integer NOT NULL DEFAULT 0,
ADD COLUMN outbound_watchlist_sent integer NOT NULL DEFAULT 0,
ADD COLUMN watchlist_removals_sent integer NOT NULL DEFAULT 0;
-- The favorites shadow table generalizes into a list-item shadow table keyed by
-- a list_kind discriminator ('favorites' | 'watchlist'). Existing rows are all
-- favorites, so the new column defaults accordingly.
ALTER TABLE public.watch_provider_favorite_items
RENAME TO watch_provider_list_items;
ALTER TABLE public.watch_provider_list_items
ADD COLUMN list_kind text NOT NULL DEFAULT 'favorites';
ALTER TABLE public.watch_provider_list_items
DROP CONSTRAINT watch_provider_favorite_items_connection_media_key;
ALTER TABLE public.watch_provider_list_items
ADD CONSTRAINT watch_provider_list_items_connection_kind_media_key
UNIQUE (connection_id, list_kind, media_item_id);
DROP INDEX IF EXISTS idx_watch_provider_favorite_items_provider_key;
CREATE UNIQUE INDEX idx_watch_provider_list_items_provider_key
ON public.watch_provider_list_items (connection_id, list_kind, provider_item_key)
WHERE provider_item_key <> '';
ALTER INDEX idx_watch_provider_favorite_items_connection_remote
RENAME TO idx_watch_provider_list_items_connection_remote;
ALTER INDEX idx_watch_provider_favorite_items_connection_local
RENAME TO idx_watch_provider_list_items_connection_local;
-- Re-map MDBList. MDBList exposes a single list — its watchlist — which Silo
-- historically bound to the *favorites* abstraction. Re-bind those connections
-- to the watchlist abstraction so MDBList's watchlist mirrors Silo's watchlist.
-- The stale favorites shadow rows are dropped so the next sync rebuilds clean
-- watchlist state (removals default off, so the first sync is a union, not a
-- purge).
UPDATE public.watch_provider_connections
SET import_watchlist_enabled = import_favorites_enabled,
export_watchlist_enabled = export_favorites_enabled,
sync_watchlist_removals_enabled = sync_favorite_removals_enabled,
last_watchlist_sync_at = last_favorites_sync_at,
import_favorites_enabled = false,
export_favorites_enabled = false,
sync_favorite_removals_enabled = false
WHERE provider = 'mdblist';
DELETE FROM public.watch_provider_list_items
WHERE list_kind = 'favorites'
AND connection_id IN (
SELECT id FROM public.watch_provider_connections WHERE provider = 'mdblist'
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Re-bind MDBList connections back to the favorites abstraction.
UPDATE public.watch_provider_connections
SET import_favorites_enabled = import_watchlist_enabled,
export_favorites_enabled = export_watchlist_enabled,
sync_favorite_removals_enabled = sync_watchlist_removals_enabled,
last_favorites_sync_at = last_watchlist_sync_at
WHERE provider = 'mdblist';
-- Collapsing list_kind back into a single list requires dropping any
-- watchlist-only rows to avoid violating the restored unique constraint.
DELETE FROM public.watch_provider_list_items
WHERE list_kind <> 'favorites';
ALTER INDEX idx_watch_provider_list_items_connection_local
RENAME TO idx_watch_provider_favorite_items_connection_local;
ALTER INDEX idx_watch_provider_list_items_connection_remote
RENAME TO idx_watch_provider_favorite_items_connection_remote;
DROP INDEX IF EXISTS idx_watch_provider_list_items_provider_key;
CREATE UNIQUE INDEX idx_watch_provider_favorite_items_provider_key
ON public.watch_provider_list_items (connection_id, provider_item_key)
WHERE provider_item_key <> '';
ALTER TABLE public.watch_provider_list_items
DROP CONSTRAINT watch_provider_list_items_connection_kind_media_key;
ALTER TABLE public.watch_provider_list_items
ADD CONSTRAINT watch_provider_favorite_items_connection_media_key
UNIQUE (connection_id, media_item_id);
ALTER TABLE public.watch_provider_list_items
DROP COLUMN list_kind;
ALTER TABLE public.watch_provider_list_items
RENAME TO watch_provider_favorite_items;
ALTER TABLE public.watch_provider_sync_runs
DROP COLUMN IF EXISTS watchlist_removals_sent,
DROP COLUMN IF EXISTS outbound_watchlist_sent,
DROP COLUMN IF EXISTS outbound_watchlist_found,
DROP COLUMN IF EXISTS inbound_watchlist_imported,
DROP COLUMN IF EXISTS inbound_watchlist_found;
ALTER TABLE public.watch_provider_connections
DROP COLUMN IF EXISTS last_watchlist_sync_at,
DROP COLUMN IF EXISTS sync_watchlist_removals_enabled,
DROP COLUMN IF EXISTS export_watchlist_enabled,
DROP COLUMN IF EXISTS import_watchlist_enabled;
ALTER TABLE public.user_profiles
DROP COLUMN IF EXISTS remove_watched_from_watchlist;
-- +goose StatementEnd
@@ -0,0 +1,30 @@
-- +goose Up
-- +goose StatementBegin
-- Optional mirroring of an external provider's watchlist sort order. sort_index
-- is NULL for locally-added items (which fall back to added_at ordering) and
-- 0..N-1 for items whose order is synced from a provider (currently MDBList).
ALTER TABLE public.user_watchlist
ADD COLUMN sort_index integer;
-- Supports "ORDER BY sort_index ASC NULLS LAST, added_at DESC".
CREATE INDEX idx_user_watchlist_profile_sort
ON public.user_watchlist (user_id, profile_id, sort_index ASC NULLS LAST, added_at DESC);
-- Per-connection toggle: mirror the provider's watchlist order into Silo's
-- watchlist. Gated in the UI by the provider's provides_watchlist_order
-- capability.
ALTER TABLE public.watch_provider_connections
ADD COLUMN sync_watchlist_order_enabled boolean NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE public.watch_provider_connections
DROP COLUMN IF EXISTS sync_watchlist_order_enabled;
DROP INDEX IF EXISTS idx_user_watchlist_profile_sort;
ALTER TABLE public.user_watchlist
DROP COLUMN IF EXISTS sort_index;
-- +goose StatementEnd
@@ -0,0 +1,89 @@
-- +goose Up
-- media_items has many columns that the Go model (models.MediaItem) declares as
-- non-pointer string/int fields, yet migration 001 left them nullable. Every
-- scanner — catalog item_repo, catalog browse, jellycompat, sections, and the
-- catalog API handler — scans these straight into the non-pointer fields, so a
-- NULL row panics with "cannot scan NULL into *string" (or *int). item_repo
-- papers over a subset (poster/backdrop/logo/metadata paths) with COALESCE in
-- its SELECT list, but the other four scanners read the same columns raw and
-- crash; sort_title/original_title/etc. are not coalesced anywhere.
--
-- No writer stores a meaningful NULL: every insert/upsert passes the Go field
-- ('' or 0 at worst), and all sort/filter SQL already collapses NULL and ''
-- (COALESCE(NULLIF(BTRIM(sort_title), ''), title); "poster_path IS NULL OR
-- poster_path = ''"; etc.). The only deliberate NULLs are poster_thumbhash /
-- backdrop_thumbhash written via NULLIF($, '') in item_repo; those writers are
-- updated in the same change to store '' directly. NULL rows otherwise arise
-- only from raw SQL inserts and legacy data.
--
-- Enforce the invariant the code already assumes so the whole class of NULL-scan
-- crash is fixed in one place rather than scattering COALESCE across every
-- current and future scanner. This mirrors what later migrations already did for
-- original_language, show_status, default_metadata_language, and the *_source_path
-- columns (all NOT NULL DEFAULT '').
-- Backfill existing NULLs to the type-appropriate empty value.
UPDATE media_items SET
sort_title = COALESCE(sort_title, ''),
original_title = COALESCE(original_title, ''),
content_rating = COALESCE(content_rating, ''),
overview = COALESCE(overview, ''),
tagline = COALESCE(tagline, ''),
imdb_id = COALESCE(imdb_id, ''),
tmdb_id = COALESCE(tmdb_id, ''),
tvdb_id = COALESCE(tvdb_id, ''),
poster_path = COALESCE(poster_path, ''),
poster_thumbhash = COALESCE(poster_thumbhash, ''),
backdrop_path = COALESCE(backdrop_path, ''),
backdrop_thumbhash = COALESCE(backdrop_thumbhash, ''),
logo_path = COALESCE(logo_path, ''),
metadata_s3_path = COALESCE(metadata_s3_path, ''),
metadata_etag = COALESCE(metadata_etag, '')
WHERE sort_title IS NULL OR original_title IS NULL OR content_rating IS NULL
OR overview IS NULL OR tagline IS NULL OR imdb_id IS NULL OR tmdb_id IS NULL
OR tvdb_id IS NULL OR poster_path IS NULL OR poster_thumbhash IS NULL
OR backdrop_path IS NULL OR backdrop_thumbhash IS NULL OR logo_path IS NULL
OR metadata_s3_path IS NULL OR metadata_etag IS NULL;
UPDATE media_items SET year = 0 WHERE year IS NULL;
UPDATE media_items SET runtime = 0 WHERE runtime IS NULL;
-- Establish defaults and NOT NULL atomically (single ACCESS EXCLUSIVE lock).
ALTER TABLE media_items
ALTER COLUMN sort_title SET DEFAULT '', ALTER COLUMN sort_title SET NOT NULL,
ALTER COLUMN original_title SET DEFAULT '', ALTER COLUMN original_title SET NOT NULL,
ALTER COLUMN content_rating SET DEFAULT '', ALTER COLUMN content_rating SET NOT NULL,
ALTER COLUMN overview SET DEFAULT '', ALTER COLUMN overview SET NOT NULL,
ALTER COLUMN tagline SET DEFAULT '', ALTER COLUMN tagline SET NOT NULL,
ALTER COLUMN imdb_id SET DEFAULT '', ALTER COLUMN imdb_id SET NOT NULL,
ALTER COLUMN tmdb_id SET DEFAULT '', ALTER COLUMN tmdb_id SET NOT NULL,
ALTER COLUMN tvdb_id SET DEFAULT '', ALTER COLUMN tvdb_id SET NOT NULL,
ALTER COLUMN poster_path SET DEFAULT '', ALTER COLUMN poster_path SET NOT NULL,
ALTER COLUMN poster_thumbhash SET DEFAULT '', ALTER COLUMN poster_thumbhash SET NOT NULL,
ALTER COLUMN backdrop_path SET DEFAULT '', ALTER COLUMN backdrop_path SET NOT NULL,
ALTER COLUMN backdrop_thumbhash SET DEFAULT '', ALTER COLUMN backdrop_thumbhash SET NOT NULL,
ALTER COLUMN logo_path SET DEFAULT '', ALTER COLUMN logo_path SET NOT NULL,
ALTER COLUMN metadata_s3_path SET DEFAULT '', ALTER COLUMN metadata_s3_path SET NOT NULL,
ALTER COLUMN metadata_etag SET DEFAULT '', ALTER COLUMN metadata_etag SET NOT NULL,
ALTER COLUMN year SET DEFAULT 0, ALTER COLUMN year SET NOT NULL,
ALTER COLUMN runtime SET DEFAULT 0, ALTER COLUMN runtime SET NOT NULL;
-- +goose Down
ALTER TABLE media_items
ALTER COLUMN sort_title DROP NOT NULL, ALTER COLUMN sort_title DROP DEFAULT,
ALTER COLUMN original_title DROP NOT NULL, ALTER COLUMN original_title DROP DEFAULT,
ALTER COLUMN content_rating DROP NOT NULL, ALTER COLUMN content_rating DROP DEFAULT,
ALTER COLUMN overview DROP NOT NULL, ALTER COLUMN overview DROP DEFAULT,
ALTER COLUMN tagline DROP NOT NULL, ALTER COLUMN tagline DROP DEFAULT,
ALTER COLUMN imdb_id DROP NOT NULL, ALTER COLUMN imdb_id DROP DEFAULT,
ALTER COLUMN tmdb_id DROP NOT NULL, ALTER COLUMN tmdb_id DROP DEFAULT,
ALTER COLUMN tvdb_id DROP NOT NULL, ALTER COLUMN tvdb_id DROP DEFAULT,
ALTER COLUMN poster_path DROP NOT NULL, ALTER COLUMN poster_path DROP DEFAULT,
ALTER COLUMN poster_thumbhash DROP NOT NULL, ALTER COLUMN poster_thumbhash DROP DEFAULT,
ALTER COLUMN backdrop_path DROP NOT NULL, ALTER COLUMN backdrop_path DROP DEFAULT,
ALTER COLUMN backdrop_thumbhash DROP NOT NULL, ALTER COLUMN backdrop_thumbhash DROP DEFAULT,
ALTER COLUMN logo_path DROP NOT NULL, ALTER COLUMN logo_path DROP DEFAULT,
ALTER COLUMN metadata_s3_path DROP NOT NULL, ALTER COLUMN metadata_s3_path DROP DEFAULT,
ALTER COLUMN metadata_etag DROP NOT NULL, ALTER COLUMN metadata_etag DROP DEFAULT,
ALTER COLUMN year DROP NOT NULL, ALTER COLUMN year DROP DEFAULT,
ALTER COLUMN runtime DROP NOT NULL, ALTER COLUMN runtime DROP DEFAULT;
+3
View File
@@ -74,11 +74,14 @@
"esbuild"
],
"overrides": {
"@babel/core@<7.29.6": "7.29.6",
"brace-expansion@<1.1.13": "1.1.13",
"brace-expansion@>=4.0.0 <5.0.6": "5.0.6",
"flatted@<=3.4.1": "3.4.2",
"js-yaml@<4.2.0": "4.2.0",
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"postcss@<8.5.10": "8.5.10",
"undici@<7.28.0": "7.28.0",
"vite@>=6.0.0 <6.4.2": "6.4.2"
}
}
+82 -55
View File
@@ -5,11 +5,14 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@babel/core@<7.29.6': 7.29.6
brace-expansion@<1.1.13: 1.1.13
brace-expansion@>=4.0.0 <5.0.6: 5.0.6
flatted@<=3.4.1: 3.4.2
js-yaml@<4.2.0: 4.2.0
picomatch@>=4.0.0 <4.0.4: 4.0.4
postcss@<8.5.10: 8.5.10
undici@<7.28.0: 7.28.0
vite@>=6.0.0 <6.4.2: 6.4.2
importers:
@@ -184,10 +187,6 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -196,12 +195,12 @@ packages:
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
engines: {node: '>=6.9.0'}
'@babel/core@7.29.0':
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
'@babel/core@7.29.6':
resolution: {integrity: sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
'@babel/generator@7.29.7':
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.28.6':
@@ -220,7 +219,7 @@ packages:
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/core': 7.29.6
'@babel/helper-plugin-utils@7.28.6':
resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
@@ -230,6 +229,10 @@ packages:
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
@@ -242,8 +245,8 @@ packages:
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.28.6':
resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
'@babel/helpers@7.29.7':
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
@@ -251,17 +254,22 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-transform-react-jsx-self@7.27.1':
resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/core': 7.29.6
'@babel/plugin-transform-react-jsx-source@7.27.1':
resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/core': 7.29.6
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
@@ -275,6 +283,10 @@ packages:
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
@@ -283,6 +295,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -2205,8 +2221,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
jsdom@29.0.1:
@@ -2834,8 +2850,8 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.24.7:
resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
update-browserslist-db@1.2.3:
@@ -3024,12 +3040,6 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -3038,14 +3048,14 @@ snapshots:
'@babel/compat-data@7.29.0': {}
'@babel/core@7.29.0':
'@babel/core@7.29.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.28.6
'@babel/parser': 7.29.0
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
@@ -3058,10 +3068,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/generator@7.29.1':
'@babel/generator@7.29.7':
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
@@ -3083,11 +3093,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)':
dependencies:
'@babel/core': 7.29.0
'@babel/core': 7.29.6
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -3096,29 +3106,35 @@ snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.28.6':
'@babel/helpers@7.29.7':
dependencies:
'@babel/template': 7.28.6
'@babel/types': 7.29.0
'@babel/template': 7.29.7
'@babel/types': 7.29.7
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
'@babel/parser@7.29.7':
dependencies:
'@babel/core': 7.29.0
'@babel/types': 7.29.7
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.6)':
dependencies:
'@babel/core': 7.29.6
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.6)':
dependencies:
'@babel/core': 7.29.0
'@babel/core': 7.29.6
'@babel/helper-plugin-utils': 7.28.6
'@babel/runtime@7.29.2': {}
@@ -3127,16 +3143,22 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.0
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.0
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.0
'@babel/parser': 7.29.7
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
@@ -3148,6 +3170,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -3310,7 +3337,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
js-yaml: 4.2.0
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -4510,9 +4537,9 @@ snapshots:
'@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
'@babel/core': 7.29.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.6)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.6)
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
@@ -4765,7 +4792,7 @@ snapshots:
eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
'@babel/core': 7.29.6
'@babel/parser': 7.29.0
eslint: 9.39.4(jiti@2.6.1)
hermes-parser: 0.25.1
@@ -4958,7 +4985,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.1.1:
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
@@ -4979,7 +5006,7 @@ snapshots:
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.24.7
undici: 7.28.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -5499,7 +5526,7 @@ snapshots:
undici-types@7.18.2: {}
undici@7.24.7: {}
undici@7.28.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
@@ -257,12 +257,10 @@ export function useLibraryForm({
}
function toggleLevelProvider(level: string, index: number) {
setLevelChains((prev) => {
const source = prev[level] ?? activeLevelChains[level] ?? [];
const updated = [...source];
updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled };
return { ...prev, [level]: updated };
});
const source = activeLevelChains[level] ?? [];
const updated = [...source];
updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled };
setLevelChains({ ...activeLevelChains, [level]: updated });
setChainDirty(true);
}
+20 -1
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ApiClientError } from "@/api/client";
import { favoriteKeys, watchProviderKeys } from "./keys";
import { favoriteKeys, watchlistKeys, watchProviderKeys } from "./keys";
import { toast } from "sonner";
import { storage } from "@/utils/storage";
@@ -25,6 +25,10 @@ export interface WatchProviderCapabilities {
import_favorites: boolean;
export_favorites: boolean;
remove_favorites: boolean;
import_watchlist: boolean;
export_watchlist: boolean;
remove_watchlist: boolean;
provides_watchlist_order: boolean;
scrobble_playback: boolean;
}
@@ -42,12 +46,17 @@ export interface WatchProviderConnection {
import_favorites_enabled: boolean;
export_favorites_enabled: boolean;
sync_favorite_removals_enabled: boolean;
import_watchlist_enabled: boolean;
export_watchlist_enabled: boolean;
sync_watchlist_removals_enabled: boolean;
sync_watchlist_order_enabled: boolean;
scrobble_enabled: boolean;
credentials_configured: boolean;
last_inbound_sync_at?: string;
last_progress_sync_at?: string;
last_outbound_sync_at?: string;
last_favorites_sync_at?: string;
last_watchlist_sync_at?: string;
last_scrobble_error_at?: string;
last_error?: string;
}
@@ -78,6 +87,11 @@ export interface WatchProviderSyncRun {
outbound_favorites_found: number;
outbound_favorites_sent: number;
favorite_removals_sent: number;
inbound_watchlist_found: number;
inbound_watchlist_imported: number;
outbound_watchlist_found: number;
outbound_watchlist_sent: number;
watchlist_removals_sent: number;
warning?: string;
error?: string;
started_at: string;
@@ -109,6 +123,10 @@ export type UpdateWatchProviderConnection = Partial<
| "import_favorites_enabled"
| "export_favorites_enabled"
| "sync_favorite_removals_enabled"
| "import_watchlist_enabled"
| "export_watchlist_enabled"
| "sync_watchlist_removals_enabled"
| "sync_watchlist_order_enabled"
| "scrobble_enabled"
>
>;
@@ -284,6 +302,7 @@ export function useTriggerWatchProviderSync(provider: string) {
queryKey: watchProviderKeys.connection(profileId, provider),
});
queryClient.invalidateQueries({ queryKey: favoriteKeys.list() });
queryClient.invalidateQueries({ queryKey: watchlistKeys.list() });
toast.success("Watch provider sync started");
},
onError: (err) => {
@@ -271,8 +271,14 @@ function APIKeyBlock({
}
interface ConnectedRunInfo {
imported: { watched: number; progress: number; favorites: number };
exported: { watched: number; favorites: number; favoriteRemovals: number };
imported: { watched: number; progress: number; favorites: number; watchlist: number };
exported: {
watched: number;
favorites: number;
favoriteRemovals: number;
watchlist: number;
watchlistRemovals: number;
};
errorMessage?: string;
errorHint?: string;
}
@@ -285,11 +291,14 @@ function deriveRunInfo(
watched: latestRun?.inbound_watched_imported ?? 0,
progress: latestRun?.inbound_progress_imported ?? 0,
favorites: latestRun?.inbound_favorites_imported ?? 0,
watchlist: latestRun?.inbound_watchlist_imported ?? 0,
};
const exported = {
watched: latestRun?.outbound_sent ?? 0,
favorites: latestRun?.outbound_favorites_sent ?? 0,
favoriteRemovals: latestRun?.favorite_removals_sent ?? 0,
watchlist: latestRun?.outbound_watchlist_sent ?? 0,
watchlistRemovals: latestRun?.watchlist_removals_sent ?? 0,
};
let errorMessage: string | undefined;
let errorHint: string | undefined;
@@ -312,6 +321,7 @@ function formatLastSync(connection: WatchProviderConnection, latestRun?: WatchPr
connection.last_progress_sync_at,
connection.last_outbound_sync_at,
connection.last_favorites_sync_at,
connection.last_watchlist_sync_at,
].filter((value): value is string => Boolean(value));
if (candidates.length === 0) return "Never synced";
const newest = candidates
@@ -373,6 +383,8 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
const hasError = Boolean(runInfo?.errorMessage);
const favoritesSyncEnabled =
connection.import_favorites_enabled || connection.export_favorites_enabled;
const watchlistSyncEnabled =
connection.import_watchlist_enabled || connection.export_watchlist_enabled;
let statusVariant: StatusVariant;
let statusLabel: string;
@@ -554,14 +566,14 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
<div className="grid grid-cols-2 gap-3 sm:hidden">
<StatCell
label="Last imported"
value={`${runInfo.imported.watched.toLocaleString()} watched · ${runInfo.imported.progress.toLocaleString()} progress · ${runInfo.imported.favorites.toLocaleString()} favorites`}
value={`${runInfo.imported.watched.toLocaleString()} watched · ${runInfo.imported.progress.toLocaleString()} progress · ${runInfo.imported.favorites.toLocaleString()} favorites · ${runInfo.imported.watchlist.toLocaleString()} watchlist`}
/>
<StatCell
label="Last exported"
value={`${(runInfo.exported.watched + runInfo.exported.favorites).toLocaleString()} sent`}
value={`${(runInfo.exported.watched + runInfo.exported.favorites + runInfo.exported.favoriteRemovals + runInfo.exported.watchlist + runInfo.exported.watchlistRemovals).toLocaleString()} sent`}
/>
</div>
<div className="hidden grid-cols-4 gap-3 sm:grid">
<div className="hidden grid-cols-5 gap-3 sm:grid">
<StatCell
label="Watched"
value={`${runInfo.imported.watched.toLocaleString()} imported`}
@@ -574,9 +586,13 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
label="Favorites"
value={`${runInfo.imported.favorites.toLocaleString()} imported`}
/>
<StatCell
label="Watchlist"
value={`${runInfo.imported.watchlist.toLocaleString()} imported`}
/>
<StatCell
label="Exported"
value={`${(runInfo.exported.watched + runInfo.exported.favorites).toLocaleString()} sent`}
value={`${(runInfo.exported.watched + runInfo.exported.favorites + runInfo.exported.favoriteRemovals + runInfo.exported.watchlist + runInfo.exported.watchlistRemovals).toLocaleString()} sent`}
/>
</div>
</div>
@@ -645,6 +661,49 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
}
/>
) : null}
{connection.capabilities.import_watchlist ||
connection.capabilities.export_watchlist ? (
<ToggleRow
id={`watch-provider-${providerKey}-watchlist`}
label="Sync watchlist"
description={`Import your ${displayName} watchlist, and send local watchlist adds.`}
checked={watchlistSyncEnabled}
disabled={isBusy}
onChange={(checked) =>
updateConnection.mutate({
import_watchlist_enabled: checked,
export_watchlist_enabled: checked,
sync_watchlist_removals_enabled: checked
? connection.sync_watchlist_removals_enabled
: false,
})
}
/>
) : null}
{connection.capabilities.remove_watchlist ? (
<ToggleRow
id={`watch-provider-${providerKey}-watchlist-removals`}
label="Sync watchlist removals"
description="Remove provider-synced watchlist items on the other side when they are removed locally."
checked={connection.sync_watchlist_removals_enabled}
disabled={isBusy || !watchlistSyncEnabled}
onChange={(checked) =>
updateConnection.mutate({ sync_watchlist_removals_enabled: checked })
}
/>
) : null}
{connection.capabilities.provides_watchlist_order ? (
<ToggleRow
id={`watch-provider-${providerKey}-watchlist-order`}
label="Mirror watchlist order"
description={`Order your Silo watchlist to match its ${displayName} sort order. Items not on ${displayName} stay at the bottom.`}
checked={connection.sync_watchlist_order_enabled}
disabled={isBusy || !connection.import_watchlist_enabled}
onChange={(checked) =>
updateConnection.mutate({ sync_watchlist_order_enabled: checked })
}
/>
) : null}
<ToggleRow
id={`watch-provider-${providerKey}-scrobble`}
label="Scrobble playback"