Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a280e89a | ||
|
|
4d2a990de1 | ||
|
|
96a279819e | ||
|
|
443445f0fe | ||
|
|
02e62767a1 | ||
|
|
b4a66b29e2 | ||
|
|
6854eac80e | ||
|
|
6414061022 | ||
|
|
a3aa93534d | ||
|
|
d83f7fadef | ||
|
|
185a90b048 | ||
|
|
78d4f9acbf | ||
|
|
7f67bb80ad |
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -337,6 +337,7 @@ func withDefaults(deps Dependencies) Dependencies {
|
||||
catalog.NewContinueWatchingProgressFilter(pool),
|
||||
staler,
|
||||
deps.RecWorker,
|
||||
deps.WatchCompletionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}},
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+82
-55
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user