This commit is contained in:
Quick
2026-06-10 08:43:08 -04:00
637 changed files with 49441 additions and 6327 deletions
+2
View File
@@ -3,6 +3,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
@@ -22,6 +23,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
+2
View File
@@ -5,6 +5,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
@@ -19,6 +20,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
+18 -3
View File
@@ -47,6 +47,7 @@ import (
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/database"
"github.com/Silo-Server/silo-server/internal/ebooks"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/imagecache"
@@ -72,8 +73,6 @@ import (
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -943,6 +942,7 @@ func main() {
var seasonRepo *catalog.SeasonRepository
var episodeRepo *catalog.EpisodeRepository
var audiobookEnricher *audiobooks.Enricher
var ebookEnricher *ebooks.Enricher
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
chainRepo := metadata.NewChainRepository(deps.DB)
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
@@ -1024,6 +1024,14 @@ func main() {
personRepo,
providerIDRepo,
)
ebookEnricher = ebooks.NewEnricher(
deps.DB,
chainRepo,
pluginResolver,
itemRepo,
personRepo,
providerIDRepo,
)
// Always wire the image resolver so plugin-prefixed URLs (e.g.
// metadb://) can be resolved to presigned HTTP URLs in API responses.
@@ -1046,6 +1054,9 @@ func main() {
audiobookEnricher.SetImageCacher(imageCacher)
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
}
if ebookEnricher != nil {
ebookEnricher.SetImageCacher(imageCacher)
}
}
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
@@ -1541,7 +1552,8 @@ func main() {
catalog.NewProviderIDRepository(deps.DB),
),
)
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
auth.NewUserRepository(deps.DB),
@@ -1587,6 +1599,9 @@ func main() {
if audiobookEnricher != nil {
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
}
if ebookEnricher != nil {
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
}
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
if err != nil {
@@ -0,0 +1,98 @@
# Wholphin → jellycompat Endpoint Coverage Report
Date: 2026-06-09
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
## Methodology
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
## Summary
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
## Coverage matrix
### Fully covered
| Wholphin call | Endpoint | silo handler |
|---|---|---|
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
### Partial / stub (graceful degradation)
| Endpoint | Status | Client impact |
|---|---|---|
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
### Missing but safely gated (client never calls them)
| Endpoint | Why it's unreachable |
|---|---|
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
### Missing and reachable (return 404 today)
| Endpoint | Wholphin caller | Impact | Severity |
|---|---|---|---|
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
## Recommendations (priority order)
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
## Notes
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
+82
View File
@@ -0,0 +1,82 @@
# PR #124 (ebooks) — Review & Hardening Pass
Date: 2026-06-10
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
fixes for every finding. Two review rounds were performed (a full-PR review, then an
adversarial edge-case/security pass over both the original code and the first round of
fixes), with all changes verified against the full Go test suite, the full web test
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
Commands assume the repository root is the cwd.
## Security fixes
- **Stored XSS via book content (high):** book sections render in same-origin blob
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
execution from book content on all browsers. Threat model documented on the policy
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
ebook file responses.
- Ebook file serving can no longer fall through to `application/octet-stream` for an
admitted file (extension/container whitelist and MIME resolution share one resolver),
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
- External links in books are intercepted app-side: http(s) only, opened with
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
presence-aware field semantics and invariant re-validation.
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
user+profile+content), no path traversal, range serving via stdlib, progress input
validation. Accepted residual: CSS-only loads inside book iframes can leak the
book's own content but cannot reach tokens, DOM, or navigation.
## Reliability fixes
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
missing files like video/audio, with real walk-failure tracking (failed roots are
excluded from deletion), symlinked-root support via the shared logical walker, and
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
backoff table, capped retries) instead of permanently stamping items done;
unconfigured chains and scan-window races skip without stamping. No longer shares
`media_items.refresh_failures` with the metadata refresh-debt system.
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
migration included), making curated-metadata protection real: matched items keep
provider titles/people/series on re-scan (fill-empty only).
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
format; plain `.fb2` reads capped like `.fbz`.
- FK-cascade indexes added for the reader-state tables.
## Consistency & correctness fixes
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
metrics, `Played` state, recommendation signals) with the exact video semantics.
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
implicit taste signals and taste-seed candidates; Continue Reading pages past
dismissals and dedupes across pages.
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
512 MiB download guard, fraction bookmarks navigable.
## Feature completion
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
mirrors video unwatch and clears position; history removal hides without losing
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
## Client follow-ups (Android / Apple)
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
`duration_seconds = 1`; do not render absolute times for ebooks.
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
existing watched SSE event fires.
- Reading progress is deliberately per-book (cross-format): one position per content
ID.
@@ -0,0 +1,301 @@
# Ebook Reader Full Parity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
---
## File Structure
- `migrations/sql/20260608000300_ebook_reader_state.sql`
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
- `internal/api/handlers/ebook_reader.go`
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
- `internal/api/handlers/ebook_reader_test.go`
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
- `internal/api/router.go`
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
- `web/src/reader/FoliateBookReader.tsx`
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
- `web/src/pages/EbookReader.tsx`
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
- Port focused personal-reader hooks into core web.
- `web/src/reader/ebookReaderApi.ts`
- Centralizes reader config and annotation API calls for tests and page usage.
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
- Adds UI and wrapper tests.
## Task 1: Server-Persisted Reader Config
**Files:**
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
- Modify: `internal/api/handlers/ebook_reader.go`
- Modify: `internal/api/handlers/ebook_reader_test.go`
- Modify: `internal/api/router.go`
- Create: `web/src/reader/ebookReaderApi.ts`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Write failing Go handler tests**
Add tests that:
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
- inaccessible content returns 404 through the existing `ItemAccess` path.
- invalid non-object config returns 400.
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
```
Expected: fail because config store/handlers do not exist.
- [ ] **Step 2: Add migration**
Create `ebook_reader_config`:
```sql
CREATE TABLE ebook_reader_config (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, profile_id, content_id)
);
CREATE INDEX ebook_reader_config_profile_updated
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
```
- [ ] **Step 3: Implement config store and handlers**
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
Validation:
- authenticated user required
- `content_id` required
- `config` must decode to a JSON object
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
- [ ] **Step 4: Wire routes and store**
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
```go
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
```
- [ ] **Step 5: Add web API wrapper**
Create `web/src/reader/ebookReaderApi.ts` with:
```ts
export function ebookReaderConfigPath(contentID: string): string
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
```
- [ ] **Step 6: Move settings persistence from localStorage to server**
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
- [ ] **Step 7: Verify and commit**
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
cd web && pnpm build
```
Commit:
```bash
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
git commit -m "feat: persist ebook reader config"
```
## Task 2: Server-Persisted Annotations And Bookmarks
**Files:**
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
- Modify: `internal/api/handlers/ebook_reader.go`
- Modify: `internal/api/handlers/ebook_reader_test.go`
- Modify: `internal/api/router.go`
- Modify: `web/src/reader/ebookReaderApi.ts`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- [ ] **Step 1: Write failing annotation handler tests**
Cover list, create, update, delete. Test annotation fields:
```json
{
"kind": "highlight",
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
"selected_text": "sample text",
"note": "note text",
"style": "highlight",
"color": "#facc15"
}
```
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
```
Expected: fail because annotation APIs do not exist.
- [ ] **Step 2: Add annotation table**
Extend migration with:
```sql
CREATE TABLE ebook_reader_annotations (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
cfi_range TEXT,
location TEXT,
selected_text TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
style TEXT NOT NULL DEFAULT 'highlight',
color TEXT NOT NULL DEFAULT '#facc15',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
);
CREATE INDEX ebook_reader_annotations_book
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
```
- [ ] **Step 3: Implement annotation store and handlers**
Add handlers:
- `GET /ebooks/{content_id}/annotations`
- `POST /ebooks/{content_id}/annotations`
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
Extend `FoliateBookReaderHandle` with:
- `createSelectionAnnotation()`
- `clearSelection()`
Draw stored annotations using Foliate `addAnnotation`.
- [ ] **Step 5: Add reader UI**
Add:
- selection popover
- highlight button
- note button
- bookmark button
- annotation list in the side panel
- delete/update note controls
- [ ] **Step 6: Verify and commit**
Run handler tests, web tests, and build. Commit:
```bash
git commit -m "feat: add ebook annotations and bookmarks"
```
## Task 3: Reader Tools, TTS, And Reading Aids
**Files:**
- Create: `web/src/hooks/useTTS.ts`
- Create: `web/src/hooks/useScreenWakeLock.ts`
- Create: `web/src/hooks/useEinkMode.ts`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Port focused hooks from personal reader**
Bring over:
- Web Speech TTS controller
- screen wake lock hook
- e-ink body-class hook
- [ ] **Step 2: Add readable text handle**
Expose `getReadableText()` from `FoliateBookReader` for TTS.
- [ ] **Step 3: Add UI controls**
Add side-panel controls for:
- Speak current text
- Pause/resume/stop
- voice/rate/pitch
- wake lock
- e-ink mode
- [ ] **Step 4: Add content popups and helpers**
Add selection popover actions:
- define
- translate
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
- [ ] **Step 5: Verify and commit**
Run focused web tests and build. Commit:
```bash
git commit -m "feat: add ebook reader tools and aids"
```
## Task 4: Advanced Reader Settings
**Files:**
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Add advanced reader settings**
Add server-persisted settings for:
- RTL
- writing mode
- zoom/scale
- brightness
- hyphenation toggle
- custom font selection/upload if API support is added in a later task
- [ ] **Step 2: Verify and commit**
Run focused tests and build. Commit:
```bash
git commit -m "feat: add ebook advanced reader settings"
```
## Self-Review
- The plan covers server persistence first, matching the user's explicit preference.
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
@@ -0,0 +1,68 @@
# Ebook Reader Shell Parity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
---
### Task 1: Reader Handle Capabilities
**Files:**
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/reader/FoliateBookReader.test.ts`
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
- [ ] Preserve existing progress save/restore behavior.
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
### Task 2: TOC and Search Panel
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
- [ ] Render side panel tabs for Contents, Search, and Settings.
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
### Task 3: Reader Settings
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
- [ ] Render compact controls in the Settings tab.
- [ ] Apply settings live through `FoliateBookReader`.
- [ ] Verify settings persist across remount in tests.
### Task 4: Progress Scrub and Keyboard Navigation
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add a progress range input in the header.
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
- [ ] Verify with page-level tests.
### Task 5: Local Verification
**Files:**
- No source changes.
- [ ] Run `cd web && pnpm run build`.
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
- [ ] Keep changes local; do not push.
+13 -13
View File
@@ -1,6 +1,6 @@
module github.com/Silo-Server/silo-server
go 1.26.3
go 1.26.4
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
@@ -14,8 +14,8 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -32,7 +32,8 @@ require (
github.com/pressly/goose/v3 v3.27.1
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.39.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
@@ -41,13 +42,13 @@ require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
@@ -55,8 +56,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.53.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -68,15 +70,11 @@ require (
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
github.com/zishang520/webtransport-go v0.9.1 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.5.0
github.com/Silo-Server/silo-plugin-sdk v0.6.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
@@ -101,8 +99,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0
golang.org/x/text v0.36.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
+24 -27
View File
@@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
@@ -44,7 +44,6 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -52,12 +51,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -166,10 +165,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -222,8 +225,6 @@ github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+q
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
github.com/zishang520/webtransport-go v0.9.1 h1:Y3gqPM8cIDvQILsTyXJ5G9fp2PYqGqLI2z+QXpgboQc=
github.com/zishang520/webtransport-go v0.9.1/go.mod h1:IgNAD6qLe3oWu7MSSkjusRNftpvjYxWjI4LmoH4VEyY=
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -242,21 +243,19 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -266,17 +265,15 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+33 -1
View File
@@ -1,6 +1,10 @@
package api
import "testing"
import (
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
// connection is rejected when the integration is disabled or has no base_url, so
@@ -22,3 +26,31 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
}
})
}
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
// locks the autoscan→requests credential-reuse behavior after the
// request_integrations table was generalized into the two-tier connection
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
// rows via the same enabled + base_url gate, regardless of the added
// capability/installation columns.
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
}
// Disabled -> error (autoscan must not poll a disabled connection).
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
t.Fatal("disabled integration must be rejected")
}
// Blank base_url -> error.
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
t.Fatal("blank base_url must be rejected")
}
}
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
// fail the build here rather than silently at runtime.
var _ = func(in mediarequests.Integration) (string, string, bool) {
return in.BaseURL, in.APIKeyRef, in.Enabled
}
+108
View File
@@ -0,0 +1,108 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type audiobookGroupResponse struct {
Name string `json:"name"`
ItemCount int `json:"item_count"`
TotalDurationSeconds int64 `json:"total_duration_seconds"`
InProgressCount int `json:"in_progress_count"`
FinishedCount int `json:"finished_count"`
PosterURLs []string `json:"poster_urls"`
}
type audiobookGroupsResponse struct {
Total int `json:"total"`
Groups []audiobookGroupResponse `json:"groups"`
}
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
//
// Grouped browse for audiobook libraries: authors, narrators, or series with
// aggregate stats (book count, total duration, per-profile progress counts)
// and up to four poster URLs for cover stacks. Query parameters:
// library_id=<id> (required), group_by=author|narrator|series (required),
// sort=name|count|duration (default name), limit, offset.
//
// Group names round-trip into the corresponding catalog filter fields
// (author / narrator / series), which match case-insensitively.
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
return
}
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
if err != nil || libraryID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
return
}
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
return
}
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
switch sort {
case "", "name", "count", "duration":
default:
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
return
}
limit := 200
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
if parseErr != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
limit = n
}
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
groups, total, err := catalog.ListAudiobookGroups(
r.Context(),
h.itemsH.browseRepo.Pool(),
catalog.AudiobookGroupsQuery{
LibraryID: libraryID,
GroupBy: groupBy,
Sort: sort,
Limit: limit,
Offset: offset,
},
h.itemsH.accessFilter(r),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
return
}
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
for _, g := range groups {
posterURLs := make([]string, 0, len(g.PosterPaths))
for _, path := range g.PosterPaths {
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
posterURLs = append(posterURLs, url)
}
}
resp.Groups = append(resp.Groups, audiobookGroupResponse{
Name: g.Name,
ItemCount: g.ItemCount,
TotalDurationSeconds: g.TotalDurationSeconds,
InProgressCount: g.InProgressCount,
FinishedCount: g.FinishedCount,
PosterURLs: posterURLs,
})
}
writeJSON(w, http.StatusOK, resp)
}
+2
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/sections"
@@ -89,6 +90,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
h.itemsH.accessFilter(r),
overlaySummaries,
store,
apimw.GetUserID(r.Context()),
profileID,
)
items := make([]itemListResponse, 0, len(result.Items))
+2 -2
View File
@@ -497,8 +497,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "movie", "episode", "audiobook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
case "movie", "episode", "audiobook", "ebook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
+150
View File
@@ -0,0 +1,150 @@
package handlers
import (
"context"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// Ebook read state lives exclusively in ebook_reader_progress: every consumer
// (user-state Played, catalog watched/in_progress filters, Continue Reading,
// sort metrics, recommendations) keys off progress crossing
// models.EbookFinishedProgressThreshold. The native watched endpoints therefore
// write that table instead of user_watch_progress/user_watch_history. The
// helpers live in this package — next to the ebook progress store and types —
// because the handler layer is the only producer today: the webhook-import
// matcher (internal/historyimport) only matches movie/series/episode kinds, so
// imported mark-played events can never resolve to an ebook content ID.
// EbookReadStateStore is the ebook_reader_progress surface needed to set or
// clear an ebook's read state from the watched endpoints.
// *PGEbookReaderProgressStore implements it.
type EbookReadStateStore interface {
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
Upsert(ctx context.Context, progress EbookReaderProgress) error
Delete(ctx context.Context, userID int, profileID string, contentID string) error
}
// EbookReaderProgressReadWriter is the full progress-store surface the items
// handler needs: batch reads for list/user-state responses plus read-state
// writes for the watched endpoints.
type EbookReaderProgressReadWriter interface {
EbookReaderProgressLister
EbookReadStateStore
}
// markEbookRead marks an ebook as finished by upserting its reader-progress
// row with progress = 1.0 (>= models.EbookFinishedProgressThreshold). An
// existing row keeps its file_id and location so the reader still reopens at
// the user's last position even though the book is marked read; a missing row
// references the item's preferred reader file (resolved lazily via
// defaultFileID, since ebook_reader_progress.file_id is NOT NULL) with an
// empty location meaning "start at the beginning". A fresh updated_at also
// resurfaces a previously hidden book, mirroring how re-watching resurfaces
// hidden video history.
func markEbookRead(
ctx context.Context,
store EbookReadStateStore,
userID int,
profileID, contentID string,
now time.Time,
defaultFileID func(ctx context.Context) (int, error),
) error {
existing, err := store.Get(ctx, userID, profileID, contentID)
if err != nil {
return err
}
progress := EbookReaderProgress{
UserID: userID,
ProfileID: profileID,
ContentID: contentID,
Progress: 1.0,
UpdatedAt: now,
}
if existing != nil {
progress.FileID = existing.FileID
progress.Location = existing.Location
} else {
fileID, err := defaultFileID(ctx)
if err != nil {
return err
}
progress.FileID = fileID
}
return store.Upsert(ctx, progress)
}
// markEbookUnread clears an ebook's read state by deleting its reader-progress
// row — the direct analog of the video unwatch path, where
// userstore.ClearProgress DELETEs the user_watch_progress row (dropping the
// resume position along with the watched flag).
func markEbookUnread(
ctx context.Context,
store EbookReadStateStore,
userID int,
profileID, contentID string,
) error {
return store.Delete(ctx, userID, profileID, contentID)
}
// setEbookReadState applies the watched toggle for an ebook target resolved by
// resolveWatchedTargets, which has already enforced the access filter on the
// item. Scoping matches the rest of the ebook progress code: user_id +
// profile_id.
func (h *ItemsHandler) setEbookReadState(
ctx context.Context,
userID int,
profileID, contentID string,
read bool,
filter catalog.AccessFilter,
) error {
if h.ebookReadStateStore == nil {
return fmt.Errorf("ebook reader progress store is not configured")
}
if !read {
return markEbookUnread(ctx, h.ebookReadStateStore, userID, profileID, contentID)
}
return markEbookRead(ctx, h.ebookReadStateStore, userID, profileID, contentID, time.Now().UTC(), func(ctx context.Context) (int, error) {
return h.defaultEbookFileID(ctx, contentID, filter)
})
}
// defaultEbookFileID resolves the file a fresh mark-read progress row should
// reference, respecting the caller's access filter.
func (h *ItemsHandler) defaultEbookFileID(ctx context.Context, contentID string, filter catalog.AccessFilter) (int, error) {
if h.fileRepo == nil {
return 0, catalog.ErrItemNotFound
}
files, err := h.fileRepo.GetByContentID(ctx, contentID)
if err != nil {
return 0, err
}
file := preferredEbookReadFile(catalog.FilterMediaFilesByAccess(files, filter))
if file == nil {
return 0, catalog.ErrItemNotFound
}
return file.ID, nil
}
// preferredEbookReadFile mirrors the web reader's default file choice
// (web/src/pages/ItemDetail/EbookContent.tsx preferredReadVersion): prefer an
// EPUB version, otherwise the first reader-supported ebook file. Callers must
// pass access-filtered files.
func preferredEbookReadFile(files []*models.MediaFile) *models.MediaFile {
var first *models.MediaFile
for _, file := range files {
if !isEbookFile(file) {
continue
}
if ebookReaderFormat(file.FilePath, file.Container) == "epub" {
return file
}
if first == nil {
first = file
}
}
return first
}
@@ -0,0 +1,230 @@
package handlers
import (
"context"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeEbookReadStateStore is an in-memory EbookReaderProgressReadWriter keyed
// by content ID for a single (user, profile) scope.
type fakeEbookReadStateStore struct {
rows map[string]EbookReaderProgress
}
func newFakeEbookReadStateStore() *fakeEbookReadStateStore {
return &fakeEbookReadStateStore{rows: map[string]EbookReaderProgress{}}
}
func (f *fakeEbookReadStateStore) Get(_ context.Context, _ int, _ string, contentID string) (*EbookReaderProgress, error) {
row, ok := f.rows[contentID]
if !ok {
return nil, nil
}
return &row, nil
}
func (f *fakeEbookReadStateStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
f.rows[progress.ContentID] = progress
return nil
}
func (f *fakeEbookReadStateStore) Delete(_ context.Context, _ int, _ string, contentID string) error {
delete(f.rows, contentID)
return nil
}
func (f *fakeEbookReadStateStore) ListByContentIDs(_ context.Context, _ int, _ string, contentIDs []string) (map[string]EbookReaderProgress, error) {
result := make(map[string]EbookReaderProgress, len(contentIDs))
for _, contentID := range contentIDs {
if row, ok := f.rows[contentID]; ok {
result[contentID] = row
}
}
return result, nil
}
// fakeEbookFileProvider implements EpisodeFileProvider for ebook file lookups.
type fakeEbookFileProvider struct {
files map[string][]*models.MediaFile
err error
}
func (f *fakeEbookFileProvider) GetByContentID(_ context.Context, contentID string) ([]*models.MediaFile, error) {
if f.err != nil {
return nil, f.err
}
return f.files[contentID], nil
}
func (f *fakeEbookFileProvider) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
return nil, nil
}
func (f *fakeEbookFileProvider) ListByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
return nil, nil
}
func TestMarkEbookReadPreservesExistingFileAndLocation(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
store.rows["ebook-1"] = EbookReaderProgress{
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-1",
FileID: 7,
Location: "epubcfi(/6/14!/4/2/14)",
Progress: 0.42,
UpdatedAt: time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC),
}
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
t.Fatal("defaultFileID must not be called when a progress row exists")
return 0, nil
})
if err != nil {
t.Fatalf("markEbookRead: %v", err)
}
row := store.rows["ebook-1"]
if row.Progress != 1.0 {
t.Fatalf("progress = %v, want 1.0", row.Progress)
}
if row.FileID != 7 || row.Location != "epubcfi(/6/14!/4/2/14)" {
t.Fatalf("file/location not preserved: %+v", row)
}
if !row.UpdatedAt.Equal(now) {
t.Fatalf("updated_at = %v, want %v", row.UpdatedAt, now)
}
}
func TestMarkEbookReadUsesDefaultFileWhenNoProgressExists(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
return 12, nil
})
if err != nil {
t.Fatalf("markEbookRead: %v", err)
}
row, ok := store.rows["ebook-1"]
if !ok {
t.Fatal("expected a progress row to be created")
}
if row.Progress != 1.0 || row.FileID != 12 || row.Location != "" {
t.Fatalf("row = %+v, want progress 1.0 with file 12 and empty location", row)
}
if row.UserID != 42 || row.ProfileID != "profile-1" {
t.Fatalf("row scope = user %d profile %q, want user 42 profile-1", row.UserID, row.ProfileID)
}
}
func TestMarkEbookReadPropagatesDefaultFileError(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", time.Now().UTC(), func(context.Context) (int, error) {
return 0, catalog.ErrItemNotFound
})
if !errors.Is(err, catalog.ErrItemNotFound) {
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
}
if len(store.rows) != 0 {
t.Fatalf("no row must be written on error, got %+v", store.rows)
}
}
func TestMarkEbookUnreadDeletesProgressRow(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
store.rows["ebook-1"] = EbookReaderProgress{ContentID: "ebook-1", FileID: 7, Progress: 1.0}
if err := markEbookUnread(ctx, store, 42, "profile-1", "ebook-1"); err != nil {
t.Fatalf("markEbookUnread: %v", err)
}
if _, ok := store.rows["ebook-1"]; ok {
t.Fatal("progress row must be deleted on mark unread")
}
}
func TestPreferredEbookReadFilePrefersEpub(t *testing.T) {
pdf := &models.MediaFile{ID: 1, BaseType: "ebook", FilePath: "/books/a.pdf", Container: "pdf"}
epub := &models.MediaFile{ID: 2, BaseType: "ebook", FilePath: "/books/a.epub", Container: "epub"}
video := &models.MediaFile{ID: 3, BaseType: "movie", FilePath: "/movies/a.mkv", Container: "mkv"}
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf, epub}); got == nil || got.ID != 2 {
t.Fatalf("preferredEbookReadFile = %+v, want epub file 2", got)
}
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf}); got == nil || got.ID != 1 {
t.Fatalf("preferredEbookReadFile = %+v, want first reader-supported file 1", got)
}
if got := preferredEbookReadFile([]*models.MediaFile{video}); got != nil {
t.Fatalf("preferredEbookReadFile = %+v, want nil without ebook files", got)
}
}
func TestSetEbookReadStateMarkReadShowsPlayedUserState(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
handler := &ItemsHandler{
ebookProgressStore: store,
ebookReadStateStore: store,
fileRepo: &fakeEbookFileProvider{files: map[string][]*models.MediaFile{
"ebook-1": {{ID: 5, BaseType: "ebook", FilePath: "/books/b.epub", Container: "epub"}},
}},
}
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{}); err != nil {
t.Fatalf("setEbookReadState(read): %v", err)
}
userStore := newProfileTestStore(t)
items := []*models.MediaItem{{ContentID: "ebook-1", Type: "ebook", Title: "Book"}}
states, err := resolveItemUserStatesWithOptions(ctx, userStore, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
EbookProgressStore: store,
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-1"] == nil || !states["ebook-1"].Played {
t.Fatalf("user state = %+v, want played after mark read", states["ebook-1"])
}
// A marked-read book is finished, not in progress: the reader-progress
// response derived from the row must exclude it from Continue Reading.
row := store.rows["ebook-1"]
if row.Progress < models.EbookFinishedProgressThreshold {
t.Fatalf("progress %v must cross the finished threshold", row.Progress)
}
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", false, catalog.AccessFilter{}); err != nil {
t.Fatalf("setEbookReadState(unread): %v", err)
}
if _, ok := store.rows["ebook-1"]; ok {
t.Fatal("mark unread must delete the reader progress row")
}
}
func TestSetEbookReadStateFailsWithoutAccessibleFile(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
handler := &ItemsHandler{
ebookProgressStore: store,
ebookReadStateStore: store,
fileRepo: &fakeEbookFileProvider{},
}
err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{})
if !errors.Is(err, catalog.ErrItemNotFound) {
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
}
}
File diff suppressed because it is too large Load Diff
+901
View File
@@ -0,0 +1,901 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"mime"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func newEbookReaderAuthRequest(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
routeCtx.URLParams.Add("file_id", fileID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func withEbookReaderAnnotationRouteParams(req *http.Request, contentID, annotationID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
routeCtx.URLParams.Add("annotation_id", annotationID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
type fakeEbookProgressStore struct {
progress *EbookReaderProgress
upserted *EbookReaderProgress
err error
}
func (s *fakeEbookProgressStore) Get(
context.Context,
int,
string,
string,
) (*EbookReaderProgress, error) {
return s.progress, s.err
}
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
if s.err != nil {
return s.err
}
s.upserted = &progress
return nil
}
type fakeEbookReaderConfigStore struct {
config *EbookReaderConfig
saved *EbookReaderConfig
getErr error
saveErr error
}
func (s *fakeEbookReaderConfigStore) Get(
context.Context,
int,
string,
string,
) (*EbookReaderConfig, error) {
return s.config, s.getErr
}
func (s *fakeEbookReaderConfigStore) Upsert(_ context.Context, config EbookReaderConfig) error {
if s.saveErr != nil {
return s.saveErr
}
s.saved = &config
return nil
}
type fakeEbookReaderAnnotationStore struct {
items []EbookReaderAnnotation
existing *EbookReaderAnnotation
created *EbookReaderAnnotation
updated *EbookReaderAnnotation
mergedFrom *EbookReaderAnnotation
mergeCalls int
deleted string
err error
}
func (s *fakeEbookReaderAnnotationStore) List(context.Context, int, string, string) ([]EbookReaderAnnotation, error) {
return s.items, s.err
}
func (s *fakeEbookReaderAnnotationStore) Create(_ context.Context, annotation EbookReaderAnnotation) error {
if s.err != nil {
return s.err
}
s.created = &annotation
s.items = append(s.items, annotation)
return nil
}
// Update models the PG store contract: read the stored row, invoke merge with
// it, and persist the merged result, all as one atomic operation.
func (s *fakeEbookReaderAnnotationStore) Update(
_ context.Context,
_ int,
_ string,
_ string,
annotationID string,
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
) (*EbookReaderAnnotation, error) {
if s.err != nil {
return nil, s.err
}
if s.existing == nil || s.existing.ID != annotationID {
return nil, nil
}
existing := *s.existing
s.mergedFrom = &existing
s.mergeCalls++
merged, err := merge(existing)
if err != nil {
return nil, err
}
s.updated = &merged
stored := merged
s.existing = &stored
return &merged, nil
}
func (s *fakeEbookReaderAnnotationStore) Delete(_ context.Context, userID int, profileID, contentID, annotationID string) error {
if s.err != nil {
return s.err
}
s.deleted = annotationID
return nil
}
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.epub")
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: filePath,
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req.Header.Set("Range", "bytes=2-5")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusPartialContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("content-type = %q", got)
}
if got := rr.Header().Get("Content-Disposition"); got != "inline; filename=book.epub" {
t.Fatalf("content-disposition = %q", got)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("x-content-type-options = %q, want nosniff", got)
}
if rr.Body.String() != "2345" {
t.Fatalf("body = %q", rr.Body.String())
}
}
func TestEbookReaderServesContainerMimeForMismatchedExtension(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.txt")
if err := os.WriteFile(filePath, []byte("<html>not really</html>"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: filePath,
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
// The file is admitted via its validated container, so the response must
// carry that container's MIME type, never application/octet-stream.
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("content-type = %q, want application/epub+zip", got)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("x-content-type-options = %q, want nosniff", got)
}
}
func TestEbookMimeTypeNeverReturnsOctetStreamForAdmittedFiles(t *testing.T) {
cases := []struct {
path string
container string
wantMime string
}{
{"/library/book.txt", "epub", "application/epub+zip"},
{"/library/book", "pdf", "application/pdf"},
{"/library/book.epub", "pdf", "application/epub+zip"}, // known extension wins
}
for _, tc := range cases {
file := &models.MediaFile{FilePath: tc.path, Container: tc.container, BaseType: "ebook"}
if !isEbookFile(file) {
t.Fatalf("isEbookFile(%q, %q) = false, want admitted", tc.path, tc.container)
}
if got := ebookMimeType(tc.path, tc.container); got != tc.wantMime {
t.Fatalf("ebookMimeType(%q, %q) = %q, want %q", tc.path, tc.container, got, tc.wantMime)
}
}
}
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Container: "mkv",
BaseType: "movie",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
req = withEbookReaderRouteParams(req, "movie-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
cases := map[string]string{
"book.epub": "application/epub+zip",
"book.pdf": "application/pdf",
"book.mobi": "application/x-mobipocket-ebook",
"book.azw": "application/vnd.amazon.ebook",
"book.azw3": "application/vnd.amazon.mobi8-ebook",
"book.cbz": "application/vnd.comicbook+zip",
"book.cbr": "application/vnd.comicbook-rar",
"book.fb2": "application/x-fictionbook+xml",
"book.fbz": "application/x-zip-compressed-fb2",
"book.fb2.zip": "application/x-zip-compressed-fb2",
"book.md": "application/octet-stream",
"book.unknown": "application/octet-stream",
}
rejected := map[string]bool{"book.md": true, "book.unknown": true}
for name, wantMime := range cases {
t.Run(name, func(t *testing.T) {
container := name
if name == "book.fb2.zip" {
container = "fbz"
}
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
if rejected[name] {
file.BaseType = "ebook"
if isEbookFile(file) {
t.Fatalf("%s should not be treated as an ebook reader format", name)
}
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
}
return
}
file.BaseType = "ebook"
if !isEbookFile(file) {
t.Fatal("expected ebook reader format")
}
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
}
})
}
}
func TestEbookReaderRejectsPlainText(t *testing.T) {
file := &models.MediaFile{
FilePath: "/library/book.txt",
BaseType: "ebook",
}
if isEbookFile(file) {
t.Fatal("plain text should not be treated as an ebook reader format")
}
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
t.Fatal("plain text should not have an ebook reader MIME type")
}
}
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
file := &models.MediaFile{
FilePath: "/library/book.fb2.zip",
BaseType: "ebook",
}
if !isEbookFile(file) {
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
}
}
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: "/tmp/book.epub",
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
store := &fakeEbookProgressStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: "/tmp/book.epub",
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = store
req := httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/progress",
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveProgress(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.upserted == nil {
t.Fatal("expected progress to be saved")
}
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
}
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
}
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
t.Fatalf("progress = %+v", store.upserted)
}
}
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = &fakeEbookProgressStore{
progress: &EbookReaderProgress{
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
FileID: 42,
Location: "epubcfi(/6/8)",
Progress: 0.75,
UpdatedAt: updatedAt,
},
}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetProgress(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
`"content_id":"ebook-1"`,
`"file_id":42`,
`"location":"epubcfi(/6/8)"`,
`"progress":0.75`,
`"updated_at":"2026-06-08T12:00:00Z"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response %s missing %s", body, want)
}
}
}
func TestEbookReaderReturnsEmptyConfigWhenNoneSaved(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ConfigStore = &fakeEbookReaderConfigStore{}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetConfig(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if got := strings.TrimSpace(rr.Body.String()); got != `{"config":{}}` {
t.Fatalf("body = %s", got)
}
}
func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
store := &fakeEbookReaderConfigStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ConfigStore = store
req := httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/reader-config",
strings.NewReader(`{"config":{"theme":"dark","flow":"scrolled","fontSize":120}}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveConfig(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.saved == nil {
t.Fatal("expected config to be saved")
}
if store.saved.UserID != 1 || store.saved.ProfileID != "profile-9" || store.saved.ContentID != "ebook-1" {
t.Fatalf("scope = %+v", store.saved)
}
var config map[string]any
if err := json.Unmarshal(store.saved.Config, &config); err != nil {
t.Fatalf("config json: %v", err)
}
if config["theme"] != "dark" || config["flow"] != "scrolled" || config["fontSize"].(float64) != 120 {
t.Fatalf("config = %#v", config)
}
}
func TestEbookReaderRejectsNonObjectConfig(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ConfigStore = &fakeEbookReaderConfigStore{}
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/reader-config")
req.Body = http.NoBody
req = httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/reader-config",
strings.NewReader(`{"config":["bad"]}`),
).WithContext(req.Context())
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveConfig(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderConfigRequiresAccessibleContent(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
})
handler.ConfigStore = &fakeEbookReaderConfigStore{}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetConfig(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = store
req := httptest.NewRequest(
http.MethodPost,
"/ebooks/ebook-1/annotations",
strings.NewReader(`{"kind":"highlight","cfi_range":"epubcfi(/6/4,/1:0,/1:12)","selected_text":"sample text","note":"note text","style":"highlight","color":"#facc15"}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleCreateAnnotation(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.created == nil {
t.Fatal("expected annotation to be created")
}
if store.created.UserID != 1 || store.created.ProfileID != "profile-9" || store.created.ContentID != "ebook-1" {
t.Fatalf("scope = %+v", store.created)
}
if store.created.Kind != "highlight" || store.created.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
t.Fatalf("annotation = %+v", store.created)
}
if store.created.SelectedText != "sample text" || store.created.Note != "note text" {
t.Fatalf("annotation text = %+v", store.created)
}
if store.created.ID == "" {
t.Fatal("expected generated annotation id")
}
}
func TestEbookReaderReturnsAnnotations(t *testing.T) {
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = &fakeEbookReaderAnnotationStore{
items: []EbookReaderAnnotation{
{
ID: "ann-1",
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
Kind: "bookmark",
Location: "epubcfi(/6/8)",
SelectedText: "",
Note: "Saved spot",
Style: "highlight",
Color: "#facc15",
Metadata: json.RawMessage(`{}`),
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
},
},
}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/annotations")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleListAnnotations(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{`"items":[`, `"id":"ann-1"`, `"kind":"bookmark"`, `"location":"epubcfi(/6/8)"`} {
if !strings.Contains(body, want) {
t.Fatalf("response %s missing %s", body, want)
}
}
}
func existingEbookReaderAnnotation() *EbookReaderAnnotation {
createdAt := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
return &EbookReaderAnnotation{
ID: "ann-1",
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
Kind: "highlight",
CFIRange: "epubcfi(/6/4,/1:0,/1:12)",
SelectedText: "sample text",
Note: "original note",
Style: "highlight",
Color: "#facc15",
Metadata: json.RawMessage(`{"chapter":"one"}`),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func patchEbookReaderAnnotation(t *testing.T, store *fakeEbookReaderAnnotationStore, body string) *httptest.ResponseRecorder {
t.Helper()
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = store
req := newEbookReaderAuthRequest(http.MethodPatch, "/ebooks/ebook-1/annotations/ann-1")
req.Body = ioNopCloser{strings.NewReader(body)}
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
rr := httptest.NewRecorder()
handler.HandleUpdateAnnotation(rr, req)
return rr
}
func TestEbookReaderPatchKeepsAbsentFields(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated","color":"#bfdbfe"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated == nil || store.updated.ID != "ann-1" || store.updated.Note != "updated" || store.updated.Color != "#bfdbfe" {
t.Fatalf("updated = %+v", store.updated)
}
if string(store.updated.Metadata) != `{"chapter":"one"}` {
t.Fatalf("metadata should be preserved when absent, got %s", store.updated.Metadata)
}
if store.updated.Kind != "highlight" || store.updated.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
t.Fatalf("absent fields should keep stored values, got %+v", store.updated)
}
}
func TestEbookReaderPatchClearsExplicitlyEmptyFields(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"note":"","metadata":{}}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated == nil || store.updated.Note != "" {
t.Fatalf("note should be cleared, got %+v", store.updated)
}
if string(store.updated.Metadata) != `{}` {
t.Fatalf("metadata should be cleared to {}, got %s", store.updated.Metadata)
}
}
func TestEbookReaderPatchValidatesResultingState(t *testing.T) {
cases := map[string]string{
"kind change without location": `{"kind":"bookmark"}`,
"clearing required cfi_range": `{"cfi_range":""}`,
"unknown kind": `{"kind":"doodle"}`,
"non-object metadata": `{"metadata":["bad"]}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated != nil {
t.Fatalf("invalid patch must not reach the store, got %+v", store.updated)
}
})
}
}
func TestEbookReaderPatchAllowsKindChangeWithRequiredFields(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"kind":"bookmark","location":"epubcfi(/6/8)"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated == nil || store.updated.Kind != "bookmark" || store.updated.Location != "epubcfi(/6/8)" {
t.Fatalf("updated = %+v", store.updated)
}
}
func TestEbookReaderPatchMergesOntoRowReadInsideStoreUpdate(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"note":"merged note"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
// The handler must not pre-read and merge outside the store: the merge
// runs exactly once, on the row handed over by the (locked) store read.
if store.mergeCalls != 1 {
t.Fatalf("merge calls = %d, want 1", store.mergeCalls)
}
if store.mergedFrom == nil || store.mergedFrom.Note != "original note" {
t.Fatalf("merge input = %+v, want the stored row", store.mergedFrom)
}
if store.updated == nil || store.updated.Note != "merged note" {
t.Fatalf("written row = %+v, want merged note", store.updated)
}
if store.updated.CFIRange != store.mergedFrom.CFIRange || store.updated.Kind != store.mergedFrom.Kind {
t.Fatalf("written row %+v must keep unpatched fields from merge input %+v", store.updated, store.mergedFrom)
}
if !store.updated.UpdatedAt.After(store.mergedFrom.UpdatedAt) {
t.Fatalf("updated_at %v must advance past %v", store.updated.UpdatedAt, store.mergedFrom.UpdatedAt)
}
}
func TestEbookReaderPatchReturnsNotFoundForMissingAnnotation(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{}
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated"}`)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderRejectsOversizedProgressBody(t *testing.T) {
store := &fakeEbookProgressStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = store
body := `{"file_id":42,"location":"` + strings.Repeat("x", int(ebookReaderProgressMaxBodySize)) + `","progress":0.5}`
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/progress")
req.Body = ioNopCloser{strings.NewReader(body)}
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveProgress(rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.upserted != nil {
t.Fatalf("oversized body must not be saved, got %+v", store.upserted)
}
}
func TestInlineContentDispositionProducesParseableHeaders(t *testing.T) {
cases := map[string]string{
"book.epub": "book.epub",
`bo"ok\.epub`: `bo"ok\.epub`,
`trailing-slash\`: `trailing-slash\`,
"börk \U0001F4DA.epub": "börk \U0001F4DA.epub",
"": "ebook",
".": "ebook",
}
for name, want := range cases {
t.Run(name, func(t *testing.T) {
got := inlineContentDisposition(name)
mediaType, params, err := mime.ParseMediaType(got)
if err != nil {
t.Fatalf("header %q is malformed: %v", got, err)
}
if mediaType != "inline" {
t.Fatalf("media type = %q", mediaType)
}
if params["filename"] != want {
t.Fatalf("filename = %q, want %q", params["filename"], want)
}
})
}
}
func TestEbookReaderDeletesAnnotation(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = store
req := newEbookReaderAuthRequest(http.MethodDelete, "/ebooks/ebook-1/annotations/ann-1")
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
rr := httptest.NewRecorder()
handler.HandleDeleteAnnotation(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.deleted != "ann-1" {
t.Fatalf("deleted = %q", store.deleted)
}
}
type ioNopCloser struct {
*strings.Reader
}
func (c ioNopCloser) Close() error { return nil }
+14 -2
View File
@@ -40,6 +40,7 @@ type PersonalDataHandler struct {
localFavoriteDispatcher LocalFavoriteEventDispatcher
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
ebookProgressStore EbookReaderProgressLister
}
// NewPersonalDataHandler creates a new PersonalDataHandler.
@@ -55,6 +56,10 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
h.detailSvc = svc
}
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
h.ebookProgressStore = store
}
// SetProfileStaler configures an optional staleness trigger for taste profiles.
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
h.profileStaler = ps
@@ -552,7 +557,10 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
userStates := map[string]*itemUserStateResponse{}
if store, profileID, ok := h.userStoreForRequest(r); ok {
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
UserID: apimw.GetUserID(r.Context()),
EbookProgressStore: h.ebookProgressStore,
}); err == nil {
userStates = resolvedStates
}
}
@@ -669,7 +677,11 @@ func (h *PersonalDataHandler) resolveHistoryRemovalMediaItemIDs(
return nil, err
}
switch item.Type {
case "movie":
case "movie", "ebook":
// Hiding an ebook gates ebook_reader_progress reads via
// user_history_hidden_items (updated_at <= hidden_before)
// without deleting the progress row, so the reader position
// survives: hidden is not the same as unread.
return []string{item.ContentID}, nil
case "series":
return h.seriesEpisodeIDs(ctx, item.ContentID)
@@ -0,0 +1,113 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type fakeHistoryItemRepo struct {
items map[string]*models.MediaItem
inaccessible map[string]bool
}
func (r *fakeHistoryItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
item, ok := r.items[contentID]
if !ok {
return nil, catalog.ErrItemNotFound
}
return item, nil
}
func (r *fakeHistoryItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
result := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
if item, ok := r.items[contentID]; ok {
result = append(result, item)
}
}
return result, nil
}
func (r *fakeHistoryItemRepo) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
if r.inaccessible[contentID] {
return catalog.ErrItemNotFound
}
return nil
}
func TestHandleRemoveHistoryAcceptsEbookTargets(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
items: map[string]*models.MediaItem{
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
},
})
// Seed a history entry keyed by the ebook content ID so the removal has
// something to hide. RemoveHistoryItems only touches watch history,
// watch progress, and the hidden-items gate — PersonalDataHandler has no
// write access to ebook_reader_progress (its store interface is read-only),
// so the reader position survives: hidden is not the same as unread.
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: "ebook-1",
WatchedAt: "2026-06-01T10:00:00Z",
Completed: true,
Source: userstore.WatchHistorySourceManual,
}); err != nil {
t.Fatalf("AddHistory: %v", err)
}
req := newAuthorizedProfileRequestWithRole(
http.MethodPost,
"/history/remove",
`{"targets":[{"content_id":"ebook-1"}]}`,
"user",
"profile-1",
)
rr := httptest.NewRecorder()
handler.HandleRemoveHistory(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
entries, err := store.ListHistory(ctx, "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(entries) != 0 {
t.Fatalf("history entries = %+v, want hidden after removal", entries)
}
}
func TestHandleRemoveHistoryRejectsInaccessibleEbook(t *testing.T) {
store := newProfileTestStore(t)
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
items: map[string]*models.MediaItem{
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
},
inaccessible: map[string]bool{"ebook-1": true},
})
req := newAuthorizedProfileRequestWithRole(
http.MethodPost,
"/history/remove",
`{"targets":[{"content_id":"ebook-1"}]}`,
"user",
"profile-1",
)
rr := httptest.NewRecorder()
handler.HandleRemoveHistory(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
+204 -18
View File
@@ -46,6 +46,10 @@ type LocalWatchEventDispatcher interface {
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
}
type EbookReaderProgressLister interface {
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
}
// ItemsHandler handles browse, search, item detail, and series endpoints.
type ItemsHandler struct {
browseRepo *catalog.BrowseRepository
@@ -62,6 +66,8 @@ type ItemsHandler struct {
profileRefreshRequester ProfileRefreshRequester
metadataRefreshRequester MetadataRefreshRequester
localWatchDispatcher LocalWatchEventDispatcher
ebookProgressStore EbookReaderProgressLister
ebookReadStateStore EbookReadStateStore
EventsHub *evt.Hub
UserRepo *auth.UserRepository
}
@@ -114,6 +120,11 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
h.localWatchDispatcher = dispatcher
}
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
h.ebookProgressStore = store
h.ebookReadStateStore = store
}
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
return
@@ -225,6 +236,9 @@ type sortMetricsResponse struct {
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
ViewedAt string `json:"viewed_at,omitempty"`
PlayCount *int `json:"play_count,omitempty"`
Author string `json:"author,omitempty"`
Narrator string `json:"narrator,omitempty"`
SeriesName string `json:"series_name,omitempty"`
}
// browseResponse is the paginated response for the /items endpoint.
@@ -373,8 +387,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
}
}
if detail.Type == "movie" || detail.Type == "episode" {
detail.UserData = h.getLeafUserData(r, detail.ContentID)
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
}
@@ -429,7 +443,8 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
return
}
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r))
filter := h.accessFilter(r)
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
if err != nil {
switch {
case isNotFound(err):
@@ -440,12 +455,16 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
return
}
if h.watchState == nil {
switch {
case targetType == "ebook":
// Ebook read state lives in ebook_reader_progress, not in
// user_watch_progress/user_watch_history; watch providers do not sync
// books, so no local watch event is dispatched.
err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter)
case h.watchState == nil:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if played {
case played:
leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets))
for _, target := range targets {
leafTargets = append(leafTargets, watchstate.LeafWatchTarget{
@@ -459,7 +478,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
if err == nil {
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result)
}
} else {
default:
targetIDs := make([]string, 0, len(targets))
for _, target := range targets {
targetIDs = append(targetIDs, target.ContentID)
@@ -471,6 +490,10 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
}
}
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
return
}
@@ -739,7 +762,10 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
if !ok {
return map[string]*itemUserStateResponse{}
}
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
UserID: apimw.GetUserID(r.Context()),
EbookProgressStore: h.ebookProgressStore,
})
if err != nil {
return map[string]*itemUserStateResponse{}
}
@@ -850,6 +876,7 @@ func (h *ItemsHandler) listSortMetrics(
filter catalog.AccessFilter,
overlaySummaries map[string]*models.OverlaySummary,
store userstore.UserStore,
userID int,
profileID string,
) map[string]*sortMetricsResponse {
metrics := make(map[string]*sortMetricsResponse, len(items))
@@ -890,16 +917,97 @@ func (h *ItemsHandler) listSortMetrics(
}
}
case "progress", "date_viewed", "plays":
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
case "author", "narrator", "series":
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
}
return metrics
}
func (h *ItemsHandler) listAudiobookSortMetrics(
ctx context.Context,
items []*models.MediaItem,
sortField string,
metrics map[string]*sortMetricsResponse,
) {
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
return
}
contentIDs := uniqueItemContentIDs(items)
if len(contentIDs) == 0 {
return
}
var query string
switch sortField {
case "author":
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
case "narrator":
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
case "series":
query = `
SELECT target.content_id, BTRIM(s.series_name) AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN audiobook_series s ON s.content_id = target.content_id
WHERE BTRIM(s.series_name) <> ''`
default:
return
}
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var contentID, value string
if err := rows.Scan(&contentID, &value); err != nil {
return
}
value = strings.TrimSpace(value)
if contentID == "" || value == "" {
continue
}
resp := metrics[contentID]
if resp == nil {
resp = &sortMetricsResponse{}
metrics[contentID] = resp
}
switch sortField {
case "author":
resp.Author = value
case "narrator":
resp.Narrator = value
case "series":
resp.SeriesName = value
}
}
}
func audiobookPersonSortMetricQuery(kind int) string {
return fmt.Sprintf(`
SELECT target.content_id, person.name AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN LATERAL (
SELECT BTRIM(p.name) AS name
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = target.content_id
AND ip.kind = %d
AND p.name IS NOT NULL
AND BTRIM(p.name) <> ''
ORDER BY p.name ASC
LIMIT 1
) person ON TRUE`, kind)
}
func (h *ItemsHandler) listUserSortMetrics(
ctx context.Context,
items []*models.MediaItem,
sortField string,
store userstore.UserStore,
userID int,
profileID string,
metrics map[string]*sortMetricsResponse,
) {
@@ -915,6 +1023,10 @@ func (h *ItemsHandler) listUserSortMetrics(
if err != nil {
return
}
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
if err != nil {
return
}
switch sortField {
case "progress":
@@ -923,10 +1035,16 @@ func (h *ItemsHandler) listUserSortMetrics(
continue
}
progress, ok := progressMap[item.ContentID]
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
ratio := progress.PositionSeconds / progress.DurationSeconds
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
continue
}
ratio := progress.PositionSeconds / progress.DurationSeconds
ebookProgress, ok := ebookProgressMap[item.ContentID]
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold {
continue
}
ratio := ebookProgress.Progress
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
}
case "date_viewed", "plays":
@@ -955,6 +1073,12 @@ func (h *ItemsHandler) listUserSortMetrics(
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
viewedAt = progress.UpdatedAt
}
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
if ebookViewedAt > viewedAt {
viewedAt = ebookViewedAt
}
}
if viewedAt == "" {
continue
}
@@ -964,6 +1088,9 @@ func (h *ItemsHandler) listUserSortMetrics(
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
playCount = 1
}
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
playCount = 1
}
if playCount <= 0 {
continue
}
@@ -974,6 +1101,18 @@ func (h *ItemsHandler) listUserSortMetrics(
}
}
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
ctx context.Context,
userID int,
profileID string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
}
func uniqueItemContentIDs(items []*models.MediaItem) []string {
contentIDs := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
@@ -1110,7 +1249,11 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
return resp
}
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
if len(itemType) > 0 && itemType[0] == "ebook" {
return h.getEbookLeafUserData(r, contentID)
}
store, profileID, ok := h.userStoreForRequest(r)
if !ok {
return nil
@@ -1124,7 +1267,7 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
return &catalog.SeasonUserData{
PositionSeconds: progress.PositionSeconds,
DurationSeconds: progress.DurationSeconds,
IsInProgress: !progress.Completed && progress.PositionSeconds > 0,
IsInProgress: progress.PositionSeconds > 0,
Played: progress.Completed,
LastFileID: progress.LastFileID,
LastResolution: progress.LastResolution,
@@ -1134,6 +1277,38 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
}
}
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
if h == nil || h.ebookProgressStore == nil {
return nil
}
userID := apimw.GetUserID(r.Context())
profileID := requestProfileID(r)
if userID <= 0 || profileID == "" || contentID == "" {
return nil
}
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
if err != nil {
return nil
}
row, ok := progress[contentID]
if !ok || row.Progress <= 0 {
return nil
}
// Ebooks have no playback duration, so PositionSeconds/DurationSeconds
// encode the 0..1 reading ratio (position=ratio, duration=1). Clients can
// derive percent-complete from the pair as usual, but rendering them as
// absolute times is meaningless for ebooks. Changing the wire shape needs
// coordinated Android/Apple client updates.
return &catalog.SeasonUserData{
PositionSeconds: row.Progress,
DurationSeconds: 1,
IsInProgress: row.Progress < models.EbookFinishedProgressThreshold,
Played: row.Progress >= models.EbookFinishedProgressThreshold,
}
}
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
return
@@ -1178,6 +1353,16 @@ func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.
}
}
// requestProfileID resolves the active profile for a request, falling back to
// the X-Profile-Id header when the auth context does not carry one.
func requestProfileID(r *http.Request) string {
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
return profileID
}
func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) {
if h.storeProvider == nil {
return nil, "", false
@@ -1188,10 +1373,7 @@ func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore
return nil, "", false
}
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
profileID := requestProfileID(r)
if profileID == "" {
return nil, "", false
}
@@ -1385,6 +1567,10 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
ContentID: item.ContentID,
DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime),
}}, nil
case "ebook":
// Ebooks have no playback duration; read state is keyed off
// ebook_reader_progress, so the leaf target only carries the ID.
return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil
case "series":
if h.episodeRepo == nil {
return "", nil, catalog.ErrItemNotFound
@@ -40,20 +40,145 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
}
}
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
progress := progressMetrics["movie-progress"]
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
t.Fatalf("progress metrics = %#v", progress)
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
t.Fatalf("viewed metrics = %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
plays := playsMetrics["movie-plays"]
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
t.Fatalf("plays metrics = %#v", plays)
}
}
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.37,
UpdatedAt: updatedAt,
},
"ebook-complete": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.95,
UpdatedAt: updatedAt.Add(time.Hour),
},
},
},
}
items := []*models.MediaItem{
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
}
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
progress := progressMetrics["ebook-progress"]
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
t.Fatalf("progress metrics = %#v", progress)
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
t.Fatalf("viewed metrics = %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
plays := playsMetrics["ebook-complete"]
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
t.Fatalf("plays metrics = %#v", plays)
}
}
func TestListSortMetricsExcludesHiddenEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-hidden": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-hidden",
Progress: 0.95,
UpdatedAt: updatedAt,
},
"ebook-reread": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-reread",
Progress: 0.95,
UpdatedAt: updatedAt.Add(2 * time.Hour),
},
},
hiddenBefore: map[string]time.Time{
"ebook-hidden": updatedAt.Add(time.Hour),
"ebook-reread": updatedAt.Add(time.Hour),
},
},
}
items := []*models.MediaItem{
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden"},
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread"},
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := viewedMetrics["ebook-hidden"]; got != nil {
t.Fatalf("hidden ebook must have no viewed metrics, got %#v", got)
}
if got := viewedMetrics["ebook-reread"]; got == nil || got.ViewedAt != "2026-06-03T11:15:00Z" {
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := playsMetrics["ebook-hidden"]; got != nil {
t.Fatalf("hidden ebook must have no play count, got %#v", got)
}
if got := playsMetrics["ebook-reread"]; got == nil || got.PlayCount == nil || *got.PlayCount != 1 {
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
}
}
type fakeEbookReaderProgressLister struct {
progress map[string]EbookReaderProgress
// hiddenBefore models user_history_hidden_items: like the PG store, a
// row is excluded while progress.UpdatedAt <= hidden_before and counts
// again once the progress row is updated after the hide.
hiddenBefore map[string]time.Time
}
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
_ context.Context,
_ int,
_ string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
result := make(map[string]EbookReaderProgress, len(contentIDs))
for _, contentID := range contentIDs {
progress, ok := f.progress[contentID]
if !ok {
continue
}
if hiddenBefore, hidden := f.hiddenBefore[contentID]; hidden && !progress.UpdatedAt.After(hiddenBefore) {
continue
}
result[contentID] = progress
}
return result, nil
}
@@ -0,0 +1,47 @@
package handlers
import (
"net/http/httptest"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.42,
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
},
"ebook-complete": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.93,
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
},
},
},
}
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
t.Fatalf("partial ebook user data = %#v", progress)
}
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
if complete == nil || !complete.Played || complete.IsInProgress {
t.Fatalf("completed ebook user data = %#v", complete)
}
}
+25 -13
View File
@@ -574,6 +574,11 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
}
if sections.IsAudiobookLibraryType(folder.Type) {
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
}
}
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
}
@@ -2029,18 +2034,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
return nil
}
// Determine which content levels apply.
var levels []string
switch libraryType {
case "series":
levels = []string{"series", "season", "episode"}
case "movies", "movie":
levels = []string{"movie"}
case "audiobooks", "audiobook":
levels = []string{"audiobook"}
case "mixed":
levels = []string{"movie", "series", "season", "episode", "audiobook"}
default:
levels := metadataContentLevelsForLibraryType(libraryType)
if len(levels) == 0 {
return nil
}
@@ -2055,7 +2050,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
var candidates []candidate
for _, c := range caps {
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
if defaultPriority > 0 {
candidates = append(candidates, candidate{
installationID: c.PluginInstallationID,
@@ -2093,6 +2088,23 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
return entries
}
func metadataContentLevelsForLibraryType(libraryType string) []string {
switch libraryType {
case "series":
return []string{"series", "season", "episode"}
case "movies", "movie":
return []string{"movie"}
case "audiobooks", "audiobook":
return []string{"audiobook"}
case "ebooks", "ebook":
return []string{"ebook"}
case "mixed":
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
default:
return nil
}
}
// HandleListStaleIDs handles GET /libraries/stale-ids.
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
if h.StaleIDRepo == nil {
@@ -0,0 +1,26 @@
package handlers
import (
"reflect"
"testing"
)
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
cases := []struct {
name string
libraryType string
want []string
}{
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
}
})
}
}
+16 -3
View File
@@ -49,6 +49,8 @@ type SessionManagerInterface interface {
SetEffectiveMediaFileID(sessionID string, fileID int) error
SetTranscodeNodeURL(sessionID, url string) error
SetWebSocket(sessionID string, connected bool) error
SetRealtimeConnection(sessionID string, connected bool) error
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
StopSession(sessionID string) error
GetSession(sessionID string) (*playback.Session, error)
}
@@ -257,6 +259,7 @@ type startPlaybackRequest struct {
StartPosition *float64 `json:"start_position,omitempty"`
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
CodecsVideo []string `json:"codecs_video"`
CodecsAudio []string `json:"codecs_audio"`
Containers []string `json:"containers"`
@@ -761,6 +764,9 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
if h.StoreProvider == nil || h.fileResolver == nil {
return
}
if session == nil || session.DisableProgressPersistence {
return
}
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
targetID := playbackProgressTarget(file)
@@ -799,7 +805,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
if h.StoreProvider == nil || h.fileResolver == nil {
return watchstate.PlaybackStopResult{}
}
if session == nil || session.Position <= 0 {
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
return watchstate.PlaybackStopResult{}
}
@@ -1281,6 +1287,13 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
return
}
setPlaybackSessionLogContext(r, session.ID)
if req.DisableProgressPersistence {
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
} else {
session.DisableProgressPersistence = true
}
}
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
slog.Error("failed to set audio track", "session", session.ID, "error", err)
@@ -1320,7 +1333,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
} else {
h.restoreSessionProgress(r.Context(), session, file)
}
if h.WatchScrobbler != nil && effectiveFile != nil {
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
targetID := playbackProgressTarget(effectiveFile)
if targetID != "" {
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
@@ -1549,7 +1562,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
// Persist progress to UserStore (best-effort).
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
h.persistProgress(r.Context(), sess)
if h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
targetID := playbackProgressTarget(file)
if targetID != "" {
+62
View File
@@ -129,6 +129,10 @@ func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return
func (failingSessionManager) SetWebSocket(string, bool) error { return nil }
func (failingSessionManager) SetRealtimeConnection(string, bool) error { return nil }
func (failingSessionManager) SetProgressPersistenceDisabled(string, bool) error { return nil }
func (failingSessionManager) StopSession(string) error { return nil }
func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil }
@@ -283,6 +287,64 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(
}
}
func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil {
t.Fatalf("seed progress: %v", err)
}
file := &models.MediaFile{
ID: 42,
ContentID: "book-1",
FilePath: writePlaybackTestMediaFile(t, "book.m4b"),
Duration: 600,
}
sessionMgr := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != 201 {
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp playbackSessionResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`))
progressReq = progressReq.WithContext(newAuthorizedPlaybackContext())
progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID)
progressRR := httptest.NewRecorder()
handler.HandleUpdateProgress(progressRR, progressReq)
if progressRR.Code != 204 {
t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String())
}
stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil)
stopReq = stopReq.WithContext(newAuthorizedPlaybackContext())
stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID)
stopRR := httptest.NewRecorder()
handler.HandleStopPlayback(stopRR, stopReq)
if stopRR.Code != 204 {
t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String())
}
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
if err != nil {
t.Fatalf("get progress: %v", err)
}
if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 {
t.Fatalf("progress after disabled session = %+v, want 500/1200", progress)
}
}
func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
file := &models.MediaFile{Duration: 3600}
+107 -25
View File
@@ -169,22 +169,27 @@ type pluginConfigSchemaJSON struct {
}
type pluginAdminFormJSON struct {
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
}
type pluginAdminFormFieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
}
type pluginAdminFormOptionJSON struct {
@@ -193,6 +198,31 @@ type pluginAdminFormOptionJSON struct {
Description string `json:"description,omitempty"`
}
type pluginAdminFormConditionJSON struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type pluginAdminFormValidationJSON struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int32 `json:"min_length,omitempty"`
MaxLength int32 `json:"max_length,omitempty"`
}
type pluginAdminFormSectionJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
}
type pluginCapabilityJSON struct {
Type string `json:"type"`
ID string `json:"id"`
@@ -660,7 +690,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
continue
}
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, level)
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
}); err != nil {
slog.Warn("failed to append provider to library chains",
"installation_id", installation.ID,
@@ -1342,26 +1372,78 @@ func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
if field.GetDefaultValue() != nil {
defaultValue = field.GetDefaultValue().AsInterface()
}
var validation *pluginAdminFormValidationJSON
if v := field.GetValidation(); v != nil {
validation = &pluginAdminFormValidationJSON{
HasMin: v.GetHasMin(),
Min: v.GetMin(),
HasMax: v.GetHasMax(),
Max: v.GetMax(),
Pattern: v.GetPattern(),
MinLength: v.GetMinLength(),
MaxLength: v.GetMaxLength(),
}
}
fields = append(fields, pluginAdminFormFieldJSON{
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
DynamicOptions: field.GetDynamicOptions(),
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
Validation: validation,
ExclusiveGroupField: field.GetExclusiveGroupField(),
})
}
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
for _, section := range form.GetSections() {
if section == nil {
continue
}
sections = append(sections, pluginAdminFormSectionJSON{
Key: section.GetKey(),
Title: section.GetTitle(),
Description: section.GetDescription(),
Collapsible: section.GetCollapsible(),
CollapsedDefault: section.GetCollapsedDefault(),
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
})
}
return &pluginAdminFormJSON{
Fields: fields,
SubmitLabel: form.GetSubmitLabel(),
Sections: sections,
}
}
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
if len(conditions) == 0 {
return nil
}
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
for _, condition := range conditions {
if condition == nil {
continue
}
out = append(out, pluginAdminFormConditionJSON{
Field: condition.GetField(),
Equals: append([]string(nil), condition.GetEquals()...),
})
}
if len(out) == 0 {
return nil
}
return out
}
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
response := make([]pluginCapabilityJSON, 0, len(descriptors))
for _, descriptor := range descriptors {
@@ -0,0 +1,216 @@
package handlers
import (
"encoding/json"
"reflect"
"strings"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
// this, the schema-driven request-connection form silently loses its
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
// validation, sections, and the MULTI_SELECT control mapping, which makes the
// new form non-functional (required fields can never be set, save stays
// disabled).
func TestAdminFormToJSONSchemaFields(t *testing.T) {
descriptor := &pluginv1.AdminFormDescriptor{
SubmitLabel: "Connect",
Fields: []*pluginv1.AdminFormField{
{
Key: "quality_profile_ids",
Label: "Quality Profiles",
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
DynamicOptions: true,
ExclusiveGroupField: "service_kind",
ShowWhen: []*pluginv1.AdminFormCondition{
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
},
Validation: &pluginv1.AdminFormValidation{
HasMin: true,
Min: 1,
HasMax: true,
Max: 10,
Pattern: "^[0-9]+$",
MinLength: 1,
MaxLength: 64,
},
},
},
Sections: []*pluginv1.AdminFormSection{
{
Key: "advanced",
Title: "Advanced",
Description: "Optional tuning",
Collapsible: true,
CollapsedDefault: true,
FieldKeys: []string{"quality_profile_ids"},
ShowWhen: []*pluginv1.AdminFormCondition{
{Field: "service_type", Equals: []string{"radarr"}},
},
},
},
}
form := adminFormToJSON(descriptor)
if form == nil {
t.Fatal("adminFormToJSON returned nil")
}
raw, err := json.Marshal(form)
if err != nil {
t.Fatalf("marshal admin form: %v", err)
}
var decoded struct {
Fields []struct {
Control string `json:"control"`
DynamicOptions bool `json:"dynamic_options"`
ShowWhen []struct {
Field string `json:"field"`
Equals []string `json:"equals"`
} `json:"show_when"`
Validation *struct {
HasMin bool `json:"has_min"`
Min float64 `json:"min"`
HasMax bool `json:"has_max"`
Max float64 `json:"max"`
Pattern string `json:"pattern"`
MinLength int32 `json:"min_length"`
MaxLength int32 `json:"max_length"`
} `json:"validation"`
ExclusiveGroupField string `json:"exclusive_group_field"`
} `json:"fields"`
Sections []struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []struct {
Field string `json:"field"`
Equals []string `json:"equals"`
} `json:"show_when"`
} `json:"sections"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
}
if len(decoded.Fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
}
field := decoded.Fields[0]
// Control must serialize to the SHORT form the frontend switches on.
if field.Control != "MULTI_SELECT" {
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
}
if !field.DynamicOptions {
t.Error("dynamic_options = false, want true")
}
if field.ExclusiveGroupField != "service_kind" {
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
}
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
len(field.ShowWhen[0].Equals) != 2 {
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
}
if field.Validation == nil {
t.Fatal("validation not serialized")
}
if !field.Validation.HasMin || field.Validation.Min != 1 ||
!field.Validation.HasMax || field.Validation.Max != 10 ||
field.Validation.Pattern != "^[0-9]+$" ||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
t.Errorf("validation not serialized correctly: %+v", field.Validation)
}
if len(decoded.Sections) != 1 {
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
}
section := decoded.Sections[0]
if section.Key != "advanced" || section.Title != "Advanced" ||
section.Description != "Optional tuning" || !section.Collapsible ||
!section.CollapsedDefault {
t.Errorf("section scalar fields not serialized correctly: %+v", section)
}
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
}
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
len(section.ShowWhen[0].Equals) != 1 {
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
}
}
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
// renames. The DTO field names currently mirror the proto field names 1:1, so
// this is empty; add an entry only when a DTO field is deliberately named
// differently from its proto source (the test then accepts the renamed target).
var adminFormDTOMappingRenames = map[string]string{}
// protoInternalFields are the protobuf-runtime bookkeeping fields every
// generated message struct carries. They are never serialized to the client
// DTO, so the completeness guard skips them.
var protoInternalFields = map[string]bool{
"state": true,
"sizeCache": true,
"unknownFields": true,
}
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
// later. It reflects over each proto message's exported fields and asserts the
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
// risky; this guard is the proportionate fix.)
func TestAdminFormDTOCompleteness(t *testing.T) {
cases := []struct {
name string
proto reflect.Type
dto reflect.Type
}{
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dtoFields := map[string]bool{}
for i := 0; i < tc.dto.NumField(); i++ {
dtoFields[tc.dto.Field(i).Name] = true
}
for i := 0; i < tc.proto.NumField(); i++ {
pf := tc.proto.Field(i)
if !pf.IsExported() {
continue // unexported protobuf-internal state
}
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
continue
}
want := pf.Name
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
want = mapped
}
if !dtoFields[want] {
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
"(or record an intentional rename in adminFormDTOMappingRenames).",
tc.name, pf.Name, want, tc.dto.Name())
}
}
})
}
}
+27 -5
View File
@@ -29,6 +29,13 @@ type rateLimitConfigResponse struct {
IPReqPerMinute float64 `json:"ip_requests_per_minute"`
IPBurst int `json:"ip_burst"`
AuthEndpoints map[string]authEndpointConfigResponse `json:"auth_endpoints"`
// Active reports whether a limiter is running in this process. The
// limiter is constructed at startup, so config saved while it is absent
// (or a backend change while it is running) needs a restart to apply.
Active bool `json:"active"`
// ActiveBackend is the backend the running limiter actually uses, which
// can differ from Backend until the server restarts.
ActiveBackend string `json:"active_backend,omitempty"`
}
type tierConfigResponse struct {
@@ -64,6 +71,10 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques
IPReqPerMinute: cfg.IPReqPerMinute,
IPBurst: cfg.IPBurst,
AuthEndpoints: make(map[string]authEndpointConfigResponse),
Active: h.mw != nil,
}
if h.mw != nil {
resp.ActiveBackend = h.mw.ActiveBackend()
}
for name, tier := range cfg.Tiers {
resp.Tiers[name] = tierConfigResponse{
@@ -151,10 +162,21 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
}
}
// Hot-reload: apply new config immediately on this instance
if err := h.mw.Reload(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
return
// The limiter is constructed at startup, so enabling while it is absent
// or switching backend while it runs only takes effect after a restart.
// Everything else hot-reloads below.
restartRequired := false
if h.mw == nil {
restartRequired = req.Enabled
} else {
if (req.Backend == "memory" || req.Backend == "redis") && req.Backend != h.mw.ActiveBackend() {
restartRequired = true
}
// Hot-reload: apply new config immediately on this instance
if err := h.mw.Reload(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
return
}
}
// Publish for multi-instance reload (if EventBus is available/backed by Redis)
@@ -165,5 +187,5 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired})
}
+140
View File
@@ -0,0 +1,140 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/ratelimit"
)
type fakeRateLimitStore struct {
values map[string]string
}
func newFakeRateLimitStore() *fakeRateLimitStore {
return &fakeRateLimitStore{values: make(map[string]string)}
}
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
s.values[key] = value
return nil
}
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
out := make(map[string]string, len(s.values))
for k, v := range s.values {
out[k] = v
}
return out, nil
}
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
t.Helper()
perKey := ratelimit.NewMemoryLimiter()
global := ratelimit.NewMemoryLimiter()
t.Cleanup(func() {
perKey.Close()
global.Close()
})
mw := ratelimit.NewMiddleware(perKey, global, store, true)
if err := mw.Init(context.Background()); err != nil {
t.Fatalf("init middleware: %v", err)
}
return mw
}
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
rec := httptest.NewRecorder()
h.HandleUpdateConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return resp
}
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
rec := httptest.NewRecorder()
h.HandleGetConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp rateLimitConfigResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode GET response: %v", err)
}
return resp
}
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
// The limiter is nil when rate limiting was disabled at boot. The config
// endpoints must still work so an admin can re-enable it from the UI.
store := newFakeRateLimitStore()
store.values["ratelimit.enabled"] = "false"
h := NewRateLimitHandler(store, nil, nil)
got := getRateLimitConfig(t, h)
if got.Enabled {
t.Error("GET enabled = true, want false")
}
if got.Active {
t.Error("GET active = true, want false when no limiter is running")
}
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
if resp["restart_required"] != true {
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
}
if store.values["ratelimit.enabled"] != "true" {
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
}
if store.values["ratelimit.backend"] != "redis" {
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
}
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
if resp["restart_required"] != false {
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
}
}
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
store := newFakeRateLimitStore()
mw := newRunningRateLimitMiddleware(t, store)
h := NewRateLimitHandler(store, mw, nil)
got := getRateLimitConfig(t, h)
if !got.Active {
t.Error("GET active = false, want true with a running limiter")
}
if got.ActiveBackend != "memory" {
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
}
// Toggling enabled hot-reloads; no restart needed when the backend matches.
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
if resp["restart_required"] != false {
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
}
// Switching backend only takes effect at boot.
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
if resp["restart_required"] != true {
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
}
}
+5 -1
View File
@@ -48,6 +48,7 @@ type RecommendationsHandler struct {
EpisodeRepo *catalog.EpisodeRepository
WatchTonightFetcher watchTonightSectionFetcher
CastFetcher cardsCastFetcher
EbookProgress EbookReaderProgressLister
// RecWorker enqueues asynchronous taste-profile refreshes after writes
// (taste seeding). Optional — when nil, refresh is simply skipped.
RecWorker ProfileRefreshRequester
@@ -643,7 +644,10 @@ func (h *RecommendationsHandler) loadItemEnrichment(
if h.storeProvider != nil {
store, storeErr := h.storeProvider.ForUser(ctx, userID)
if storeErr == nil && store != nil {
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if stateErr == nil {
out.states = states
}
+39 -53
View File
@@ -38,7 +38,7 @@ type RequestService interface {
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error)
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
@@ -588,62 +588,36 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
}
type requestIntegrationResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Is4K bool `json:"is_4k"`
IsDefault bool `json:"is_default"`
IsDefault4K bool `json:"is_default_4k"`
AnimeEnabled bool `json:"anime_enabled"`
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
AnimeTags []int `json:"anime_tags"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
RootFolder string `json:"root_folder"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
Tags []int `json:"tags"`
Options map[string]any `json:"options"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
CapabilityID string `json:"capability_id"`
InstallationID *int `json:"installation_id,omitempty"`
SupportedMediaTypes []string `json:"supported_media_types"`
PluginConfig map[string]any `json:"plugin_config"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
// Normalize nil slices so they serialize as [] (not null); the frontend
// types them as number[] and indexes/maps over them.
tags := integration.Tags
if tags == nil {
tags = []int{}
}
animeTags := integration.AnimeTags
if animeTags == nil {
animeTags = []int{}
}
return requestIntegrationResponse{
ID: integration.ID,
Name: integration.Name,
Kind: integration.Kind,
Enabled: integration.Enabled,
Is4K: integration.Is4K,
IsDefault: integration.IsDefault,
IsDefault4K: integration.IsDefault4K,
AnimeEnabled: integration.AnimeEnabled,
AnimeQualityProfileID: integration.AnimeQualityProfileID,
AnimeRootFolder: integration.AnimeRootFolder,
AnimeTags: animeTags,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
RootFolder: integration.RootFolder,
QualityProfileID: integration.QualityProfileID,
Tags: tags,
Options: integration.Options,
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
ID: integration.ID,
Name: integration.Name,
CapabilityID: integration.CapabilityID,
InstallationID: integration.InstallationID,
SupportedMediaTypes: integration.SupportedMediaTypes,
PluginConfig: integration.PluginConfig,
Enabled: integration.Enabled,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
}
}
@@ -656,6 +630,18 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI
}
func writeRequestServiceError(w http.ResponseWriter, err error) {
// Plugin/instance validation failures carry inline field/form errors; surface
// them as a structured 400 so any handler routing through here renders them
// inline. Checked first because *ValidationError does not wrap a sentinel.
var verr *mediarequests.ValidationError
if errors.As(err, &verr) {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "validation_failed",
"field_errors": verr.FieldErrors,
"form_error": verr.FormError,
})
return
}
var quota mediarequests.QuotaError
switch {
case errors.As(err, &quota):
+1 -1
View File
@@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi
return nil
}
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) {
return nil, nil
}
+27 -6
View File
@@ -34,6 +34,7 @@ type SectionHandler struct {
DetailSvc *catalog.DetailService
Settings catalog.SettingsStore
CollectionRepo *catalog.LibraryCollectionRepository
EbookProgress EbookReaderProgressLister
}
// NewSectionHandler creates a new SectionHandler.
@@ -157,8 +158,11 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
}
cfg := sections.ParseConfigFilters(config)
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
return "filter_type must be 'movie' or 'series'", false
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" && cfg.FilterType != "audiobook" {
return "filter_type must be 'movie', 'series', or 'audiobook'", false
}
if _, err := sections.ParseContinueType(config); err != nil {
return err.Error(), false
}
var rawLibraryConfig struct {
@@ -1377,7 +1381,10 @@ func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*mod
if err != nil || store == nil {
return map[string]*itemUserStateResponse{}
}
states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items)
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.EpisodeRepo, items, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if err != nil {
return map[string]*itemUserStateResponse{}
}
@@ -1408,8 +1415,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
return resolved
}
// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after
// SectionContinueWatching in the resolved sections list.
// injectNextUpSection inserts a synthetic SectionNextUp entry after the
// contiguous continue rows that start with the video Continue Watching row.
func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection {
nextUp := sections.ResolvedSection{
ID: "system-next-up",
@@ -1418,6 +1425,20 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
ItemLimit: 20,
}
for i, s := range resolved {
if s.SectionType == sections.SectionContinueWatching && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching {
insertAt := i + 1
for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching {
insertAt++
}
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
result = append(result, resolved[:insertAt]...)
result = append(result, nextUp)
result = append(result, resolved[insertAt:]...)
return result
}
}
for i, s := range resolved {
if s.SectionType == sections.SectionContinueWatching {
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
@@ -1428,7 +1449,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
}
}
// If no continue_watching found, prepend
// If no resume row is found, prepend.
return append([]sections.ResolvedSection{nextUp}, resolved...)
}
+60
View File
@@ -282,6 +282,66 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) {
}
}
func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) {
tests := []string{
`{"continue_type":"watching"}`,
`{"continue_type":"listening"}`,
`{"continue_type":"reading"}`,
`{"filter_type":"audiobook"}`,
}
for _, config := range tests {
t.Run(config, func(t *testing.T) {
if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok {
t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg)
}
})
}
}
func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) {
msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`))
if ok {
t.Fatal("validateSectionConfig accepted unknown continue_type")
}
if msg != "continue_type must be 'watching', 'listening', or 'reading'" {
t.Fatalf("message = %q", msg)
}
}
func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) {
in := []sections.ResolvedSection{
{
ID: "cw",
SectionType: sections.SectionContinueWatching,
Title: "Continue Watching",
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
},
{
ID: "cl",
SectionType: sections.SectionContinueWatching,
Title: "Continue Listening",
Config: sections.ContinueTypeConfig(sections.ContinueTypeListening),
},
{ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"},
}
got := injectNextUpSection(in)
gotIDs := make([]string, 0, len(got))
for _, section := range got {
gotIDs = append(gotIDs, section.ID)
}
wantIDs := []string{"cw", "cl", "system-next-up", "recent"}
if len(gotIDs) != len(wantIDs) {
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
}
for i := range wantIDs {
if gotIDs[i] != wantIDs[i] {
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
}
}
}
func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) {
in := []sections.SectionWithItems{
// empty seasonal — drop
+9
View File
@@ -20,6 +20,7 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
const (
libraryPageStateSettingKey = "ui.library_page_state"
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
searchMediaScopeSettingKey = "search.media_scope"
)
const (
@@ -171,6 +172,14 @@ var settingsRegistry = map[string]settingSpec{
DefaultValue: "true",
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
},
// Preferred default scope for global/catalog search. "video" keeps
// results to movies and series; "all" mixes audiobooks in.
searchMediaScopeSettingKey: {
Scope: scopeUser,
DefaultValue: "video",
Validate: validateEnumSetting(searchMediaScopeSettingKey,
"all", "video", "audiobook"),
},
"player.hdr_enabled": {
Scope: scopeDevice,
DefaultValue: "true",
+4 -1
View File
@@ -181,7 +181,10 @@ func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context,
if err != nil || store == nil {
return nil
}
states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, err := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if err != nil {
return nil
}
+34
View File
@@ -14,12 +14,28 @@ type itemUserStateResponse struct {
InWatchlist bool `json:"in_watchlist"`
}
type itemUserStateOptions struct {
UserID int
EbookProgressStore EbookReaderProgressLister
}
func resolveItemUserStates(
ctx context.Context,
store userstore.UserStore,
profileID string,
episodeRepo *catalog.EpisodeRepository,
items []*models.MediaItem,
) (map[string]*itemUserStateResponse, error) {
return resolveItemUserStatesWithOptions(ctx, store, profileID, episodeRepo, items, itemUserStateOptions{})
}
func resolveItemUserStatesWithOptions(
ctx context.Context,
store userstore.UserStore,
profileID string,
episodeRepo *catalog.EpisodeRepository,
items []*models.MediaItem,
options itemUserStateOptions,
) (map[string]*itemUserStateResponse, error) {
result := make(map[string]*itemUserStateResponse, len(items))
if store == nil || profileID == "" || len(items) == 0 {
@@ -111,6 +127,10 @@ func resolveItemUserStates(
if err != nil {
return nil, err
}
ebookProgressMap, err := resolveEbookProgressForUserStates(ctx, options, profileID, contentIDs)
if err != nil {
return nil, err
}
for _, item := range items {
if item == nil || item.ContentID == "" {
@@ -125,6 +145,8 @@ func resolveItemUserStates(
state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap)
case "season":
state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap)
case "ebook":
state.Played = ebookProgressMap[item.ContentID].Progress >= models.EbookFinishedProgressThreshold
default:
state.Played = progressMap[item.ContentID].Completed
}
@@ -134,6 +156,18 @@ func resolveItemUserStates(
return result, nil
}
func resolveEbookProgressForUserStates(
ctx context.Context,
options itemUserStateOptions,
profileID string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
if options.EbookProgressStore == nil || options.UserID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
return options.EbookProgressStore.ListByContentIDs(ctx, options.UserID, profileID, contentIDs)
}
func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool {
if len(episodes) == 0 {
return false
+96
View File
@@ -0,0 +1,96 @@
package handlers
import (
"context"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestResolveItemUserStatesIncludesCompletedEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
items := []*models.MediaItem{
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete Ebook"},
{ContentID: "ebook-progress", Type: "ebook", Title: "Partial Ebook"},
}
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
EbookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-complete": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.95,
UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC),
},
"ebook-progress": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.45,
UpdatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
},
},
},
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-complete"] == nil || !states["ebook-complete"].Played {
t.Fatalf("completed ebook state = %+v, want played", states["ebook-complete"])
}
if states["ebook-progress"] == nil || states["ebook-progress"].Played {
t.Fatalf("partial ebook state = %+v, want not played", states["ebook-progress"])
}
}
func TestResolveItemUserStatesExcludesHiddenEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
items := []*models.MediaItem{
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden Ebook"},
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread Ebook"},
}
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
EbookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-hidden": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-hidden",
Progress: 0.95,
UpdatedAt: updatedAt,
},
"ebook-reread": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-reread",
Progress: 0.95,
UpdatedAt: updatedAt.Add(2 * time.Hour),
},
},
hiddenBefore: map[string]time.Time{
"ebook-hidden": updatedAt.Add(time.Hour),
"ebook-reread": updatedAt.Add(time.Hour),
},
},
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-hidden"] == nil || states["ebook-hidden"].Played {
t.Fatalf("hidden ebook state = %+v, want not played", states["ebook-hidden"])
}
if states["ebook-reread"] == nil || !states["ebook-reread"].Played {
t.Fatalf("ebook updated after hidden_before = %+v, want played again", states["ebook-reread"])
}
}
+4 -1
View File
@@ -362,7 +362,10 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
if h.storeProvider != nil {
store, storeErr := h.storeProvider.ForUser(ctx, userID)
if storeErr == nil && store != nil {
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if stateErr == nil {
stateMap = states
}
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"context"
"errors"
"github.com/Silo-Server/silo-server/internal/plugins"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
// PluginRequestRouterAdapter adapts plugins.Service to
// mediarequests.RouterClientResolver. The concrete *pluginhost.RequestRouterClient
// satisfies mediarequests.RouterClient, so the adapter names the interface as its
// return type (Go has no return-type covariance).
type PluginRequestRouterAdapter struct {
Svc *plugins.Service
}
func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterClient, error) {
if a.Svc == nil {
return nil, errors.New("request router plugin service is not configured")
}
return a.Svc.RequestRouterClient(ctx, installationID, capabilityID)
}
// AttachRequestRouter wires the plugin-backed router provider onto a requests
// service. Both the HTTP handler and the reconcile task call this so the wiring
// lives in one place. With either dependency absent (e.g. a build without the
// plugin service) it leaves the requests service router-less, so fulfillment
// degrades to the existing "no backend configured" failure instead of panicking.
func AttachRequestRouter(svc *mediarequests.Service, pluginService *plugins.Service) {
if svc == nil || pluginService == nil {
return
}
svc.SetRouterProvider(mediarequests.NewPluginRouterProvider(PluginRequestRouterAdapter{pluginService}))
}
+22
View File
@@ -0,0 +1,22 @@
package api
import (
"context"
"testing"
)
// AttachRequestRouter must tolerate absent dependencies (e.g. a build with the
// plugin service disabled) without panicking — fulfillment then degrades to the
// existing "no backend configured" failure rather than crashing the wiring.
func TestAttachRequestRouterNilDependenciesDoesNotPanic(t *testing.T) {
AttachRequestRouter(nil, nil)
}
// A router adapter installed without a plugin service must return a controlled
// error rather than panic on the request path.
func TestPluginRequestRouterAdapterNilServiceReturnsError(t *testing.T) {
adapter := PluginRequestRouterAdapter{}
if _, err := adapter.RequestRouterClient(context.Background(), 1, "request_router.v1"); err == nil {
t.Fatal("RequestRouterClient with nil Svc = nil error, want a controlled error")
}
}
+61 -5
View File
@@ -48,8 +48,6 @@ import (
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -372,7 +370,14 @@ func NewRouter(deps Dependencies) chi.Router {
var webhookSyncHandler *handlers.WebhookSyncHandler
var requestHandler *handlers.RequestsHandler
var autoscanHandler *handlers.AutoscanHandler
var ebookReaderHandler *handlers.EbookReaderHandler
var ebookProgressStore *handlers.PGEbookReaderProgressStore
var ebookConfigStore *handlers.PGEbookReaderConfigStore
var ebookAnnotationStore *handlers.PGEbookReaderAnnotationStore
if deps.DB != nil {
ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB)
ebookConfigStore = handlers.NewPGEbookReaderConfigStore(deps.DB)
ebookAnnotationStore = handlers.NewPGEbookReaderAnnotationStore(deps.DB)
browseRepo := catalog.NewBrowseRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
@@ -423,6 +428,25 @@ func NewRouter(deps Dependencies) chi.Router {
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
}
if ebookProgressStore != nil {
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
if deps.FileRepo != nil {
ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
})
if ebookProgressStore != nil {
ebookReaderHandler.ProgressStore = ebookProgressStore
}
if ebookConfigStore != nil {
ebookReaderHandler.ConfigStore = ebookConfigStore
}
if ebookAnnotationStore != nil {
ebookReaderHandler.AnnotationStore = ebookAnnotationStore
}
}
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
catalogHandler = handlers.NewCatalogHandler(
catalog.NewCatalogResolver(browseRepo, itemRepo).
@@ -441,7 +465,8 @@ func NewRouter(deps Dependencies) chi.Router {
tmdb.NewClient(tmdbAPIKey, 40),
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
AttachRequestRouter(requestSvc, deps.PluginService)
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
}
@@ -500,6 +525,9 @@ func NewRouter(deps Dependencies) chi.Router {
if detailSvc != nil {
personalDataHandler.SetDetailService(detailSvc)
}
if ebookProgressStore != nil {
personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
personalDataHandler.SetEpisodeRepo(episodeRepo)
personalDataHandler.SetSeasonRepo(seasonRepo)
personalDataHandler.EventsHub = deps.EventsHub
@@ -924,6 +952,7 @@ func NewRouter(deps Dependencies) chi.Router {
sectionFetcher.StoreProvider = deps.UserStoreProvider
sectionFetcher.CollectionRepo = catalog.NewLibraryCollectionRepository(deps.DB)
sectionFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
sectionFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
if deps.DB != nil {
sectionFetcher.RecommendationRepo = recommendations.NewRepo(deps.DB)
if ratingsRepo != nil {
@@ -939,6 +968,9 @@ func NewRouter(deps Dependencies) chi.Router {
}
sectionHandler.EpisodeRepo = episodeRepo
sectionHandler.DetailSvc = detailSvc
if ebookProgressStore != nil {
sectionHandler.EbookProgress = ebookProgressStore
}
if userRepo != nil {
sectionHandler.UserRepo = userRepo
}
@@ -1112,6 +1144,7 @@ func NewRouter(deps Dependencies) chi.Router {
recsFetcher := sections.NewFetcher(deps.DB)
recsFetcher.StoreProvider = deps.UserStoreProvider
recsFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
recsFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
recsHandler.Fetcher = recsFetcher
recsHandler.WatchTonightFetcher = recsFetcher
}
@@ -1120,6 +1153,9 @@ func NewRouter(deps Dependencies) chi.Router {
}
recsHandler.CalendarRepo = calendarRepo
recsHandler.EpisodeRepo = episodeRepo
if ebookProgressStore != nil {
recsHandler.EbookProgress = ebookProgressStore
}
if deps.PersonRepo != nil {
recsHandler.CastFetcher = deps.PersonRepo
}
@@ -1420,6 +1456,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/catalog", catalogHandler.HandleGetCatalog)
r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters)
r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch)
r.Get("/catalog/audiobook-groups", catalogHandler.HandleGetAudiobookGroups)
r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery)
if catalogResourceHandler != nil {
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
@@ -1706,6 +1743,22 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
if ebookReaderHandler != nil {
r.Route("/ebooks", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress)
r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress)
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
r.Get("/{content_id}/annotations", ebookReaderHandler.HandleListAnnotations)
r.Post("/{content_id}/annotations", ebookReaderHandler.HandleCreateAnnotation)
r.Patch("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleUpdateAnnotation)
r.Delete("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleDeleteAnnotation)
})
}
// Subtitle search + AI translation routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
@@ -2157,8 +2210,11 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
// Rate limit admin routes
if deps.RateLimitMW != nil && settingsRepo != nil {
// Rate limit admin routes. Mounted even when the limiter is not
// running (deps.RateLimitMW == nil) so admins can always reach the
// config — otherwise disabling rate limiting and restarting would
// permanently lock the settings page out of re-enabling it.
if settingsRepo != nil {
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
r.Route("/rate-limits", func(r chi.Router) {
r.Get("/config", rateLimitHandler.HandleGetConfig)
+20 -4
View File
@@ -38,9 +38,13 @@ func (s *ABSProgressStore) GetProgress(ctx context.Context, userID, profileID, c
var completed bool
var progressPct *float64
// Completed rows store position_seconds = 0 (no resume point), so the
// percentage must come from the completed flag, not the position.
dbRow := s.Pool.QueryRow(ctx, `
SELECT media_item_id, position_seconds, duration_seconds, completed,
CASE WHEN duration_seconds > 0 THEN position_seconds / duration_seconds ELSE 0 END AS progress_pct,
CASE WHEN completed THEN 1.0
WHEN duration_seconds > 0 THEN position_seconds / duration_seconds
ELSE 0 END AS progress_pct,
updated_at
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
@@ -85,7 +89,9 @@ func (s *ABSProgressStore) ListProgressForAudiobooks(ctx context.Context, userID
wp.position_seconds,
wp.duration_seconds,
wp.completed,
CASE WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds ELSE 0 END,
CASE WHEN wp.completed THEN 1.0
WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds
ELSE 0 END,
wp.updated_at
FROM user_watch_progress wp
JOIN media_items mi ON mi.content_id = wp.media_item_id
@@ -138,17 +144,27 @@ func (s *ABSProgressStore) UpsertProgress(ctx context.Context, row abs.ProgressR
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
// Completed rows hold no resume point (position_seconds = 0) — the same
// invariant as the userstore write paths — so finished books can't leak
// into position-based continue-watching/listening queries. A later
// non-finished report (re-listen) moves position forward from 0 while
// the completed latch stays.
position := row.CurrentSeconds
if row.IsFinished {
position = 0
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds),
position_seconds = CASE WHEN EXCLUDED.completed THEN 0
ELSE GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds) END,
duration_seconds = GREATEST(user_watch_progress.duration_seconds, EXCLUDED.duration_seconds),
completed = user_watch_progress.completed OR EXCLUDED.completed,
updated_at = GREATEST(user_watch_progress.updated_at, EXCLUDED.updated_at)`,
uid, row.ProfileID, row.ContentID,
row.CurrentSeconds, row.DurationSeconds, row.IsFinished, updatedAt,
position, row.DurationSeconds, row.IsFinished, updatedAt,
)
if err != nil {
return fmt.Errorf("abs_progress_store: upsert progress: %w", err)
+46 -8
View File
@@ -44,7 +44,8 @@ const (
// defaultEnrichBatchSize is the maximum number of audiobook items processed
// per sweep invocation. Keeps latency bounded for large libraries.
defaultEnrichBatchSize = 50
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
defaultEnrichBatchSize = 250
// defaultEnrichWorkers is the default fan-out used by Enricher.Run.
// Network-bound: each worker holds one provider HTTP call at a time, so
// 4 is enough to mask single-request latency without hammering plugins.
@@ -52,18 +53,28 @@ const (
defaultEnrichWorkers = 4
)
// audiobookEnrichBatchSize returns the configured maximum sweep size.
func audiobookEnrichBatchSize() int {
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
return parsed
}
}
return defaultEnrichBatchSize
}
// audiobookEnrichWorkers returns the configured number of parallel enrichment
// workers, capped to defaultEnrichBatchSize so workers never outnumber the
// batch they drain.
func audiobookEnrichWorkers() int {
// workers, capped to the active batch size so workers never outnumber the batch
// they drain.
func audiobookEnrichWorkers(batchSize int) int {
n := defaultEnrichWorkers
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n > defaultEnrichBatchSize {
n = defaultEnrichBatchSize
if batchSize > 0 && n > batchSize {
n = batchSize
}
return n
}
@@ -105,6 +116,7 @@ func NewEnricher(
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
) *Enricher {
batchSize := audiobookEnrichBatchSize()
return &Enricher{
pool: pool,
chainRepo: chainRepo,
@@ -112,8 +124,8 @@ func NewEnricher(
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
batchSize: defaultEnrichBatchSize,
workers: audiobookEnrichWorkers(),
batchSize: batchSize,
workers: audiobookEnrichWorkers(batchSize),
}
}
@@ -166,6 +178,32 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
return enriched, nil
}
// HasPendingItems reports whether a scheduled sweep has any audiobook rows to
// process. It mirrors claimBatch's eligibility predicate without loading rows
// or provider IDs, so the scheduler can skip no-op executions without adding
// task-history noise.
func (e *Enricher) HasPendingItems(ctx context.Context) (bool, error) {
if e == nil || e.pool == nil || e.chainRepo == nil {
return false, nil
}
var exists bool
err := e.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1
FROM media_items mi
WHERE mi.type = 'audiobook'
AND (mi.poster_path IS NULL OR mi.poster_path = '')
AND mi.last_refreshed IS NULL
LIMIT 1
)
`).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking pending audiobook enrichment: %w", err)
}
return exists, nil
}
// runBatch processes items in parallel using e.workers goroutines. The
// enrichFn parameter exists for testing: production calls pass e.enrichItem.
// Returns the count of items where enrichFn returned nil.
+44
View File
@@ -63,6 +63,50 @@ func TestEnricherRunFansOut(t *testing.T) {
}
}
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 123 {
t.Fatalf("batchSize = %d, want 123", e.batchSize)
}
if e.workers != 8 {
t.Fatalf("workers = %d, want 8", e.workers)
}
}
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 50 {
t.Fatalf("batchSize = %d, want 50", e.batchSize)
}
if e.workers != e.batchSize {
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
}
}
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
if got := audiobookEnrichWorkers(3); got != 3 {
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
}
}
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeAudiobookImageCacher{}
e := &Enricher{imageCacher: cacher}
+22 -4
View File
@@ -106,6 +106,23 @@ func (r *Refresher) WithHTTPClient(hc *http.Client) *Refresher {
return r
}
// HasDue reports whether RefreshDue would attempt at least one feed. The task
// scheduler uses this to avoid recording empty maintenance runs when no podcast
// feeds exist or every feed is still inside its refresh window.
func (r *Refresher) HasDue(ctx context.Context, s Store) (bool, error) {
feeds, err := s.ListPodcastFeeds(ctx)
if err != nil {
return false, fmt.Errorf("list podcast feeds: %w", err)
}
now := time.Now()
for _, f := range feeds {
if shouldRefreshFeed(f, now) {
return true, nil
}
}
return false, nil
}
// RefreshDue walks every podcast_feeds row and refreshes those whose
// last_refreshed_at + refresh_interval has elapsed. Per-feed failures are
// logged at Warn and do not abort the walk. Returns the count of feeds
@@ -118,10 +135,7 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
now := time.Now()
attempted := 0
for _, f := range feeds {
if f.FeedURL == "" {
continue
}
if !isDue(f, now) {
if !shouldRefreshFeed(f, now) {
continue
}
attempted++
@@ -136,6 +150,10 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
return attempted, nil
}
func shouldRefreshFeed(f PodcastFeed, now time.Time) bool {
return f.FeedURL != "" && isDue(f, now)
}
// RefreshOne refreshes a single podcast feed. Public so the admin
// force-refresh endpoint can call it directly. Records success or failure
// in podcast_feeds.last_refresh_error regardless of outcome.
+1 -1
View File
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
if deps.Detail == nil {
return ""
}
return deps.Detail.PresignURL(ctx, path, variant)
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
},
})
s.ABSHandler = h
+54
View File
@@ -3,7 +3,14 @@ package audiobooks
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"unsafe"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/scanner"
)
type fakeSettingsReader struct {
@@ -74,3 +81,50 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
t.Fatal("ABSCompatEnabled = true, want false")
}
}
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
resolver := &recordingImageResolver{}
detail := &catalog.DetailService{}
detail.SetImageResolver(resolver)
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
Items: &catalog.ItemRepository{},
Files: &scanner.FileRepository{},
Detail: detail,
})
coverResolver := absCoverResolverForTest(t, handler)
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
t.Helper()
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
}
type recordingImageResolver struct {
path string
variant string
}
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+10
View File
@@ -25,6 +25,11 @@ type AccessFilter struct {
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
// the predicate can use idx_media_items_sort_key.
NamePrefix string
// ExcludedMediaTypes lists media_items.type values the viewer's surface
// never exposes (e.g. the Jellyfin compat layer excludes "audiobook" and
// "podcast" — they're served by the ABS-compat API instead). Applied by
// every query builder that consumes an AccessFilter.
ExcludedMediaTypes []string
}
func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
@@ -38,6 +43,11 @@ func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string,
*argIdx = *argIdx + 1
}
}
if len(filter.ExcludedMediaTypes) > 0 {
*conditions = append(*conditions, fmt.Sprintf("NOT (%s.type = ANY($%d))", alias, *argIdx))
*args = append(*args, filter.ExcludedMediaTypes)
*argIdx = *argIdx + 1
}
}
// ApplySectionAccessFilter applies non-library access constraints to section queries.
+201
View File
@@ -0,0 +1,201 @@
package catalog
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
)
// AudiobookGroupBy enumerates the supported audiobook grouping axes.
type AudiobookGroupBy string
const (
AudiobookGroupByAuthor AudiobookGroupBy = "author"
AudiobookGroupByNarrator AudiobookGroupBy = "narrator"
AudiobookGroupBySeries AudiobookGroupBy = "series"
)
// ParseAudiobookGroupBy validates a group_by request parameter.
func ParseAudiobookGroupBy(raw string) (AudiobookGroupBy, bool) {
switch AudiobookGroupBy(strings.ToLower(strings.TrimSpace(raw))) {
case AudiobookGroupByAuthor:
return AudiobookGroupByAuthor, true
case AudiobookGroupByNarrator:
return AudiobookGroupByNarrator, true
case AudiobookGroupBySeries:
return AudiobookGroupBySeries, true
default:
return "", false
}
}
// AudiobookGroupsQuery controls the grouped audiobook browse lookup.
type AudiobookGroupsQuery struct {
LibraryID int
GroupBy AudiobookGroupBy
// Sort is one of "name" (default), "count", "duration".
Sort string
Limit int
Offset int
}
// AudiobookGroup is one grouped browse row: an author, narrator, or series
// with aggregate stats over the audiobooks visible to the viewer.
type AudiobookGroup struct {
Name string
ItemCount int
TotalDurationSeconds int64
InProgressCount int
FinishedCount int
// PosterPaths holds up to four raw poster paths for cover stacks; the
// API layer presigns them.
PosterPaths []string
}
// ListAudiobookGroups returns grouped browse rows for an audiobook library.
// Groups are aggregated per person name (authors/narrators) or per series
// name, matching the case-insensitive semantics of the corresponding catalog
// filters, so a group name can be fed straight back into an author/narrator/
// series filter rule. Progress counts are scoped to the requesting profile via
// filter.UserID / filter.ProfileID.
func ListAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) ([]AudiobookGroup, int, error) {
if pool == nil {
return nil, 0, fmt.Errorf("audiobook groups: no database pool")
}
if q.LibraryID <= 0 {
return nil, 0, fmt.Errorf("audiobook groups: library id is required")
}
limit := q.Limit
if limit <= 0 {
limit = 200
}
if limit > 500 {
limit = 500
}
offset := max(q.Offset, 0)
args := []any{q.LibraryID}
argIdx := 2
conditions := []string{
"mi.type = 'audiobook'",
"mil.media_folder_id = $1",
}
if !appendAudiobookItemAccessConditions("mi", filter, &conditions, &args, &argIdx) {
return []AudiobookGroup{}, 0, nil
}
var joinClause, nameExpr, groupExpr, posterOrder string
switch q.GroupBy {
case AudiobookGroupByAuthor, AudiobookGroupByNarrator:
kind := models.PersonKindAuthor
if q.GroupBy == AudiobookGroupByNarrator {
kind = models.PersonKindNarrator
}
joinClause = fmt.Sprintf(`JOIN item_people ip ON ip.content_id = b.content_id AND ip.kind = $%d
JOIN people p ON p.id = ip.person_id`, argIdx)
args = append(args, int(kind))
argIdx++
nameExpr = "MIN(BTRIM(p.name))"
groupExpr = "LOWER(BTRIM(p.name))"
posterOrder = "b.sort_title"
case AudiobookGroupBySeries:
joinClause = "JOIN audiobook_series s ON s.content_id = b.content_id"
nameExpr = "MIN(BTRIM(s.series_name))"
groupExpr = "LOWER(BTRIM(s.series_name))"
posterOrder = "s.series_index NULLS LAST, b.sort_title"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported group_by %q", q.GroupBy)
}
userArg := fmt.Sprintf("$%d", argIdx)
args = append(args, filter.UserID)
argIdx++
profileArg := fmt.Sprintf("$%d", argIdx)
args = append(args, filter.ProfileID)
argIdx++
var orderClause string
switch strings.ToLower(strings.TrimSpace(q.Sort)) {
case "", "name":
orderClause = "LOWER(name)"
case "count":
orderClause = "item_count DESC, LOWER(name)"
case "duration":
orderClause = "total_duration_seconds DESC, LOWER(name)"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported sort %q", q.Sort)
}
query := fmt.Sprintf(`
WITH books AS (
SELECT
mi.content_id,
mi.poster_path,
mi.sort_title,
COALESCE((
SELECT SUM(mf.duration)
FROM media_files mf
WHERE mf.content_id = mi.content_id AND mf.missing_since IS NULL
), 0) AS duration_seconds
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
WHERE %s
),
grouped AS (
SELECT
%s AS name,
COUNT(*)::int AS item_count,
COALESCE(SUM(b.duration_seconds), 0)::bigint AS total_duration_seconds,
COUNT(*) FILTER (WHERE uwp.media_item_id IS NOT NULL AND NOT uwp.completed)::int AS in_progress_count,
COUNT(*) FILTER (WHERE uwp.completed)::int AS finished_count,
(ARRAY_AGG(b.poster_path ORDER BY %s) FILTER (WHERE NULLIF(b.poster_path, '') IS NOT NULL))[1:4] AS poster_paths
FROM books b
%s
LEFT JOIN user_watch_progress uwp
ON uwp.media_item_id = b.content_id
AND uwp.user_id = %s
AND uwp.profile_id = %s
GROUP BY %s
)
SELECT name, item_count, total_duration_seconds, in_progress_count, finished_count, poster_paths,
COUNT(*) OVER ()::int AS total_groups
FROM grouped
ORDER BY %s
LIMIT $%d OFFSET $%d`,
strings.Join(conditions, " AND "),
nameExpr, posterOrder, joinClause, userArg, profileArg, groupExpr, orderClause,
argIdx, argIdx+1,
)
args = append(args, limit, offset)
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("querying audiobook groups: %w", err)
}
defer rows.Close()
total := 0
groups := make([]AudiobookGroup, 0, limit)
for rows.Next() {
var g AudiobookGroup
var posterPaths []string
if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths, &total); err != nil {
return nil, 0, fmt.Errorf("scanning audiobook group: %w", err)
}
if posterPaths == nil {
posterPaths = []string{}
}
g.PosterPaths = posterPaths
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterating audiobook groups: %w", err)
}
return groups, total, nil
}
+163
View File
@@ -0,0 +1,163 @@
package catalog
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// NextInSeriesQuery controls the audiobook next-in-series lookup.
type NextInSeriesQuery struct {
UserID int
ProfileID string
Limit int
// LibraryID / LibraryIDs / DisabledLibraryIDs scope the candidate books in
// SQL. Without this, a profile whose recently finished series live in a
// different library would consume the LIMIT with candidates the caller
// then filters out, starving a library-scoped section.
LibraryID *int
LibraryIDs []int
DisabledLibraryIDs []int
}
// NextInSeriesResult is one row from the next-in-series query: the next
// unstarted audiobook in a series the profile has finished a book of.
type NextInSeriesResult struct {
ContentID string
SeriesName string
SeriesIndex *float64
LastFinishedAt time.Time
}
// AudiobookNextRepository queries audiobook series progression for the
// next_in_series section.
type AudiobookNextRepository struct {
pool *pgxpool.Pool
}
// NewAudiobookNextRepository creates an AudiobookNextRepository.
func NewAudiobookNextRepository(pool *pgxpool.Pool) *AudiobookNextRepository {
return &AudiobookNextRepository{pool: pool}
}
// ListNextInSeries returns, for each audiobook series the profile has finished
// at least one book of, the lowest-indexed later book the profile has not
// started yet. Series surface in most-recently-finished order. Books already
// in progress are excluded — they belong to Continue Listening, not here.
//
// Candidate books are library-scoped in SQL (see NextInSeriesQuery); remaining
// access filtering (content rating) is applied by the caller when resolving
// content IDs to items.
func (r *AudiobookNextRepository) ListNextInSeries(ctx context.Context, q NextInSeriesQuery) ([]NextInSeriesResult, error) {
if r == nil || r.pool == nil || q.UserID <= 0 || q.ProfileID == "" {
return nil, nil
}
limit := q.Limit
if limit <= 0 {
limit = 20
}
args := []any{q.UserID, q.ProfileID}
argIdx := 3
candidateScope := ""
if q.LibraryID != nil {
candidateScope += fmt.Sprintf(`
AND EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = m.content_id AND mil.media_folder_id = $%d
)`, argIdx)
args = append(args, *q.LibraryID)
argIdx++
} else if q.LibraryIDs != nil {
if len(q.LibraryIDs) == 0 {
return nil, nil
}
candidateScope += fmt.Sprintf(`
AND EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = m.content_id AND mil.media_folder_id = ANY($%d)
)`, argIdx)
args = append(args, q.LibraryIDs)
argIdx++
}
if len(q.DisabledLibraryIDs) > 0 {
candidateScope += fmt.Sprintf(`
AND NOT EXISTS (
SELECT 1 FROM media_item_libraries mil_disabled
WHERE mil_disabled.content_id = m.content_id
AND mil_disabled.media_folder_id = ANY($%d)
)`, argIdx)
args = append(args, q.DisabledLibraryIDs)
argIdx++
}
query := fmt.Sprintf(`
WITH finished_series AS (
SELECT
LOWER(BTRIM(s.series_name)) AS series_key,
MIN(BTRIM(s.series_name)) AS series_name,
MAX(s.series_index) AS max_finished_index,
MAX(uwp.updated_at) AS last_finished_at
FROM user_watch_progress uwp
JOIN audiobook_series s ON s.content_id = uwp.media_item_id
JOIN media_items mi ON mi.content_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = TRUE
AND mi.type = 'audiobook'
AND s.series_index IS NOT NULL
GROUP BY LOWER(BTRIM(s.series_name))
)
SELECT
next_book.content_id,
fs.series_name,
next_book.series_index,
fs.last_finished_at
FROM finished_series fs
JOIN LATERAL (
SELECT m.content_id, s2.series_index
FROM audiobook_series s2
JOIN media_items m ON m.content_id = s2.content_id
WHERE LOWER(BTRIM(s2.series_name)) = fs.series_key
AND s2.series_index IS NOT NULL
AND s2.series_index > fs.max_finished_index
AND m.type = 'audiobook'
AND EXISTS (
SELECT 1 FROM media_files mf
WHERE mf.content_id = m.content_id AND mf.missing_since IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM user_watch_progress uwp2
WHERE uwp2.user_id = $1
AND uwp2.profile_id = $2
AND uwp2.media_item_id = m.content_id
)%s
ORDER BY s2.series_index, LOWER(m.sort_title)
LIMIT 1
) next_book ON true
ORDER BY fs.last_finished_at DESC
LIMIT $%d`, candidateScope, argIdx)
args = append(args, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying next-in-series audiobooks: %w", err)
}
defer rows.Close()
var results []NextInSeriesResult
for rows.Next() {
var res NextInSeriesResult
if err := rows.Scan(&res.ContentID, &res.SeriesName, &res.SeriesIndex, &res.LastFinishedAt); err != nil {
return nil, fmt.Errorf("scanning next-in-series row: %w", err)
}
results = append(results, res)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating next-in-series rows: %w", err)
}
return results, nil
}
+21 -6
View File
@@ -36,6 +36,7 @@ type BrowseFilters struct {
MaxLimit int // optional caller-specific cap; zero keeps the default browse cap
Offset int
SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp
RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter)
}
// BrowseResult contains the paginated result of a browse query.
@@ -295,6 +296,13 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
argIdx++
}
// Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so
// random/limited selections only ever pick items that actually have a
// backdrop — filtering after the LIMIT would wrongly return empty pages.
if filters.RequireBackdrop {
conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL")
}
// Library access control: restrict to user's accessible libraries.
needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added"
needsPersonJoin := filters.PersonID > 0
@@ -759,9 +767,9 @@ func listDistinctPeopleByKindWithSource(
}
// listDistinctAudiobookSeriesWithSource returns distinct series_name values
// from audiobook_series joined onto the scoped result set. Names are trimmed
// from the active book series table joined onto the scoped result set. Names are trimmed
// and case-folded for sort so the picker doesn't show duplicates that differ
// only by whitespace or casing — the literal trimmed series_name is still
// only by whitespace or casing; the literal trimmed series_name is still
// returned so existing rules continue to match.
//
// The DISTINCT happens in an inline subquery (rather than directly on the
@@ -783,14 +791,14 @@ func listDistinctAudiobookSeriesWithSource(
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
JOIN %s s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), catalogFacetMaxValues)
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
@@ -946,7 +954,7 @@ func searchDistinctAudiobookSeriesWithSource(
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
JOIN %s s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
@@ -954,10 +962,17 @@ func searchDistinctAudiobookSeriesWithSource(
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), prefixIdx, limit+1)
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), prefixIdx, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
func bookSeriesTableForMediaScope(mediaScope string) string {
if mediaScope == "ebook" {
return "ebook_series"
}
return "audiobook_series"
}
// queryFacetSearchResults executes a facet search query that was built
// with LIMIT N+1 and returns the first N rows plus a hasMore flag
// indicating whether an additional row was present (i.e. the result set
+31
View File
@@ -0,0 +1,31 @@
package catalog
import (
"strings"
"testing"
)
// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter
// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into
// the WHERE clause, and is absent otherwise. Guards against a future refactor
// of buildBrowsePlan silently dropping the condition.
func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) {
const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL"
repo := &BrowseRepository{}
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true})
if err != nil || earlyEmpty {
t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty)
}
if !strings.Contains(plan.whereClause, predicate) {
t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause)
}
plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"})
if err != nil {
t.Fatalf("buildBrowsePlan err=%v", err)
}
if strings.Contains(plan.whereClause, predicate) {
t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause)
}
}
+4 -5
View File
@@ -389,12 +389,11 @@ func normalizeCatalogMatch(raw string) string {
}
func parseCatalogMediaScope(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "movie", "series", "episode", "audiobook":
return strings.ToLower(strings.TrimSpace(raw))
default:
return ""
scope := strings.ToLower(strings.TrimSpace(raw))
if scope != "" && IsValidMediaScope(scope) {
return scope
}
return ""
}
func defaultCatalogQuerySort(searchQuery string) QuerySort {
+9
View File
@@ -0,0 +1,9 @@
package catalog
import "testing"
func TestParseCatalogMediaScope_AllowsEbook(t *testing.T) {
if got := parseCatalogMediaScope(" ebook "); got != "ebook" {
t.Fatalf("expected ebook media scope, got %q", got)
}
}
+27 -31
View File
@@ -39,10 +39,9 @@ type CatalogFiltersResult struct {
Resolutions []string
AudioLanguages []string
SubtitleLanguages []string
// Authors, Narrators and Series are audiobook-native facets. Populated
// whenever the scope contains audiobook items; empty for video-only
// scopes. They share the item_people / audiobook_series tables so
// callers don't need to switch on media type.
// Authors, Narrators and Series are book-native facets. Authors and
// Narrators use item_people; Series uses the active book series table.
// Video-only scopes return these empty.
Authors []string
Narrators []string
Series []string
@@ -64,8 +63,8 @@ type facetFetcher interface {
// PeopleByKind returns distinct people names for the given PersonKind
// across the scoped result set. Used for Authors / Narrators facets.
PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// AudiobookSeries returns distinct series_name values from
// audiobook_series joined onto the scoped result set.
// AudiobookSeries returns distinct series_name values from the active
// book series table joined onto the scoped result set.
AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// SearchDistinctArrayColumn prefix-searches an array-column facet
// (genres, studios, networks, countries). Returns up to limit
@@ -710,7 +709,7 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo
return r.listFiltersForSource(ctx, filters, options, episodeCatalogBaseRelation, req.Query.MediaScope)
}
return r.listFiltersForSource(ctx, filters, options, "media_items mi", "")
return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope)
}
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
@@ -798,10 +797,9 @@ func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, a
}
baseRelation := "media_items mi"
mediaScope := ""
mediaScope := req.Query.MediaScope
if isEpisodeCatalogScope(req.Query.MediaScope) {
baseRelation = episodeCatalogBaseRelation
mediaScope = req.Query.MediaScope
}
facets := r.facets
@@ -837,6 +835,9 @@ func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string,
case "author":
return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit)
case "narrator":
if mediaScope == "ebook" {
return nil, false, fmt.Errorf("%w: narrator facet is not available for ebook scope", ErrInvalidCatalogRequest)
}
return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit)
case "series":
return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit)
@@ -958,14 +959,16 @@ func (r *CatalogResolver) listFiltersForSource(
authors = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
if mediaScope != "ebook" {
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
}
eg.Go(withLimit(func() error {
out, err := facets.AudiobookSeries(gctx, filters, baseRelation, mediaScope)
if err != nil {
@@ -1086,12 +1089,8 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
}
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
if def.MediaScope != "" &&
def.MediaScope != "movie" &&
def.MediaScope != "series" &&
def.MediaScope != "episode" &&
def.MediaScope != "audiobook" {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
if !IsValidMediaScope(def.MediaScope) {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', or 'video'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
@@ -1607,12 +1606,7 @@ func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter,
MaxContentRating: access.MaxContentRating,
}
var itemTypes []string
if req.Query.MediaScope != "" {
itemTypes = []string{req.Query.MediaScope}
}
return searchAccess, itemTypes, false
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
}
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
@@ -1622,7 +1616,9 @@ func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilter
}
filters := BrowseFilters{
Type: req.Query.MediaScope,
// BrowseFilters.Type accepts a comma-separated type list, so group
// scopes like "video" expand here rather than leaking downstream.
Type: strings.Join(MediaScopeItemTypes(req.Query.MediaScope), ","),
NamePrefix: req.NamePrefix,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
@@ -1763,7 +1759,7 @@ func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*model
if item == nil {
continue
}
if def.MediaScope != "" && !strings.EqualFold(strings.TrimSpace(item.Type), def.MediaScope) {
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
continue
}
if catalogDefinitionMatchesItem(item, def) {
+165
View File
@@ -39,6 +39,21 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) {
}
}
func TestValidateCatalogQueryRequest_AllowsEbookMediaScope(t *testing.T) {
req := CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{
MediaScope: "ebook",
Match: "all",
Sort: QuerySort{Field: "title", Order: "asc"},
},
}
if err := validateCatalogQueryRequest(req, true); err != nil {
t.Fatalf("expected ebook media scope to be accepted, got %v", err)
}
}
func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) {
req := CatalogRequest{
Source: CatalogSourceQuery,
@@ -244,6 +259,67 @@ func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters Br
return nil, false, nil
}
type recordingFacetFetcher struct {
mu sync.Mutex
peopleKinds []models.PersonKind
searchPeopleKind models.PersonKind
searchMediaScope string
}
func (s *recordingFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
s.mu.Lock()
s.peopleKinds = append(s.peopleKinds, kind)
s.mu.Unlock()
if kind == models.PersonKindAuthor {
return []string{"Author"}, nil
}
return []string{"Narrator"}, nil
}
func (s *recordingFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
func (s *recordingFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
func (s *recordingFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.mu.Lock()
s.searchPeopleKind = kind
s.searchMediaScope = mediaScope
s.mu.Unlock()
return []string{"Author"}, false, nil
}
func (s *recordingFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
// countingExecutor is a previewExecutor stub that records the number of
// PreviewPage calls and the AccessFilter passed in (so tests can confirm
// NamePrefix made it through). It returns an empty result set so the resolver
@@ -327,3 +403,92 @@ func TestListFiltersWithOptions_RunsFacetQueriesConcurrently(t *testing.T) {
barrier.assertConcurrent(t)
}
func TestListFiltersWithOptions_EbookScopeSkipsNarratorFacet(t *testing.T) {
facets := &recordingFacetFetcher{}
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: facets,
}
result, err := resolver.ListFiltersWithOptions(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
CatalogFilterOptions{},
)
if err != nil {
t.Fatalf("ListFiltersWithOptions returned error: %v", err)
}
facets.mu.Lock()
defer facets.mu.Unlock()
for _, kind := range facets.peopleKinds {
if kind == models.PersonKindNarrator {
t.Fatal("ebook filters should not request narrator facets")
}
}
if len(result.Narrators) != 0 {
t.Fatalf("ebook filters returned narrators = %v, want empty", result.Narrators)
}
}
func TestSearchFacet_EbookScopeForwardsMediaScope(t *testing.T) {
facets := &recordingFacetFetcher{}
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: facets,
}
result, err := resolver.SearchFacet(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
"author",
"Au",
20,
)
if err != nil {
t.Fatalf("SearchFacet returned error: %v", err)
}
if len(result.Matches) != 1 || result.Matches[0] != "Author" {
t.Fatalf("matches = %v, want Author", result.Matches)
}
facets.mu.Lock()
defer facets.mu.Unlock()
if facets.searchPeopleKind != models.PersonKindAuthor {
t.Fatalf("searchPeopleKind = %v, want author", facets.searchPeopleKind)
}
if facets.searchMediaScope != "ebook" {
t.Fatalf("search mediaScope = %q, want ebook", facets.searchMediaScope)
}
}
func TestSearchFacet_EbookScopeRejectsNarratorFacet(t *testing.T) {
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: &recordingFacetFetcher{},
}
_, err := resolver.SearchFacet(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
"narrator",
"Na",
20,
)
if err == nil {
t.Fatal("expected narrator facet to be rejected for ebook scope")
}
}
@@ -0,0 +1,212 @@
package catalog
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ProgressLister pages watch-progress rows for one profile.
// userstore.UserStore satisfies it.
type ProgressLister interface {
ListProgress(ctx context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error)
}
// ProgressSnapshot pairs a media item with the time its progress row last changed.
type ProgressSnapshot struct {
ContentID string
UpdatedAt time.Time
}
// ContinueWatchingProgressFilter identifies in-progress entries that Continue
// Watching surfaces should hide: episodes superseded by a later-completed
// episode in the same series. The first-party sections fetcher and the
// jellycompat Resume endpoint share it so both surfaces agree on what "still
// watching" means.
type ContinueWatchingProgressFilter struct {
pool *pgxpool.Pool
}
// NewContinueWatchingProgressFilter creates a filter. A nil pool disables the
// superseded-episode check, leaving entries unfiltered.
func NewContinueWatchingProgressFilter(pool *pgxpool.Pool) *ContinueWatchingProgressFilter {
return &ContinueWatchingProgressFilter{pool: pool}
}
const supersededProgressPageSize = 500
// SupersededEpisodeProgressIDs returns the content IDs of in-progress entries
// whose series has a later episode completed more recently than the entry's
// own progress. Those entries are stale — the viewer already moved past them.
// Non-episode entries never match.
func (f *ContinueWatchingProgressFilter) SupersededEpisodeProgressIDs(ctx context.Context, store ProgressLister, profileID string, entries []userstore.WatchProgress) (map[string]struct{}, error) {
if f == nil || f.pool == nil {
return map[string]struct{}{}, nil
}
inProgress := ProgressSnapshots(entries)
if len(inProgress) == 0 {
return map[string]struct{}{}, nil
}
completed, err := CompletedProgressSnapshots(ctx, store, profileID)
if err != nil {
return nil, err
}
if len(completed) == 0 {
return map[string]struct{}{}, nil
}
inProgressIDs, inProgressUpdatedAts := splitProgressSnapshots(inProgress)
completedIDs, completedUpdatedAts := splitProgressSnapshots(completed)
query := buildSupersededEpisodeProgressQuery()
rows, err := f.pool.Query(ctx, query, inProgressIDs, inProgressUpdatedAts, completedIDs, completedUpdatedAts)
if err != nil {
return nil, fmt.Errorf("querying superseded episode progress: %w", err)
}
defer rows.Close()
superseded := make(map[string]struct{})
for rows.Next() {
var mediaItemID string
if err := rows.Scan(&mediaItemID); err != nil {
return nil, fmt.Errorf("scanning superseded episode progress: %w", err)
}
superseded[mediaItemID] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating superseded episode progress: %w", err)
}
return superseded, nil
}
// CompletedProgressSnapshots pages through all completed progress rows for the
// profile and returns deduplicated snapshots.
func CompletedProgressSnapshots(ctx context.Context, store ProgressLister, profileID string) ([]ProgressSnapshot, error) {
seen := make(map[string]struct{})
snapshots := make([]ProgressSnapshot, 0)
for offset := 0; ; offset += supersededProgressPageSize {
entries, err := store.ListProgress(ctx, profileID, "completed", supersededProgressPageSize, offset)
if err != nil {
return nil, fmt.Errorf("listing completed progress for superseded episodes: %w", err)
}
for _, snapshot := range ProgressSnapshots(entries) {
contentID := snapshot.ContentID
if _, ok := seen[contentID]; ok {
continue
}
seen[contentID] = struct{}{}
snapshots = append(snapshots, snapshot)
}
if len(entries) < supersededProgressPageSize {
return snapshots, nil
}
}
}
// ProgressSnapshots converts progress rows to snapshots, dropping rows with a
// blank media item ID or an unparseable timestamp.
func ProgressSnapshots(entries []userstore.WatchProgress) []ProgressSnapshot {
snapshots := make([]ProgressSnapshot, 0, len(entries))
for _, entry := range entries {
contentID := strings.TrimSpace(entry.MediaItemID)
if contentID == "" {
continue
}
updatedAt, err := time.Parse(time.RFC3339, entry.UpdatedAt)
if err != nil || updatedAt.IsZero() {
continue
}
snapshots = append(snapshots, ProgressSnapshot{
ContentID: contentID,
UpdatedAt: updatedAt.UTC(),
})
}
return snapshots
}
func splitProgressSnapshots(snapshots []ProgressSnapshot) ([]string, []time.Time) {
contentIDs := make([]string, len(snapshots))
updatedAts := make([]time.Time, len(snapshots))
for i, snapshot := range snapshots {
contentIDs[i] = snapshot.ContentID
updatedAts[i] = snapshot.UpdatedAt
}
return contentIDs, updatedAts
}
// The snapshots arrive as unnest arrays instead of joins against
// user_watch_progress because per-user progress may live in a SQLite store
// rather than this Postgres database.
func buildSupersededEpisodeProgressQuery() string {
return `
WITH in_progress(content_id, updated_at) AS (
SELECT * FROM unnest($1::text[], $2::timestamptz[])
),
completed(content_id, updated_at) AS (
SELECT * FROM unnest($3::text[], $4::timestamptz[])
)
SELECT DISTINCT ip.content_id
FROM in_progress ip_progress
JOIN episodes ip ON ip.content_id = ip_progress.content_id
JOIN episodes done
ON done.series_id = ip.series_id
AND (done.season_number, done.episode_number) > (ip.season_number, ip.episode_number)
JOIN completed done_progress
ON done_progress.content_id = done.content_id
WHERE done_progress.updated_at > ip_progress.updated_at`
}
// FilterSupersededProgress drops entries whose media item ID is in the
// superseded set.
func FilterSupersededProgress(entries []userstore.WatchProgress, superseded map[string]struct{}) []userstore.WatchProgress {
if len(entries) == 0 || len(superseded) == 0 {
return entries
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
if _, ok := superseded[entry.MediaItemID]; ok {
continue
}
filtered = append(filtered, entry)
}
return filtered
}
// HomeDismissalIndex maps media item ID to its dismissal row for one home surface.
type HomeDismissalIndex map[string]userstore.HomeItemDismissal
// NewHomeDismissalIndex builds an index from dismissal rows.
func NewHomeDismissalIndex(dismissals []userstore.HomeItemDismissal) HomeDismissalIndex {
index := make(HomeDismissalIndex, len(dismissals))
for _, dismissal := range dismissals {
index[dismissal.MediaItemID] = dismissal
}
return index
}
// FilterProgress drops entries still covered by a dismissal. A dismissal only
// holds while the entry's progress timestamp matches the one captured when the
// user dismissed it; resuming playback re-surfaces the item.
func (idx HomeDismissalIndex) FilterProgress(entries []userstore.WatchProgress) []userstore.WatchProgress {
if len(entries) == 0 || len(idx) == 0 {
return entries
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
dismissal, ok := idx[entry.MediaItemID]
if !ok || dismissal.ProgressUpdatedAt == nil || *dismissal.ProgressUpdatedAt != entry.UpdatedAt {
filtered = append(filtered, entry)
}
}
return filtered
}
@@ -0,0 +1,189 @@
package catalog
import (
"context"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func TestFilterSupersededProgressDropsOlderPartialsAfterLaterCompletedEpisode(t *testing.T) {
t.Parallel()
entries := []userstore.WatchProgress{
{MediaItemID: "boys-s1e1"},
{MediaItemID: "boys-s5e3"},
{MediaItemID: "movie-1"},
}
superseded := map[string]struct{}{
"boys-s1e1": {},
"boys-s5e3": {},
}
filtered := FilterSupersededProgress(entries, superseded)
if len(filtered) != 1 || filtered[0].MediaItemID != "movie-1" {
t.Fatalf("filtered entries = %+v, want only movie-1", filtered)
}
}
func TestCompletedProgressSnapshotsPagesThroughConfiguredStore(t *testing.T) {
t.Parallel()
entries := make([]userstore.WatchProgress, supersededProgressPageSize+1)
for i := range entries {
entries[i] = userstore.WatchProgress{
MediaItemID: "done-" + time.Unix(int64(i), 0).Format("150405"),
UpdatedAt: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC).Format(time.RFC3339),
}
}
store := &stubProgressLister{entries: entries}
snapshots, err := CompletedProgressSnapshots(context.Background(), store, "p1")
if err != nil {
t.Fatalf("CompletedProgressSnapshots: %v", err)
}
if len(snapshots) != len(entries) {
t.Fatalf("completed snapshots count = %d, want %d", len(snapshots), len(entries))
}
if len(store.calls) != 2 {
t.Fatalf("ListProgress calls = %+v, want 2 paged calls", store.calls)
}
if store.calls[0] != (progressListCall{profileID: "p1", status: "completed", limit: supersededProgressPageSize, offset: 0}) {
t.Fatalf("first ListProgress call = %+v", store.calls[0])
}
if store.calls[1] != (progressListCall{profileID: "p1", status: "completed", limit: supersededProgressPageSize, offset: supersededProgressPageSize}) {
t.Fatalf("second ListProgress call = %+v", store.calls[1])
}
}
func TestBuildSupersededEpisodeProgressQueryUsesStoreSnapshotsWithFreshnessGate(t *testing.T) {
t.Parallel()
query := buildSupersededEpisodeProgressQuery()
expectedFragments := []string{
"unnest($1::text[], $2::timestamptz[])",
"unnest($3::text[], $4::timestamptz[])",
"FROM in_progress ip_progress",
"done_progress.updated_at > ip_progress.updated_at",
}
for _, fragment := range expectedFragments {
if !strings.Contains(query, fragment) {
t.Fatalf("expected superseded progress query to contain %q, got:\n%s", fragment, query)
}
}
unexpectedFragments := []string{
"user_watch_progress",
"user_history_hidden_items",
}
for _, fragment := range unexpectedFragments {
if strings.Contains(query, fragment) {
t.Fatalf("superseded progress query contains %q, got:\n%s", fragment, query)
}
}
}
func TestSupersededEpisodeProgressIDsWithoutPoolReturnsEmptySet(t *testing.T) {
t.Parallel()
filter := NewContinueWatchingProgressFilter(nil)
entries := []userstore.WatchProgress{{
MediaItemID: "ep-1",
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
}}
store := &stubProgressLister{}
superseded, err := filter.SupersededEpisodeProgressIDs(context.Background(), store, "p1", entries)
if err != nil {
t.Fatalf("SupersededEpisodeProgressIDs: %v", err)
}
if len(superseded) != 0 {
t.Fatalf("superseded = %v, want empty set", superseded)
}
if len(store.calls) != 0 {
t.Fatalf("ListProgress calls = %+v, want none without a pool", store.calls)
}
}
func TestHomeDismissalIndexFilterProgressDropsOnlyMatchingTimestamps(t *testing.T) {
t.Parallel()
dismissedAt := "2025-01-01T00:00:00Z"
resumedAt := "2025-01-02T00:00:00Z"
idx := NewHomeDismissalIndex([]userstore.HomeItemDismissal{
{MediaItemID: "still-dismissed", ProgressUpdatedAt: &dismissedAt},
{MediaItemID: "resumed-since", ProgressUpdatedAt: &dismissedAt},
{MediaItemID: "no-timestamp"},
})
entries := []userstore.WatchProgress{
{MediaItemID: "still-dismissed", UpdatedAt: dismissedAt},
{MediaItemID: "resumed-since", UpdatedAt: resumedAt},
{MediaItemID: "no-timestamp", UpdatedAt: dismissedAt},
{MediaItemID: "never-dismissed", UpdatedAt: dismissedAt},
}
filtered := idx.FilterProgress(entries)
got := make([]string, 0, len(filtered))
for _, entry := range filtered {
got = append(got, entry.MediaItemID)
}
want := []string{"resumed-since", "no-timestamp", "never-dismissed"}
if len(got) != len(want) {
t.Fatalf("filtered = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("filtered = %v, want %v", got, want)
}
}
}
func TestProgressSnapshotsSkipsBlankIDsAndBadTimestamps(t *testing.T) {
t.Parallel()
valid := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
entries := []userstore.WatchProgress{
{MediaItemID: "ok", UpdatedAt: valid.Format(time.RFC3339)},
{MediaItemID: " ", UpdatedAt: valid.Format(time.RFC3339)},
{MediaItemID: "bad-time", UpdatedAt: "not-a-time"},
}
snapshots := ProgressSnapshots(entries)
if len(snapshots) != 1 || snapshots[0].ContentID != "ok" || !snapshots[0].UpdatedAt.Equal(valid) {
t.Fatalf("snapshots = %+v, want single valid snapshot for %q", snapshots, "ok")
}
}
type progressListCall struct {
profileID string
status string
limit int
offset int
}
type stubProgressLister struct {
entries []userstore.WatchProgress
calls []progressListCall
}
func (s *stubProgressLister) ListProgress(_ context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error) {
s.calls = append(s.calls, progressListCall{
profileID: profileID,
status: status,
limit: limit,
offset: offset,
})
if offset >= len(s.entries) {
return nil, nil
}
end := offset + limit
if end > len(s.entries) {
end = len(s.entries)
}
return s.entries[offset:end], nil
}
+81 -15
View File
@@ -157,6 +157,9 @@ type ItemDetail struct {
// Audiobook-specific detail. Present only when Type == "audiobook".
Audiobook *AudiobookDetailExtension `json:"audiobook,omitempty"`
// Ebook-specific detail. Present only when Type == "ebook".
Ebook *EbookDetailExtension `json:"ebook,omitempty"`
}
type AudiobookDetailExtension struct {
@@ -201,6 +204,13 @@ type AudiobookNarration struct {
Narrators []string `json:"narrators"`
}
type EbookDetailExtension struct {
Authors []AudiobookPerson `json:"authors"`
Publisher string `json:"publisher,omitempty"`
Series *AudiobookSeriesGroup `json:"series,omitempty"`
Related AudiobookRelatedContent `json:"related"`
}
// ItemUserState is per-profile viewer state included in item detail responses.
type ItemUserState struct {
Played bool `json:"played"`
@@ -895,6 +905,9 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
if item.Type == "audiobook" {
detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter)
}
if item.Type == "ebook" {
detail.Ebook = s.buildEbookExtension(ctx, item, crewCredits, filter)
}
// Series folder paths from confirmed claims when available, otherwise from
// the file links that currently belong to the item. This keeps provisional
@@ -1036,6 +1049,26 @@ func (s *DetailService) buildAudiobookExtension(
}
}
func (s *DetailService) buildEbookExtension(
ctx context.Context,
item *models.MediaItem,
crew []CrewCredit,
filter AccessFilter,
) *EbookDetailExtension {
if item == nil {
return nil
}
return &EbookDetailExtension{
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
Publisher: firstNonEmptyString(item.Studios),
Series: s.fetchEbookSeries(ctx, item.ContentID, filter),
Related: AudiobookRelatedContent{
AlsoByAuthor: s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter),
Similar: s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter),
},
}
}
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
out := make([]AudiobookPerson, 0)
for _, credit := range crew {
@@ -1061,6 +1094,10 @@ func firstNonEmptyString(values []string) string {
return ""
}
func (s *DetailService) presignAudiobookPosterURL(ctx context.Context, posterPath string) string {
return s.PresignImageURL(ctx, posterPath, "poster", "")
}
func appendAudiobookItemAccessConditions(
alias string,
filter AccessFilter,
@@ -1103,15 +1140,23 @@ func appendAudiobookItemAccessConditions(
}
func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookAlsoByAuthor(ctx, contentID, "audiobook", filter)
}
func (s *DetailService) fetchEbookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookAlsoByAuthor(ctx, contentID, "ebook", filter)
}
func (s *DetailService) fetchBookAlsoByAuthor(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor}
argIdx := 3
args := []any{contentID, models.PersonKindAuthor, mediaType}
argIdx := 4
conditions := []string{
"ip1.content_id = $1",
"ip1.kind = $2",
"m2.type = 'audiobook'",
"m2.type = $3",
}
if !appendAudiobookItemAccessConditions("m2", filter, &conditions, &args, &argIdx) {
return []AudiobookRelatedItem{}
@@ -1146,20 +1191,28 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
continue
}
seen[item.ContentID] = struct{}{}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
}
func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookSimilarByGenres(ctx, contentID, "audiobook", filter)
}
func (s *DetailService) fetchEbookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookSimilarByGenres(ctx, contentID, "ebook", filter)
}
func (s *DetailService) fetchBookSimilarByGenres(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor}
argIdx := 3
args := []any{contentID, models.PersonKindAuthor, mediaType}
argIdx := 4
conditions := []string{
"m.type = 'audiobook'",
"m.type = $3",
"m.content_id <> $1",
"m.genres && (SELECT array_agg(g) FROM this_genres)",
`NOT EXISTS (
@@ -1205,21 +1258,34 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
return []AudiobookRelatedItem{}
}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
}
func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
return s.fetchBookSeries(ctx, contentID, "audiobook", "audiobook_series", filter)
}
func (s *DetailService) fetchEbookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
return s.fetchBookSeries(ctx, contentID, "ebook", "ebook_series", filter)
}
func (s *DetailService) fetchBookSeries(ctx context.Context, contentID string, mediaType string, tableName string, filter AccessFilter) *AudiobookSeriesGroup {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return nil
}
args := []any{contentID}
argIdx := 2
switch tableName {
case "audiobook_series", "ebook_series":
default:
return nil
}
args := []any{contentID, mediaType}
argIdx := 3
conditions := []string{
"root.content_id = $1",
"m.type = 'audiobook'",
"m.type = $2",
}
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
return nil
@@ -1232,13 +1298,13 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
COALESCE(m.poster_path, ''),
s.series_name,
s.series_index
FROM audiobook_series root
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
FROM %s root
JOIN %s s ON LOWER(s.series_name) = LOWER(root.series_name)
JOIN media_items m ON m.content_id = s.content_id
WHERE %s
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
LIMIT 30
`, strings.Join(conditions, " AND "))
`, tableName, tableName, strings.Join(conditions, " AND "))
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
if err != nil {
return nil
@@ -1266,7 +1332,7 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
item.SeriesIndex = &n
}
}
item.PosterURL = s.PresignURL(ctx, poster, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
entries = append(entries, item)
}
if len(entries) < 2 {
+60
View File
@@ -1,6 +1,8 @@
package catalog
import (
"context"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -40,3 +42,61 @@ func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
}
}
}
func TestPresignAudiobookPosterURLUsesPosterVariant(t *testing.T) {
resolver := &recordingCatalogImageResolver{}
detail := &DetailService{}
detail.SetImageResolver(resolver)
got := detail.presignAudiobookPosterURL(context.Background(), "local/audiobooks/book/poster/original.webp")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
type recordingCatalogImageResolver struct {
path string
variant string
}
func (r *recordingCatalogImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
func TestBuildEbookExtensionUsesAuthorsAndPublisherWithoutNarrators(t *testing.T) {
crew := []CrewCredit{
{Name: "Becky Chambers", Job: models.PersonKindAuthor.String(), PersonID: "10"},
{Name: "Narrator Should Not Apply", Job: models.PersonKindNarrator.String(), PersonID: "20"},
}
item := &models.MediaItem{
ContentID: "ebook-1",
Type: "ebook",
Studios: []string{"Tor"},
}
extension := (&DetailService{}).buildEbookExtension(nil, item, crew, AccessFilter{})
if extension == nil {
t.Fatal("buildEbookExtension returned nil")
}
if len(extension.Authors) != 1 || extension.Authors[0].Name != "Becky Chambers" {
t.Fatalf("Authors = %+v, want Becky Chambers", extension.Authors)
}
if extension.Publisher != "Tor" {
t.Fatalf("Publisher = %q, want Tor", extension.Publisher)
}
}
@@ -563,7 +563,6 @@ func episodeCatalogUserStateCTE(plan episodeCatalogUserStatePlan) string {
AND hhi.media_item_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = FALSE
AND uwp.position_seconds > 0%s
AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before)
)`, progressRatioGate)
+26
View File
@@ -638,6 +638,32 @@ func (r *EpisodeRepository) GetByIDs(ctx context.Context, contentIDs []string) (
return scanEpisodes(rows)
}
// HasFilesByIDs reports which of the given episodes are backed by at least one
// live (non-missing) media file. Episodes absent from the result map have no
// file — e.g. provider-metadata-only entries for unaired episodes.
func (r *EpisodeRepository) HasFilesByIDs(ctx context.Context, contentIDs []string) (map[string]bool, error) {
result := make(map[string]bool, len(contentIDs))
if len(contentIDs) == 0 {
return result, nil
}
query := `SELECT episode_id FROM media_files
WHERE episode_id = ANY($1) AND missing_since IS NULL
GROUP BY episode_id`
rows, err := r.pool.Query(ctx, query, contentIDs)
if err != nil {
return nil, fmt.Errorf("checking episode file presence: %w", err)
}
defer rows.Close()
for rows.Next() {
var episodeID string
if err := rows.Scan(&episodeID); err != nil {
return nil, fmt.Errorf("scanning episode file presence: %w", err)
}
result[episodeID] = true
}
return result, rows.Err()
}
// ListBySeries returns all episodes for a given series, ordered by season and
// episode number.
func (r *EpisodeRepository) ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error) {
+4 -3
View File
@@ -24,8 +24,9 @@ type BrowseFavoritesFilters struct {
AllowedLibraryIDs []int // nil = no allowlist, []int{} = empty result
DisabledLibraryIDs []int // user-disabled libraries to exclude
MaxContentRating string
SortField string // "added_at" (default), "title"/"sort_title", "year", "release_date"
SortOrder string // "asc" or "desc" (default desc)
ExcludedMediaTypes []string // media types the caller's surface never exposes
SortField string // "added_at" (default), "title"/"sort_title", "year", "release_date"
SortOrder string // "asc" or "desc" (default desc)
Limit int
Offset int
}
@@ -230,7 +231,7 @@ func buildBrowseFavoritesPlan(f BrowseFavoritesFilters) (browseFavoritesPlan, er
argIdx++
}
applyAccessFilter("mi", AccessFilter{MaxContentRating: f.MaxContentRating}, &conditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: f.MaxContentRating, ExcludedMediaTypes: f.ExcludedMediaTypes}, &conditions, &args, &argIdx)
orderBy := buildBrowseFavoritesOrderBy(f.SortField, f.SortOrder)
+143 -49
View File
@@ -35,6 +35,69 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
return &ItemRepository{pool: pool}
}
// GetPoster returns the current poster path and thumbhash for a media item.
// Missing or NULL values are returned as empty strings.
func (r *ItemRepository) GetPoster(ctx context.Context, contentID string) (posterPath string, posterThumbhash string, err error) {
if r == nil || r.pool == nil {
return "", "", ErrItemNotFound
}
if err := r.pool.QueryRow(ctx, `
SELECT COALESCE(poster_path, ''), COALESCE(poster_thumbhash, '')
FROM media_items
WHERE content_id = $1
`, contentID).Scan(&posterPath, &posterThumbhash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", ErrItemNotFound
}
return "", "", fmt.Errorf("getting media item poster: %w", err)
}
return posterPath, posterThumbhash, nil
}
// SetLocalPoster records a locally extracted poster without ever overwriting
// provider or manually applied artwork: the row is updated only when the item
// has no poster yet or its current poster lives under localPrefix. The
// condition is evaluated atomically in SQL so concurrent metadata writers
// (e.g. the ebook enrichment sweep) cannot be clobbered between a read and a
// write. Returns true when a row was updated.
func (r *ItemRepository) SetLocalPoster(ctx context.Context, contentID, posterPath, thumbhash, localPrefix string) (bool, error) {
if r == nil || r.pool == nil {
return false, ErrItemNotFound
}
tag, err := r.pool.Exec(ctx, `
UPDATE media_items
SET poster_path = $2,
poster_thumbhash = NULLIF($3, ''),
updated_at = NOW()
WHERE content_id = $1
AND (poster_path IS NULL OR poster_path = '' OR poster_path LIKE $4 || '%')
`, contentID, posterPath, thumbhash, localPrefix)
if err != nil {
return false, fmt.Errorf("setting local poster: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// GetPosterPath returns the current poster path for a media item. Missing or
// NULL poster values are returned as an empty string.
func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (string, error) {
if r == nil || r.pool == nil {
return "", ErrItemNotFound
}
var posterPath string
if err := r.pool.QueryRow(ctx, `
SELECT COALESCE(poster_path, '')
FROM media_items
WHERE content_id = $1
`, contentID).Scan(&posterPath); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrItemNotFound
}
return "", fmt.Errorf("getting media item poster path: %w", err)
}
return posterPath, nil
}
// overviewMatchFloor is the minimum ts_rank_cd score an overview-only match
// must achieve to be returned when no title FTS match exists in the candidate
// set. The overview tsvector is built without setweight(), so PostgreSQL's
@@ -45,60 +108,87 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
// incidental one-mention hits that flooded results before.
const overviewMatchFloor = 0.15
// itemColumnNames lists, in scan order, every column selected by media_items
// item queries. Shared by itemColumns, qualifiedItemColumns,
// qualifiedListItemColumns, and qualifiedItemColumnRefs so the select lists
// can never drift from each other or from scanItem.
var itemColumnNames = []string{
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
"show_status",
"matched_at", "last_refreshed", "refresh_failures",
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
}
// nullableStringItemColumns are media_items columns that may hold NULL but
// scan into plain (non-pointer) string fields on models.MediaItem, so select
// lists coalesce them to ”.
var nullableStringItemColumns = map[string]bool{
"poster_path": true,
"poster_thumbhash": true,
"backdrop_path": true,
"backdrop_thumbhash": true,
"logo_path": true,
"metadata_s3_path": true,
"metadata_etag": true,
}
// itemColumnExpr renders one select-list entry for col, qualified with alias
// when non-empty. Nullable string columns are coalesced to ” and aliased
// back to their own name so queries that wrap the select list in a CTE or
// subquery can still reference the column by name.
func itemColumnExpr(alias, col string) string {
qualified := col
if alias != "" {
qualified = alias + "." + col
}
if nullableStringItemColumns[col] {
return fmt.Sprintf("COALESCE(%s, '') AS %s", qualified, col)
}
return qualified
}
func joinItemColumns(alias string) string {
exprs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
exprs[i] = itemColumnExpr(alias, col)
}
return strings.Join(exprs, ", ")
}
// itemColumns is the list of columns returned by all SELECT queries on media_items.
const itemColumns = `content_id, type, title, sort_title, default_metadata_language, original_title, year, genres,
content_rating, runtime, overview, tagline,
rating_imdb, rating_tmdb, rating_rt_critic, rating_rt_audience,
imdb_id, tmdb_id, tvdb_id,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
metadata_s3_path, metadata_etag, season_count,
studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, air_timezone,
show_status,
matched_at, last_refreshed, refresh_failures,
episode_metadata_incomplete, episode_metadata_last_checked_at, locked_fields, status, created_at, updated_at`
var itemColumns = joinItemColumns("")
func qualifiedItemColumns(alias string) string {
cols := []string{
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
"show_status",
"matched_at", "last_refreshed", "refresh_failures",
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
return joinItemColumns(alias)
}
// qualifiedItemColumnRefs renders plain alias-qualified column references
// without COALESCE or AS aliases, for contexts like GROUP BY where output
// aliases are invalid.
func qualifiedItemColumnRefs(alias string) string {
refs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
refs[i] = alias + "." + col
}
prefixed := make([]string, len(cols))
for i, col := range cols {
prefixed[i] = alias + "." + col
}
return strings.Join(prefixed, ", ")
return strings.Join(refs, ", ")
}
func qualifiedListItemColumns(alias string) string {
cols := []string{
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
"show_status",
"matched_at", "last_refreshed", "refresh_failures",
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
}
prefixed := make([]string, len(cols))
for i, col := range cols {
exprs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
if col == "last_air_date" {
prefixed[i] = effectiveLastAirDateExpr(alias)
exprs[i] = effectiveLastAirDateExpr(alias) + " AS last_air_date"
continue
}
prefixed[i] = alias + "." + col
exprs[i] = itemColumnExpr(alias, col)
}
return strings.Join(prefixed, ", ")
return strings.Join(exprs, ", ")
}
// scanItem scans a single row into a *models.MediaItem.
@@ -573,9 +663,9 @@ func (r *ItemRepository) buildGetByIDsWithAccessSQL(contentIDs []string, access
argIdx++
}
// Apply MaxContentRating like applyAccessFilter does.
// Apply MaxContentRating and type exclusions like applyAccessFilter does.
var ratingConditions []string
applyAccessFilter("mi", AccessFilter{MaxContentRating: access.MaxContentRating}, &ratingConditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: access.MaxContentRating, ExcludedMediaTypes: access.ExcludedMediaTypes}, &ratingConditions, &args, &argIdx)
for _, c := range ratingConditions {
sql += " AND " + c
}
@@ -900,7 +990,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
if needsLibJoin {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
}
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating}, &conditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, &conditions, &args, &argIdx)
whereClause := "WHERE " + strings.Join(conditions, " AND ")
@@ -956,8 +1046,12 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
)
// Use qualified column names inside the CTE to avoid ambiguity when
// the FROM clause includes a JOIN to media_item_libraries.
// the FROM clause includes a JOIN to media_item_libraries. The select
// list aliases coalesced columns back to their own names (poster_path
// etc.) so the outer query can re-reference them; GROUP BY needs the
// raw references because output aliases are invalid there.
qualifiedCols := qualifiedItemColumns("mi")
groupByCols := qualifiedItemColumnRefs("mi")
scoredCTE := fmt.Sprintf(`
WITH scored AS (
SELECT
@@ -984,7 +1078,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
%s
GROUP BY %s
)
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, qualifiedCols)
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, groupByCols)
// COUNT(*) OVER () runs after the GROUP BY in the scored CTE collapses
// duplicates from the library JOIN, so the window count preserves the
@@ -1110,7 +1204,7 @@ func (r *ItemRepository) EnsureAccessible(ctx context.Context, contentID string,
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
}
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating}, &conditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, &conditions, &args, &argIdx)
query := fmt.Sprintf("SELECT 1 FROM %s WHERE %s LIMIT 1", fromClause, strings.Join(conditions, " AND "))
var found int
+57
View File
@@ -2,6 +2,7 @@ package catalog
import (
"fmt"
"sort"
"strings"
"testing"
)
@@ -367,3 +368,59 @@ func TestItemRepo_Search_NormalizesTsqueryInput(t *testing.T) {
t.Fatalf("overview arm should NOT wrap $1 in normalize_search_text; got:\n%s", sql)
}
}
// TestItemRepo_Search_ScoredCTEExposesItemColumnNames guards the contract
// between the scored CTE and the outer SELECT in buildSearchSQL. The CTE
// projects qualifiedItemColumns("mi") and the outer query re-selects those
// columns by name via itemColumns, so every entry must expose its own column
// name as the output name. Postgres names an unaliased expression like
// COALESCE(mi.poster_path, ”) "coalesce", which breaks the outer reference
// with SQLSTATE 42703 (column "poster_path" does not exist) — exactly how
// search returned 500s when the nullable-string COALESCE wrappers first
// landed without AS aliases.
func TestItemRepo_Search_ScoredCTEExposesItemColumnNames(t *testing.T) {
exposed := map[string]bool{}
for _, part := range splitTopLevelSQLCommas(qualifiedItemColumns("mi")) {
exposed[sqlOutputColumnName(part)] = true
}
var missing []string
for _, col := range itemColumnNames {
name := trailingSQLIdent(col)
if !exposed[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("qualifiedItemColumns(\"mi\") does not expose output columns %v; "+
"alias each expression back to its column name (e.g. COALESCE(mi.x, '') AS x) "+
"or the search CTE's outer SELECT fails with SQLSTATE 42703", missing)
}
}
// TestItemRepo_Search_GroupByHasNoOutputAliases asserts the scored CTE's
// GROUP BY uses raw column references: AS aliases are valid in a select list
// but are a syntax error inside GROUP BY, so the CTE must not reuse the
// aliased projection there.
func TestItemRepo_Search_GroupByHasNoOutputAliases(t *testing.T) {
repo := &ItemRepository{}
dataSQL, countSQL, _ := repo.buildSearchSQL("avatar", []string{"movie"}, 20, 0, AccessFilter{})
for _, sql := range []string{dataSQL, countSQL} {
idx := strings.Index(sql, "GROUP BY")
if idx < 0 {
t.Fatalf("expected GROUP BY in scored CTE; got:\n%s", sql)
}
clause := sql[idx:]
if end := strings.Index(clause, ")"); end >= 0 {
clause = clause[:end]
}
if strings.Contains(clause, " AS ") {
t.Fatalf("GROUP BY must not contain output aliases; got:\n%s", clause)
}
if strings.Contains(clause, "COALESCE") {
t.Fatalf("GROUP BY should group by raw columns, not COALESCE expressions; got:\n%s", clause)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
package catalog
import (
"net/url"
"reflect"
"strings"
"testing"
)
// TestMediaScopeItemTypes pins the expansion of group scopes: "video" covers
// the video-side media_items types so search/browse can offer a
// "Movies & Series vs Audiobooks" split without enumerating types per caller.
func TestMediaScopeItemTypes(t *testing.T) {
cases := []struct {
scope string
want []string
}{
{"", nil},
{"movie", []string{"movie"}},
{"audiobook", []string{"audiobook"}},
{"video", []string{"movie", "series"}},
{" Video ", []string{"movie", "series"}},
}
for _, tc := range cases {
if got := MediaScopeItemTypes(tc.scope); !reflect.DeepEqual(got, tc.want) {
t.Errorf("MediaScopeItemTypes(%q) = %v, want %v", tc.scope, got, tc.want)
}
}
}
func TestMediaScopeMatchesItemType(t *testing.T) {
cases := []struct {
scope string
itemType string
want bool
}{
{"", "audiobook", true},
{"video", "movie", true},
{"video", "series", true},
{"video", "audiobook", false},
{"audiobook", "audiobook", true},
{"movie", "series", false},
}
for _, tc := range cases {
if got := MediaScopeMatchesItemType(tc.scope, tc.itemType); got != tc.want {
t.Errorf("MediaScopeMatchesItemType(%q, %q) = %v, want %v", tc.scope, tc.itemType, got, tc.want)
}
}
}
// TestParseCatalogRequest_VideoMediaScope asserts ?type=video parses into the
// query definition's media scope, and that catalogSearchAccess expands it to
// the video item types for the direct search path.
func TestParseCatalogRequest_VideoMediaScope(t *testing.T) {
req, err := ParseCatalogRequest(url.Values{
"source": {"query"},
"q": {"the rookie"},
"type": {"video"},
})
if err != nil {
t.Fatalf("ParseCatalogRequest: %v", err)
}
if req.Query.MediaScope != "video" {
t.Fatalf("expected media scope video, got %q", req.Query.MediaScope)
}
_, itemTypes, earlyEmpty := catalogSearchAccess(req, AccessFilter{})
if earlyEmpty {
t.Fatal("unexpected early empty")
}
if !reflect.DeepEqual(itemTypes, []string{"movie", "series"}) {
t.Fatalf("expected video scope to expand to movie+series, got %v", itemTypes)
}
}
// TestPreviewPage_VideoScopeUsesTypeAny asserts the preview/query-executor
// path renders a multi-type condition for the video group scope.
func TestPreviewPage_VideoScopeUsesTypeAny(t *testing.T) {
sql, args, err := (&QueryExecutor{}).buildPreviewPageSQL(
QueryDefinition{
MediaScope: "video",
Sort: QuerySort{Field: "title", Order: "asc"},
},
AccessFilter{},
20,
0,
true,
)
if err != nil {
t.Fatalf("buildPreviewPageSQL error: %v", err)
}
if !strings.Contains(sql, "mi.type = ANY(") {
t.Fatalf("expected mi.type = ANY(...) for video scope, got %s", sql)
}
found := false
for _, arg := range args {
if types, ok := arg.([]string); ok && reflect.DeepEqual(types, []string{"movie", "series"}) {
found = true
}
}
if !found {
t.Fatalf("expected movie+series type arg, got %v", args)
}
}
// TestQueryDefinitionValidate_VideoScope asserts "video" passes definition
// validation alongside the single-type scopes.
func TestQueryDefinitionValidate_VideoScope(t *testing.T) {
def := QueryDefinition{MediaScope: "video"}
if err := def.Validate(); err != nil {
t.Fatalf("expected video media scope to validate, got %v", err)
}
bad := QueryDefinition{MediaScope: "podcast"}
if err := bad.Validate(); err == nil {
t.Fatal("expected invalid media scope to fail validation")
}
}
+2 -2
View File
@@ -142,7 +142,7 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
JOIN episodes e_ip ON e_ip.content_id = uwp_ip.media_item_id
WHERE uwp_ip.user_id = $1
AND uwp_ip.profile_id = $2
AND uwp_ip.completed = FALSE
AND uwp_ip.position_seconds > 0
AND e_ip.series_id = ce.series_id
AND uwp_ip.updated_at > ce.updated_at
)
@@ -257,7 +257,7 @@ func buildListResumableFirstEpisodesQuery(q NextUpQuery, inProgressIDs []string)
JOIN media_items si ON si.content_id = e.series_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = FALSE
AND uwp.position_seconds > 0
AND uwp.media_item_id = ANY($3)%s%s
ORDER BY e.series_id, uwp.updated_at DESC`, seriesFilter, completedSeriesGate)
+1 -1
View File
@@ -15,7 +15,7 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *
expectedFragments := []string{
"eligible_series AS (",
"uwp_ip.completed = FALSE",
"uwp_ip.position_seconds > 0",
"e_ip.series_id = ce.series_id",
"uwp_ip.updated_at > ce.updated_at",
"FROM user_history_hidden_items hhi",
+177 -12
View File
@@ -11,6 +11,12 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
// EbookFinishedProgressThresholdSQL is models.EbookFinishedProgressThreshold
// rendered as a SQL numeric literal. Queries that classify ebook reader
// progress as finished must interpolate this value instead of hardcoding the
// threshold so the definition of "finished" cannot drift between subsystems.
var EbookFinishedProgressThresholdSQL = strconv.FormatFloat(models.EbookFinishedProgressThreshold, 'f', -1, 64)
type QueryBuilder struct {
alias string
argIdx int
@@ -159,9 +165,10 @@ func (qb *QueryBuilder) UserHistoryCTEArgs() []any {
// UserHistoryCTESQL returns the user_last_watched CTE definition. The two
// placeholder positions (user_id, profile_id) are bound to the args returned
// by UserHistoryCTEArgs and should be the first two entries in the final
// statement's arg list. The CTE aggregates user_watch_history watch_at and
// user_watch_progress.completed=TRUE updated_at into one row per
// media_item_id, filtering out items the user has hidden via
// statement's arg list. The CTE aggregates user_watch_history watched_at,
// user_watch_progress.completed=TRUE updated_at, and completed ebook reader
// progress updated_at into one row per media_item_id, filtering out videos the
// user has hidden via
// user_history_hidden_items. The single GROUP BY replaces the two
// correlated MAX subqueries the previous lastWatchedExpr emitted (audit
// 2026-05-01 §3.1 Pattern B).
@@ -187,6 +194,10 @@ func UserHistoryCTESQL(argIdx int) string {
SELECT uwp.media_item_id, NULL::timestamptz, uwp.updated_at
FROM user_watch_progress uwp
WHERE uwp.user_id = $%d AND uwp.profile_id = $%d AND uwp.completed = TRUE
UNION ALL
SELECT erp.content_id AS media_item_id, NULL::timestamptz, erp.updated_at
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d AND erp.profile_id = $%d AND erp.progress >= %s
) src
WHERE NOT EXISTS (
SELECT 1 FROM user_history_hidden_items hhi
@@ -195,7 +206,7 @@ func UserHistoryCTESQL(argIdx int) string {
AND COALESCE(src.uwh_at, src.uwp_at) <= hhi.hidden_before
)
GROUP BY src.media_item_id
)`, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1)
)`, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1, EbookFinishedProgressThresholdSQL, argIdx, argIdx+1)
}
func (qb *QueryBuilder) BuildSortClause(sortConfig QuerySort) (string, []any, error) {
@@ -298,7 +309,8 @@ func (qb *QueryBuilder) BuildSortPlan(sortConfig QuerySort) (QuerySortPlan, erro
return plan, nil
case "series":
plan.Joins = []string{fmt.Sprintf(
"LEFT JOIN audiobook_series sort_series ON sort_series.content_id = %s.content_id",
"LEFT JOIN %s sort_series ON sort_series.content_id = %s.content_id",
qb.bookSeriesTable(),
qb.alias,
)}
// Sort by series name primarily, then by series_index so books
@@ -385,7 +397,7 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) {
case "narrator":
return qb.buildPersonClause(models.PersonKindNarrator, rule)
case "series":
return qb.buildAudiobookSeriesClause(rule)
return qb.buildBookSeriesClause(rule)
case "watched":
return qb.buildWatchedClause(rule)
case "favorited":
@@ -502,14 +514,21 @@ func (qb *QueryBuilder) personKindSortJoin(kind models.PersonKind, alias string)
)
}
func (qb *QueryBuilder) buildAudiobookSeriesClause(rule QueryRule) (string, error) {
func (qb *QueryBuilder) bookSeriesTable() string {
if qb.mediaScope == "ebook" {
return "ebook_series"
}
return "audiobook_series"
}
func (qb *QueryBuilder) buildBookSeriesClause(rule QueryRule) (string, error) {
qb.args = append(qb.args, rule.Value)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM audiobook_series s
FROM %s s
WHERE s.content_id = %s.content_id
AND LOWER(BTRIM(s.series_name)) = LOWER(BTRIM($%d))
)`, qb.alias, qb.argIdx)
)`, qb.bookSeriesTable(), qb.alias, qb.argIdx)
qb.argIdx++
if rule.Op == "is_not" {
return "NOT (" + clause + ")", nil
@@ -947,6 +966,9 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
if !ok {
return "", fmt.Errorf("in_progress requires a boolean value")
}
if qb.mediaScope == "ebook" {
return qb.buildEbookInProgressClause(value), nil
}
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
@@ -954,7 +976,6 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
WHERE uwp.user_id = $%d
AND uwp.profile_id = $%d
AND uwp.media_item_id = %s.content_id
AND uwp.completed = FALSE
AND uwp.position_seconds > 0
AND NOT EXISTS (
SELECT 1
@@ -972,6 +993,32 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
return clause, nil
}
func (qb *QueryBuilder) buildEbookInProgressClause(value bool) string {
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
AND erp.content_id = %s.content_id
AND erp.progress > 0
AND erp.progress < %s
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = $%d
AND hhi.profile_id = $%d
AND hhi.media_item_id = erp.content_id
AND erp.updated_at <= hhi.hidden_before
)
)`, qb.argIdx, qb.argIdx+1, qb.alias, EbookFinishedProgressThresholdSQL, qb.argIdx, qb.argIdx+1)
qb.argIdx += 2
if !value {
return "NOT (" + clause + ")"
}
return clause
}
func (qb *QueryBuilder) buildLastWatchedClause(rule QueryRule) (string, error) {
if err := qb.ensureUserScope(rule.Field); err != nil {
return "", err
@@ -1070,6 +1117,9 @@ func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) {
}
func (qb *QueryBuilder) userStateCompletionClause() string {
if qb.mediaScope == "ebook" {
return qb.ebookUserStateCompletionClause()
}
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`(
EXISTS (
@@ -1120,6 +1170,28 @@ func (qb *QueryBuilder) userStateCompletionClause() string {
return clause
}
func (qb *QueryBuilder) ebookUserStateCompletionClause() string {
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
AND erp.content_id = %s.content_id
AND erp.progress >= %s
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = $%d
AND hhi.profile_id = $%d
AND hhi.media_item_id = erp.content_id
AND erp.updated_at <= hhi.hidden_before
)
)`, qb.argIdx, qb.argIdx+1, qb.alias, EbookFinishedProgressThresholdSQL, qb.argIdx, qb.argIdx+1)
qb.argIdx += 2
return clause
}
// lastWatchedExpr returns the SQL expression the executor binds to the
// per-user history aggregate. It signals the executor (via
// requireUserHistoryCTE) that the user_last_watched CTE and a LEFT JOIN
@@ -1343,12 +1415,14 @@ func (qb *QueryBuilder) consumeIntArgs(values []int) ([]string, []any) {
}
func (qb *QueryBuilder) progressSortPlan() (string, []string, []any) {
if qb.mediaScope == "ebook" {
return qb.ebookProgressSortPlan()
}
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT uwp.media_item_id AS content_id,
CASE
WHEN uwp.completed = FALSE
AND uwp.position_seconds > 0
WHEN uwp.position_seconds > 0
AND COALESCE(uwp.duration_seconds, 0) > 0
AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before)
THEN uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0)
@@ -1371,7 +1445,39 @@ func (qb *QueryBuilder) progressSortPlan() (string, []string, []any) {
return "sort_progress.progress_ratio", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) ebookProgressSortPlan() (string, []string, []any) {
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT erp.content_id,
CASE
WHEN erp.progress > 0
AND erp.progress < %s
AND (hhi.media_item_id IS NULL OR erp.updated_at > hhi.hidden_before)
THEN erp.progress::double precision
ELSE NULL
END AS progress_ratio
FROM ebook_reader_progress erp
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $%d
AND hhi.profile_id = $%d
AND hhi.media_item_id = erp.content_id
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
) sort_progress ON sort_progress.content_id = %s.content_id`,
EbookFinishedProgressThresholdSQL,
qb.argIdx,
qb.argIdx+1,
qb.argIdx,
qb.argIdx+1,
qb.alias,
)
return "sort_progress.progress_ratio", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) dateViewedSortPlan() (string, []string, []any) {
if qb.mediaScope == "ebook" {
return qb.ebookDateViewedSortPlan()
}
historyJoin := fmt.Sprintf(
`LEFT JOIN (
SELECT uwh.media_item_id AS content_id, MAX(uwh.watched_at) AS viewed_at
@@ -1422,7 +1528,38 @@ func (qb *QueryBuilder) dateViewedSortPlan() (string, []string, []any) {
return expr, []string{historyJoin, progressJoin}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) ebookDateViewedSortPlan() (string, []string, []any) {
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT erp.content_id,
CASE
WHEN erp.progress >= %s
AND (hhi.media_item_id IS NULL OR erp.updated_at > hhi.hidden_before)
THEN erp.updated_at
ELSE NULL
END AS viewed_at
FROM ebook_reader_progress erp
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $%d
AND hhi.profile_id = $%d
AND hhi.media_item_id = erp.content_id
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
) sort_ebook_viewed ON sort_ebook_viewed.content_id = %s.content_id`,
EbookFinishedProgressThresholdSQL,
qb.argIdx,
qb.argIdx+1,
qb.argIdx,
qb.argIdx+1,
qb.alias,
)
return "sort_ebook_viewed.viewed_at", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) playsSortPlan() (string, []string, []any) {
if qb.mediaScope == "ebook" {
return qb.ebookPlaysSortPlan()
}
historyJoin := fmt.Sprintf(
`LEFT JOIN (
SELECT uwh.media_item_id AS content_id, COUNT(*) AS play_count
@@ -1473,6 +1610,34 @@ func (qb *QueryBuilder) playsSortPlan() (string, []string, []any) {
return expr, []string{historyJoin, progressJoin}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) ebookPlaysSortPlan() (string, []string, []any) {
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT erp.content_id,
CASE
WHEN erp.progress >= %s
AND (hhi.media_item_id IS NULL OR erp.updated_at > hhi.hidden_before)
THEN 1
ELSE 0
END AS completed_play_count
FROM ebook_reader_progress erp
LEFT JOIN user_history_hidden_items hhi
ON hhi.user_id = $%d
AND hhi.profile_id = $%d
AND hhi.media_item_id = erp.content_id
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
) sort_ebook_plays ON sort_ebook_plays.content_id = %s.content_id`,
EbookFinishedProgressThresholdSQL,
qb.argIdx,
qb.argIdx+1,
qb.argIdx,
qb.argIdx+1,
qb.alias,
)
return "NULLIF(sort_ebook_plays.completed_play_count, 0)", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) lastAirDateSortPlan() (string, []string) {
expr := fmt.Sprintf("%s.last_air_date_at", qb.alias)
return expr, nil // denormalized column — no JOIN required
+203 -1
View File
@@ -344,7 +344,7 @@ func TestBuildSortClause_PersonalizedComputedSorts(t *testing.T) {
}{
{
field: "progress",
expectJoin: []string{"FROM user_watch_progress uwp", "uwp.completed = FALSE", "uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0)"},
expectJoin: []string{"FROM user_watch_progress uwp", "uwp.position_seconds > 0", "uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0)"},
expectExpr: []string{"sort_progress.progress_ratio"},
expectArgN: 2,
},
@@ -390,6 +390,161 @@ func TestBuildSortClause_PersonalizedComputedSorts(t *testing.T) {
}
}
func TestBuildSortClause_EbookPersonalizedComputedSortsUseReaderProgress(t *testing.T) {
builder := NewQueryBuilder("mi").WithMediaScope("ebook").WithUserScope(42, "profile-1")
tests := []struct {
field string
expectJoin []string
expectExpr []string
}{
{
field: "progress",
expectJoin: []string{
"FROM ebook_reader_progress erp",
"erp.progress > 0",
"erp.progress < 0.9",
"LEFT JOIN user_history_hidden_items hhi",
"hhi.media_item_id IS NULL OR erp.updated_at > hhi.hidden_before",
},
expectExpr: []string{"sort_progress.progress_ratio"},
},
{
field: "date_viewed",
expectJoin: []string{
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
"LEFT JOIN user_history_hidden_items hhi",
"hhi.media_item_id IS NULL OR erp.updated_at > hhi.hidden_before",
},
expectExpr: []string{"sort_ebook_viewed.viewed_at"},
},
{
field: "plays",
expectJoin: []string{
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
"completed_play_count",
"LEFT JOIN user_history_hidden_items hhi",
"hhi.media_item_id IS NULL OR erp.updated_at > hhi.hidden_before",
},
expectExpr: []string{"sort_ebook_plays.completed_play_count"},
},
}
for _, tt := range tests {
plan, err := builder.BuildSortPlan(QuerySort{Field: tt.field, Order: "desc"})
if err != nil {
t.Fatalf("%s: BuildSortPlan returned error: %v", tt.field, err)
}
if len(plan.Args) != 2 || plan.Args[0] != 42 || plan.Args[1] != "profile-1" {
t.Fatalf("%s: expected user scope args, got %v", tt.field, plan.Args)
}
joined := strings.Join(plan.Joins, "\n")
for _, fragment := range tt.expectJoin {
if !strings.Contains(joined, fragment) {
t.Fatalf("%s: expected joins to contain %q, got %q", tt.field, fragment, joined)
}
}
for _, fragment := range tt.expectExpr {
if !strings.Contains(plan.OrderBy, fragment) {
t.Fatalf("%s: expected order by to contain %q, got %q", tt.field, fragment, plan.OrderBy)
}
}
}
}
func TestBuild_EbookUserStateRulesUseReaderProgress(t *testing.T) {
tests := []struct {
name string
rule QueryRule
expectSQL []string
unexpectSQL []string
}{
{
name: "watched",
rule: QueryRule{Field: "watched", Op: "is", Value: true},
expectSQL: []string{
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
"FROM user_history_hidden_items hhi",
"hhi.media_item_id = erp.content_id",
"erp.updated_at <= hhi.hidden_before",
},
unexpectSQL: []string{"FROM user_watch_progress uwp", "FROM user_watch_history uwh"},
},
{
name: "unread",
rule: QueryRule{Field: "watched", Op: "is", Value: false},
expectSQL: []string{
"NOT (EXISTS",
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
"FROM user_history_hidden_items hhi",
"hhi.media_item_id = erp.content_id",
"erp.updated_at <= hhi.hidden_before",
},
unexpectSQL: []string{"FROM user_watch_progress uwp", "FROM user_watch_history uwh"},
},
{
name: "in progress",
rule: QueryRule{Field: "in_progress", Op: "is", Value: true},
expectSQL: []string{
"FROM ebook_reader_progress erp",
"erp.progress > 0",
"erp.progress < 0.9",
"FROM user_history_hidden_items hhi",
"hhi.media_item_id = erp.content_id",
"erp.updated_at <= hhi.hidden_before",
},
unexpectSQL: []string{"FROM user_watch_progress uwp"},
},
{
name: "not in progress",
rule: QueryRule{Field: "in_progress", Op: "is", Value: false},
expectSQL: []string{
"NOT (EXISTS",
"FROM ebook_reader_progress erp",
"erp.progress > 0",
"erp.progress < 0.9",
"FROM user_history_hidden_items hhi",
"hhi.media_item_id = erp.content_id",
"erp.updated_at <= hhi.hidden_before",
},
unexpectSQL: []string{"FROM user_watch_progress uwp"},
},
}
for _, tt := range tests {
clause, args, err := NewQueryBuilder("mi").
WithMediaScope("ebook").
WithUserScope(42, "profile-1").
Build(QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{{
Match: "all",
Rules: []QueryRule{tt.rule},
}},
})
if err != nil {
t.Fatalf("%s: Build returned error: %v", tt.name, err)
}
if len(args) != 2 || args[0] != 42 || args[1] != "profile-1" {
t.Fatalf("%s: expected user scope args, got %v", tt.name, args)
}
for _, fragment := range tt.expectSQL {
if !strings.Contains(clause, fragment) {
t.Fatalf("%s: expected clause to contain %q, got %q", tt.name, fragment, clause)
}
}
for _, fragment := range tt.unexpectSQL {
if strings.Contains(clause, fragment) {
t.Fatalf("%s: did not expect clause to contain %q, got %q", tt.name, fragment, clause)
}
}
}
}
func TestBuild_ResolutionNormalizes4KAndScopesMediaFiles(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").
WithLibraryScope([]int{3}).
@@ -531,6 +686,26 @@ func TestBuild_SeriesClauseJoinsAudiobookSeries(t *testing.T) {
}
}
func TestBuild_SeriesClauseJoinsEbookSeriesForEbookScope(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").
WithMediaScope("ebook").
Build(QueryDefinition{
Match: "all",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "series", Op: "is", Value: "Wayfarers"}}},
},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if len(args) != 1 || args[0] != "Wayfarers" {
t.Fatalf("expected series arg, got %v", args)
}
if !strings.Contains(clause, "FROM ebook_series s") {
t.Fatalf("expected ebook_series join in series clause, got %q", clause)
}
}
func TestBuildSortPlan_AuthorJoinsLateralOnPersonKindAuthor(t *testing.T) {
plan, err := NewQueryBuilder("mi").BuildSortPlan(QuerySort{Field: "author", Order: "asc"})
if err != nil {
@@ -576,6 +751,18 @@ func TestBuildSortPlan_SeriesOrdersByNameThenIndex(t *testing.T) {
}
}
func TestBuildSortPlan_SeriesUsesEbookSeriesForEbookScope(t *testing.T) {
plan, err := NewQueryBuilder("mi").
WithMediaScope("ebook").
BuildSortPlan(QuerySort{Field: "series", Order: "asc"})
if err != nil {
t.Fatalf("BuildSortPlan(series) returned error: %v", err)
}
if len(plan.Joins) != 1 || !strings.Contains(plan.Joins[0], "ebook_series sort_series") {
t.Fatalf("expected LEFT JOIN ebook_series, got %v", plan.Joins)
}
}
func TestBuild_SeriesClauseNegationWrapsInNOT(t *testing.T) {
clause, _, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
@@ -713,3 +900,18 @@ func TestQueryBuilder_LastWatched_RequiresCTE(t *testing.T) {
t.Fatalf("after lastWatchedExpr, builder should require CTE")
}
}
func TestUserHistoryCTESQLIncludesCompletedEbookReaderProgress(t *testing.T) {
sql := UserHistoryCTESQL(1)
expectedFragments := []string{
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
"erp.content_id",
"erp.updated_at",
}
for _, fragment := range expectedFragments {
if !strings.Contains(sql, fragment) {
t.Fatalf("expected CTE to contain %q, got:\n%s", fragment, sql)
}
}
}
+55 -6
View File
@@ -104,6 +104,53 @@ type QueryDefinition struct {
Limit *int `json:"limit,omitempty"`
}
// MediaScopeVideo is the group scope covering all video-side item types. It
// is accepted anywhere a single-type media scope is and expands to the
// underlying media_items.type values via MediaScopeItemTypes.
const MediaScopeVideo = "video"
// IsValidMediaScope reports whether scope (already normalized to lowercase)
// is an accepted media_scope value. Empty means unscoped and is valid.
func IsValidMediaScope(scope string) bool {
switch scope {
case "", "movie", "series", "episode", "audiobook", "ebook", MediaScopeVideo:
return true
default:
return false
}
}
// MediaScopeItemTypes expands a media scope into the media_items.type values
// it covers. Single-type scopes map to themselves; the empty scope returns
// nil (unscoped).
func MediaScopeItemTypes(scope string) []string {
scope = strings.ToLower(strings.TrimSpace(scope))
switch scope {
case "":
return nil
case MediaScopeVideo:
return []string{"movie", "series"}
default:
return []string{scope}
}
}
// MediaScopeMatchesItemType reports whether an item type falls inside scope.
// An empty scope matches every type.
func MediaScopeMatchesItemType(scope, itemType string) bool {
types := MediaScopeItemTypes(scope)
if len(types) == 0 {
return true
}
itemType = strings.ToLower(strings.TrimSpace(itemType))
for _, t := range types {
if t == itemType {
return true
}
}
return false
}
const (
DefaultSmartCollectionItemLimit = 100
MaxSmartCollectionItemLimit = 500
@@ -182,12 +229,8 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
}
}
if normalized.MediaScope != "" &&
normalized.MediaScope != "movie" &&
normalized.MediaScope != "series" &&
normalized.MediaScope != "episode" &&
normalized.MediaScope != "audiobook" {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
if !IsValidMediaScope(normalized.MediaScope) {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', or 'video'")
}
if normalized.Match != "all" && normalized.Match != "any" {
@@ -206,6 +249,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
if def.personalized && !allowPersonalizedFields {
return fmt.Errorf("groups[%d].rules[%d].field %q requires profile scope", i, j, rule.Field)
}
if normalized.MediaScope == "ebook" && rule.Field == "narrator" {
return fmt.Errorf("groups[%d].rules[%d].field %q is not supported for ebook media_scope", i, j, rule.Field)
}
if !def.validOps[rule.Op] {
return fmt.Errorf("groups[%d].rules[%d].op %q is not supported for field %q", i, j, rule.Op, rule.Field)
}
@@ -220,6 +266,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
if def.personalized && !allowPersonalizedSorts {
return fmt.Errorf("sort.field %q requires profile scope", normalized.Sort.Field)
}
if normalized.MediaScope == "ebook" && normalized.Sort.Field == "narrator" {
return fmt.Errorf("sort.field %q is not supported for ebook media_scope", normalized.Sort.Field)
}
}
if normalized.Sort.Order != "" && normalized.Sort.Order != "asc" && normalized.Sort.Order != "desc" {
return fmt.Errorf("sort.order must be 'asc' or 'desc'")
+39
View File
@@ -72,6 +72,45 @@ func TestValidate_AudiobookMediaScope(t *testing.T) {
}
}
func TestValidate_EbookMediaScope(t *testing.T) {
qd := QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{},
Sort: QuerySort{Field: "title", Order: "asc"},
}
if err := qd.Validate(); err != nil {
t.Fatalf("expected ebook media scope to be valid, got %v", err)
}
}
func TestValidate_EbookMediaScopeRejectsNarratorRule(t *testing.T) {
qd := QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{{
Match: "all",
Rules: []QueryRule{{Field: "narrator", Op: "is", Value: "Narrator"}},
}},
Sort: QuerySort{Field: "title", Order: "asc"},
}
if err := qd.Validate(); err == nil {
t.Fatal("expected ebook narrator rule to be rejected")
}
}
func TestValidate_EbookMediaScopeRejectsNarratorSort(t *testing.T) {
qd := QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{},
Sort: QuerySort{Field: "narrator", Order: "asc"},
}
if err := qd.Validate(); err == nil {
t.Fatal("expected ebook narrator sort to be rejected")
}
}
func TestValidateWithSortScope_RejectsPersonalizedSortWithoutProfileScope(t *testing.T) {
qd := QueryDefinition{
Match: "all",
+8 -2
View File
@@ -282,8 +282,14 @@ func (e *QueryExecutor) buildPreviewPagePlan(
conditions = append(conditions, filterWhere)
}
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
args = append(args, def.MediaScope)
scopeTypes := MediaScopeItemTypes(def.MediaScope)
if len(scopeTypes) == 1 {
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
args = append(args, scopeTypes[0])
} else {
conditions = append(conditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
args = append(args, scopeTypes)
}
argIdx++
}
libScopeWhere, libScopeArgs, hasLibraryScope := "", []any(nil), false
@@ -0,0 +1,80 @@
// Package webtransport preserves the github.com/zishang520/webtransport-go
// API shape expected by github.com/zishang520/engine.io while delegating to
// the maintained github.com/quic-go/webtransport-go module.
package webtransport
import (
"net"
"net/http"
"time"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
upstream "github.com/quic-go/webtransport-go"
)
type (
Session = upstream.Session
SessionState = upstream.SessionState
SessionError = upstream.SessionError
SessionErrorCode = upstream.SessionErrorCode
Stream = upstream.Stream
ReceiveStream = upstream.ReceiveStream
SendStream = upstream.SendStream
StreamError = upstream.StreamError
StreamErrorCode = upstream.StreamErrorCode
Dialer = upstream.Dialer
)
const (
WTBufferedStreamRejectedErrorCode = upstream.WTBufferedStreamRejectedErrorCode
WTSessionGoneErrorCode = upstream.WTSessionGoneErrorCode
)
// Server matches the older zishang520/webtransport-go shape where H3 was a
// value field. The upstream module changed it to *http3.Server in v0.10.0.
type Server struct {
H3 http3.Server
ApplicationProtocols []string
ReorderingTimeout time.Duration
CheckOrigin func(r *http.Request) bool
upstream *upstream.Server
}
func (s *Server) delegate() *upstream.Server {
if s.upstream == nil {
s.upstream = &upstream.Server{H3: &s.H3}
}
s.upstream.ApplicationProtocols = s.ApplicationProtocols
s.upstream.ReorderingTimeout = s.ReorderingTimeout
s.upstream.CheckOrigin = s.CheckOrigin
return s.upstream
}
func (s *Server) Serve(conn net.PacketConn) error {
return s.delegate().Serve(conn)
}
func (s *Server) ServeQUICConn(conn *quic.Conn) error {
return s.delegate().ServeQUICConn(conn)
}
func (s *Server) ListenAndServe() error {
return s.delegate().ListenAndServe()
}
func (s *Server) ListenAndServeTLS(certFile, keyFile string) error {
return s.delegate().ListenAndServeTLS(certFile, keyFile)
}
func (s *Server) Close() error {
if s.upstream != nil {
return s.upstream.Close()
}
return s.H3.Close()
}
func (s *Server) Upgrade(w http.ResponseWriter, r *http.Request) (*Session, error) {
return s.delegate().Upgrade(w, r)
}
@@ -0,0 +1,17 @@
module github.com/zishang520/webtransport-go
go 1.26.4
require (
github.com/quic-go/quic-go v0.60.0
github.com/quic-go/webtransport-go v0.10.0
)
require (
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3 -3
View File
@@ -355,9 +355,9 @@ func LoadFromDB(m map[string]string) (*Config, error) {
}
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
// listener off unless ABS compatibility is explicitly enabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
// AudiobookshelfCompat — dedicated listener for ABS client apps. Docker
// deployments publish :13378, so listen by default unless disabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", true)
if err != nil {
return nil, err
}
+9 -1
View File
@@ -35,7 +35,15 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
}
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
cfg, err := LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
if cfg.AudiobookshelfCompat.Listen != ":13378" {
t.Fatalf("default audiobooks listener = %q, want default :13378", cfg.AudiobookshelfCompat.Listen)
}
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
+765
View File
@@ -0,0 +1,765 @@
package ebooks
// Enricher periodically enriches ebook media_items that are missing metadata
// by querying the configured metadata-provider chain for each item's library
// folder.
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
)
const (
ebookMetadataImageProviderID = "ebook-metadata"
defaultEnrichBatchSize = 50
defaultEnrichWorkers = 4
// enrichFailureCap is the ebook_enrichment_state.failures count at which
// an ebook stops being claimed for enrichment. Combined with the
// failure-count-first claim ordering this prevents a head-of-line block
// of permanently failing items from starving newer items and hammering
// providers.
enrichFailureCap = 5
)
// errEnrichmentSkipped marks an item that could not be attempted at all (no
// library folder linked yet, no providers configured). Skipped items are
// neither stamped as refreshed nor counted against the failure cap, so they
// are retried on every sweep until the missing prerequisite appears.
var errEnrichmentSkipped = errors.New("ebook enrichment skipped")
func ebookContentType() string {
return "ebook"
}
func ebookEnrichWorkers() int {
n := defaultEnrichWorkers
if v := os.Getenv("SILO_EBOOK_ENRICH_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n > defaultEnrichBatchSize {
n = defaultEnrichBatchSize
}
return n
}
type enrichmentItemRow struct {
ContentID string
Title string
Year int
FolderID int
Language string
Author string
ProviderIDs map[string]string
}
// Enricher drives the ebook metadata enrichment sweep.
type Enricher struct {
pool *pgxpool.Pool
chainRepo *metadata.ChainRepository
resolver *metadata.PluginResolverAdapter
itemRepo *catalog.ItemRepository
personRepo *catalog.PersonRepository
providerIDs *catalog.ProviderIDRepository
imageCacher metadata.ImageCacher
batchSize int
workers int
}
func NewEnricher(
pool *pgxpool.Pool,
chainRepo *metadata.ChainRepository,
resolver *metadata.PluginResolverAdapter,
itemRepo *catalog.ItemRepository,
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
) *Enricher {
return &Enricher{
pool: pool,
chainRepo: chainRepo,
resolver: resolver,
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
batchSize: defaultEnrichBatchSize,
workers: ebookEnrichWorkers(),
}
}
func (e *Enricher) SetImageCacher(cacher metadata.ImageCacher) {
if e == nil {
return
}
e.imageCacher = cacher
}
func (e *Enricher) Run(ctx context.Context) (int, error) {
if e == nil || e.pool == nil || e.chainRepo == nil {
return 0, nil
}
items, err := e.claimBatch(ctx)
if err != nil {
return 0, fmt.Errorf("ebook enrichment: claim batch: %w", err)
}
if len(items) == 0 {
return 0, nil
}
slog.Info("ebook enrichment: sweep started",
"count", len(items),
"workers", e.workers,
)
enriched := e.runBatch(ctx, items, e.enrichItem, e.recordEnrichFailure)
slog.Info("ebook enrichment: sweep complete",
"attempted", len(items),
"enriched", enriched,
)
return enriched, nil
}
func (e *Enricher) runBatch(
ctx context.Context,
items []enrichmentItemRow,
enrichFn func(context.Context, enrichmentItemRow) error,
recordFailure func(context.Context, enrichmentItemRow),
) int {
workers := e.workers
if workers <= 0 {
workers = 1
}
if workers > len(items) {
workers = len(items)
}
ch := make(chan enrichmentItemRow, workers)
var (
wg sync.WaitGroup
enriched int64
)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range ch {
if ctx.Err() != nil {
continue
}
if err := enrichFn(ctx, item); err != nil {
if errors.Is(err, errEnrichmentSkipped) {
slog.Debug("ebook enrichment: item skipped",
"content_id", item.ContentID,
"title", item.Title,
"reason", err,
)
continue
}
slog.Warn("ebook enrichment: item failed",
"content_id", item.ContentID,
"title", item.Title,
"error", err,
)
// A cancelled sweep says nothing about the item itself,
// so it does not count against the failure cap.
if recordFailure != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
recordFailure(ctx, item)
}
continue
}
atomic.AddInt64(&enriched, 1)
}
}()
}
for _, item := range items {
if ctx.Err() != nil {
break
}
ch <- item
}
close(ch)
wg.Wait()
return int(enriched)
}
// claimBatchQuery selects unenriched ebooks. Items with fewer prior failures
// are claimed first and items at/above enrichFailureCap are skipped entirely,
// so a block of permanently failing items cannot occupy every sweep.
const claimBatchQuery = `
SELECT
mi.content_id,
mi.title,
mi.year,
COALESCE(mil.media_folder_id, 0) AS folder_id,
COALESCE(mf.metadata_language, 'en') AS language,
COALESCE(
(SELECT p.name
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = mi.content_id
AND ip.kind = 7
ORDER BY ip.sort_order, ip.id
LIMIT 1),
''
) AS author
FROM media_items mi
LEFT JOIN media_item_libraries mil ON mil.content_id = mi.content_id
LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id
LEFT JOIN ebook_enrichment_state ees ON ees.content_id = mi.content_id
WHERE mi.type = 'ebook'
AND (mi.poster_path IS NULL OR mi.poster_path = '')
AND mi.last_refreshed IS NULL
AND COALESCE(ees.failures, 0) < $2
ORDER BY COALESCE(ees.failures, 0) ASC, mi.created_at ASC
LIMIT $1
`
func (e *Enricher) claimBatch(ctx context.Context) ([]enrichmentItemRow, error) {
rows, err := e.pool.Query(ctx, claimBatchQuery, e.batchSize, enrichFailureCap)
if err != nil {
return nil, fmt.Errorf("querying unenriched ebooks: %w", err)
}
defer rows.Close()
var items []enrichmentItemRow
seen := make(map[string]struct{})
for rows.Next() {
var item enrichmentItemRow
if err := rows.Scan(
&item.ContentID,
&item.Title,
&item.Year,
&item.FolderID,
&item.Language,
&item.Author,
); err != nil {
return nil, fmt.Errorf("scanning ebook enrichment row: %w", err)
}
if _, dup := seen[item.ContentID]; dup {
continue
}
seen[item.ContentID] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating ebook enrichment rows: %w", err)
}
if e.providerIDs != nil {
for i := range items {
pids, err := e.providerIDs.GetByContentID(ctx, items[i].ContentID)
if err == nil {
items[i].ProviderIDs = providerIDMapFromRows(pids)
}
}
}
return items, nil
}
func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error {
if item.FolderID == 0 {
// The scanner inserts the library membership after the item upsert, so
// a freshly indexed ebook can be claimed inside that window. Skip it:
// stamping here would terminally mark the item refreshed before any
// provider ever saw it.
return fmt.Errorf("%w: item %s has no library folder yet", errEnrichmentSkipped, item.ContentID)
}
providers, err := metadata.ResolveChain(ctx, item.FolderID, ebookContentType(), e.chainRepo, e.resolver)
if err != nil {
return fmt.Errorf("resolving ebook chain for folder %d: %w", item.FolderID, err)
}
return e.enrichWithProviders(ctx, item, providers)
}
// enrichWithProviders runs the provider chain for one claimed item. Outcomes:
// - metadata obtained: persist it and stamp last_refreshed (nil error);
// - providers answered but nothing matched: stamp last_refreshed so the
// item is not re-claimed every sweep (nil error);
// - one or more providers errored and no metadata was obtained: return an
// error so the failure cap/backoff engages, without stamping;
// - no providers configured: skip (no stamp, no failure) so the item is
// retried once a chain exists.
func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemRow, providers []metadata.Provider) error {
if len(providers) == 0 {
return fmt.Errorf("%w: no metadata providers configured for folder %d", errEnrichmentSkipped, item.FolderID)
}
accumulator, accumulatedIDs, providerErrs := collectEbookMetadata(ctx, item, providers)
if !accumulator.HasMetadata && accumulator.PosterPath == "" && accumulator.Overview == "" {
if err := ctx.Err(); err != nil {
// A cancelled sweep says nothing about the item or the providers.
return err
}
if len(providerErrs) > 0 {
// Transient provider trouble must not stamp the item terminally;
// surfacing an error engages the failure cap and backoff instead.
return fmt.Errorf("no metadata obtained, %d provider error(s): %w",
len(providerErrs), errors.Join(providerErrs...))
}
slog.Info("ebook enrichment: no metadata found",
"content_id", item.ContentID,
"title", item.Title,
)
return e.stampLastRefreshed(ctx, item.ContentID)
}
e.cacheRemotePoster(ctx, item.ContentID, accumulator)
if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil {
return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err)
}
slog.Info("ebook enrichment: enriched",
"content_id", item.ContentID,
"title", item.Title,
"poster", accumulator.PosterPath != "",
"overview", accumulator.Overview != "",
"people", len(filterEbookPeople(accumulator.People)),
)
return nil
}
// collectEbookMetadata queries every provider in the chain and accumulates
// IDs and metadata. Individual provider failures are collected (not fatal) so
// the caller can distinguish "providers answered, no match" from "providers
// were unreachable".
func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers []metadata.Provider) (*metadata.MetadataResult, map[string]string, []error) {
searchQuery, accumulatedIDs := buildEbookSearchQuery(item)
var providerErrs []error
for _, p := range providers {
sp, ok := p.(metadata.SearchProvider)
if !ok {
continue
}
results, searchErr := sp.Search(ctx, searchQuery)
if searchErr != nil {
slog.Warn("ebook enrichment: search error",
"provider", p.Slug(),
"content_id", item.ContentID,
"error", searchErr,
)
providerErrs = append(providerErrs, fmt.Errorf("%s search: %w", p.Slug(), searchErr))
continue
}
if len(results) == 0 {
continue
}
for k, v := range results[0].ProviderIDs {
if v != "" {
if _, exists := accumulatedIDs[k]; !exists {
accumulatedIDs[k] = v
}
}
}
slog.Debug("ebook enrichment: search result",
"provider", p.Slug(),
"content_id", item.ContentID,
"matched_ids", accumulatedIDs,
)
}
accumulator := &metadata.MetadataResult{
ProviderIDs: accumulatedIDs,
}
for _, p := range providers {
mp, ok := p.(metadata.MetadataProvider)
if !ok {
continue
}
result, getErr := mp.GetMetadata(ctx, buildEbookMetadataRequest(accumulator.ProviderIDs, item.Language))
if getErr != nil {
slog.Warn("ebook enrichment: GetMetadata error",
"provider", p.Slug(),
"content_id", item.ContentID,
"error", getErr,
)
providerErrs = append(providerErrs, fmt.Errorf("%s metadata: %w", p.Slug(), getErr))
continue
}
if result == nil || !result.HasMetadata {
continue
}
mergeEnrichmentProviderIDs(accumulator, result)
metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty)
slog.Debug("ebook enrichment: metadata received",
"provider", p.Slug(),
"content_id", item.ContentID,
"has_poster", result.PosterPath != "",
"has_overview", result.Overview != "",
)
}
return accumulator, accumulator.ProviderIDs, providerErrs
}
func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, result *metadata.MetadataResult) {
if e == nil || result == nil || result.PosterPath == "" {
return
}
if !strings.HasPrefix(result.PosterPath, "http://") && !strings.HasPrefix(result.PosterPath, "https://") {
return
}
if isNilImageCacher(e.imageCacher) {
return
}
cached, err := e.imageCacher.CacheImage(ctx, metadata.CacheImageRequest{
SourceURL: result.PosterPath,
ProviderID: ebookMetadataImageProviderID,
ContentType: "ebooks",
ContentID: contentID,
ImageType: metadata.ImagePoster,
})
if err != nil {
slog.Warn("ebook enrichment: poster cache failed, keeping provider URL",
"content_id", contentID,
"url", result.PosterPath,
"error", err,
)
return
}
if cached == nil {
slog.Warn("ebook enrichment: poster cache returned no result, keeping provider URL",
"content_id", contentID,
"url", result.PosterPath,
)
return
}
if storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext); storedPath != "" {
result.PosterPath = storedPath
}
if cached.Thumbhash != "" {
result.PosterThumbhash = cached.Thumbhash
}
}
func isNilImageCacher(cacher metadata.ImageCacher) bool {
if cacher == nil {
return true
}
value := reflect.ValueOf(cacher)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func cachedOriginalImagePath(basePath, ext string) string {
if basePath == "" {
return ""
}
if strings.Contains(basePath, "/original.") {
return basePath
}
if ext == "" {
ext = ".jpg"
}
return strings.TrimRight(basePath, "/") + "/original" + ext
}
func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error {
upd := &catalog.MetadataUpdate{}
if result.PosterPath != "" {
upd.PosterPath = &result.PosterPath
}
if result.PosterThumbhash != "" {
upd.PosterThumbhash = &result.PosterThumbhash
}
if result.BackdropPath != "" {
upd.BackdropPath = &result.BackdropPath
}
if result.BackdropThumbhash != "" {
upd.BackdropThumbhash = &result.BackdropThumbhash
}
if result.LogoPath != "" {
upd.LogoPath = &result.LogoPath
}
if result.Overview != "" {
upd.Overview = &result.Overview
}
if result.Tagline != "" {
upd.Tagline = &result.Tagline
}
if result.ReleaseDate != "" {
upd.ReleaseDate = &result.ReleaseDate
}
if len(result.Genres) > 0 {
genres := append([]string(nil), result.Genres...)
upd.Genres = &genres
}
if len(result.Studios) > 0 {
studios := append([]string(nil), result.Studios...)
upd.Studios = &studios
}
if result.ContentRating != "" {
upd.ContentRating = &result.ContentRating
}
if result.Runtime > 0 {
upd.Runtime = &result.Runtime
}
if result.Year > 0 {
upd.Year = &result.Year
}
providerIDs = filterEbookProviderIDs(providerIDs)
if e.providerIDs != nil && len(providerIDs) > 0 {
if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
slog.Warn("ebook enrichment: failed to persist provider IDs",
"content_id", contentID,
"error", err,
)
}
}
if err := e.updateMetadataAndTimestamps(ctx, contentID, upd); err != nil {
return err
}
authors := filterEbookPeople(result.People)
if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil {
if err := e.persistPeople(ctx, contentID, authors); err != nil {
slog.Warn("ebook enrichment: failed to persist people",
"content_id", contentID,
"error", err,
)
}
}
return nil
}
func (e *Enricher) updateMetadataAndTimestamps(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error {
if e.itemRepo == nil {
return nil
}
if err := e.itemRepo.UpdateMetadata(ctx, contentID, upd); err != nil {
return fmt.Errorf("UpdateMetadata: %w", err)
}
return e.stampLastRefreshed(ctx, contentID)
}
func (e *Enricher) stampLastRefreshed(ctx context.Context, contentID string) error {
if e.pool == nil {
return nil
}
now := time.Now().UTC()
if _, err := e.pool.Exec(ctx, `
UPDATE media_items
SET last_refreshed = $1,
matched_at = COALESCE(matched_at, $1),
status = CASE WHEN status = 'pending' THEN 'matched' ELSE status END
WHERE content_id = $2
`, now, contentID); err != nil {
return err
}
// Success clears the enrichment failure backlog. media_items.refresh_failures
// is intentionally left alone: it belongs to the metadata refresh-debt system.
_, err := e.pool.Exec(ctx, `
DELETE FROM ebook_enrichment_state WHERE content_id = $1
`, contentID)
return err
}
// recordEnrichFailure increments the item's ebook_enrichment_state failure
// counter so claimBatch deprioritizes it on the next sweep and stops claiming
// it at enrichFailureCap. The state is dedicated to ebook enrichment;
// media_items.refresh_failures is owned by the metadata refresh-debt system
// and is never touched here.
func (e *Enricher) recordEnrichFailure(ctx context.Context, item enrichmentItemRow) {
if e == nil || e.pool == nil {
return
}
if _, err := e.pool.Exec(ctx, `
INSERT INTO ebook_enrichment_state (content_id, failures, updated_at)
VALUES ($1, 1, NOW())
ON CONFLICT (content_id) DO UPDATE SET
failures = ebook_enrichment_state.failures + 1,
updated_at = NOW()
`, item.ContentID); err != nil {
slog.Warn("ebook enrichment: failed to record enrichment failure",
"content_id", item.ContentID,
"error", err,
)
}
}
func (e *Enricher) persistPeople(ctx context.Context, contentID string, people []models.ItemPerson) error {
people = filterEbookPeople(people)
if len(people) == 0 {
return nil
}
persons := make([]models.Person, len(people))
for i := range people {
persons[i] = people[i].Person
}
personIDs, err := e.personRepo.BatchFindOrCreate(ctx, persons)
if err != nil {
return fmt.Errorf("BatchFindOrCreate people: %w", err)
}
linked := make([]models.ItemPerson, 0, len(people))
for i := range people {
if i >= len(personIDs) || personIDs[i] == 0 {
continue
}
ip := people[i]
ip.Person.ID = personIDs[i]
linked = append(linked, ip)
}
if len(linked) == 0 {
return nil
}
existing, err := e.itemRepo.GetPeople(ctx, contentID)
if err != nil {
return fmt.Errorf("get existing people: %w", err)
}
return e.itemRepo.ReplacePeople(ctx, contentID, mergeEbookAuthorCredits(existing, linked))
}
// mergeEbookAuthorCredits mirrors the scanner's mergeEbookPeople semantics:
// the provider authors replace existing author (and stale narrator) credits,
// while every other curated people kind on the item is preserved.
func mergeEbookAuthorCredits(existing []models.ItemPerson, authors []models.ItemPerson) []models.ItemPerson {
merged := make([]models.ItemPerson, 0, len(existing)+len(authors))
for _, p := range existing {
if p.Kind == models.PersonKindAuthor || p.Kind == models.PersonKindNarrator {
continue
}
p.SortOrder = len(merged)
merged = append(merged, p)
}
for _, a := range authors {
a.SortOrder = len(merged)
merged = append(merged, a)
}
return merged
}
func filterEbookPeople(people []models.ItemPerson) []models.ItemPerson {
authors := make([]models.ItemPerson, 0, len(people))
for _, person := range people {
if person.Kind != models.PersonKindAuthor {
continue
}
person.SortOrder = len(authors)
authors = append(authors, person)
}
return authors
}
func buildEbookSearchQuery(item enrichmentItemRow) (metadata.SearchQuery, map[string]string) {
accumulatedIDs := filterEbookProviderIDs(item.ProviderIDs)
if accumulatedIDs == nil {
accumulatedIDs = map[string]string{}
}
return metadata.SearchQuery{
Title: item.Title,
Year: item.Year,
ContentType: ebookContentType(),
ProviderIDs: accumulatedIDs,
Language: item.Language,
}, accumulatedIDs
}
func buildEbookMetadataRequest(providerIDs map[string]string, language string) metadata.MetadataRequest {
return metadata.MetadataRequest{
ProviderIDs: filterEbookProviderIDs(providerIDs),
ContentType: ebookContentType(),
Language: language,
}
}
func mergeEnrichmentProviderIDs(dst *metadata.MetadataResult, src *metadata.MetadataResult) {
if src == nil || len(src.ProviderIDs) == 0 {
return
}
if dst.ProviderIDs == nil {
dst.ProviderIDs = make(map[string]string, len(src.ProviderIDs))
}
for k, v := range filterEbookProviderIDs(src.ProviderIDs) {
if v != "" {
if _, exists := dst.ProviderIDs[k]; !exists {
dst.ProviderIDs[k] = v
}
}
}
}
func filterEbookProviderIDs(providerIDs map[string]string) map[string]string {
if len(providerIDs) == 0 {
return nil
}
filtered := make(map[string]string, len(providerIDs))
for provider, providerID := range providerIDs {
provider = strings.TrimSpace(provider)
providerID = strings.TrimSpace(providerID)
if provider == "" || providerID == "" {
continue
}
provider = strings.ToLower(provider)
if isEbookASINProvider(provider) {
continue
}
filtered[provider] = providerID
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func isEbookASINProvider(provider string) bool {
normalized := strings.ReplaceAll(strings.ReplaceAll(provider, "_", ""), "-", "")
return normalized == "asin" || normalized == "audibleasin"
}
func providerIDMapFromRows(rows []*models.MediaItemProviderID) map[string]string {
if len(rows) == 0 {
return nil
}
m := make(map[string]string, len(rows))
for _, r := range rows {
if r != nil {
for provider, providerID := range filterEbookProviderIDs(map[string]string{
r.Provider: r.ProviderID,
}) {
m[provider] = providerID
}
}
}
return m
}
+573
View File
@@ -0,0 +1,573 @@
package ebooks
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestEbookContentType(t *testing.T) {
if got := ebookContentType(); got != "ebook" {
t.Fatalf("ebookContentType() = %q, want ebook", got)
}
}
func TestFilterEbookPeopleKeepsAuthorsOnly(t *testing.T) {
people := []models.ItemPerson{
{Person: models.Person{Name: "Author One"}, Kind: models.PersonKindAuthor, SortOrder: 7},
{Person: models.Person{Name: "Narrator One"}, Kind: models.PersonKindNarrator, SortOrder: 8},
{Person: models.Person{Name: "Writer One"}, Kind: models.PersonKindWriter, SortOrder: 9},
{Person: models.Person{Name: "Author Two"}, Kind: models.PersonKindAuthor, SortOrder: 10},
}
got := filterEbookPeople(people)
if len(got) != 2 {
t.Fatalf("filtered people len = %d, want 2: %+v", len(got), got)
}
for i, p := range got {
if p.Kind != models.PersonKindAuthor {
t.Fatalf("filtered[%d].Kind = %v, want author", i, p.Kind)
}
if p.SortOrder != i {
t.Fatalf("filtered[%d].SortOrder = %d, want %d", i, p.SortOrder, i)
}
}
if got[0].Person.Name != "Author One" || got[1].Person.Name != "Author Two" {
t.Fatalf("filtered author order = %+v", got)
}
}
func TestEbookEnrichWorkersFromEnv(t *testing.T) {
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "12")
if got := ebookEnrichWorkers(); got != 12 {
t.Fatalf("ebookEnrichWorkers() = %d, want 12", got)
}
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "0")
if got := ebookEnrichWorkers(); got != defaultEnrichWorkers {
t.Fatalf("ebookEnrichWorkers() with zero = %d, want default %d", got, defaultEnrichWorkers)
}
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "999")
if got := ebookEnrichWorkers(); got != defaultEnrichBatchSize {
t.Fatalf("ebookEnrichWorkers() capped = %d, want %d", got, defaultEnrichBatchSize)
}
}
func TestEnricherRunFansOut(t *testing.T) {
const wantWorkers = 4
const itemCount = 16
items := make([]enrichmentItemRow, itemCount)
for i := range items {
items[i] = enrichmentItemRow{ContentID: "test", Title: "t"}
}
var inFlight int32
var maxInFlight int32
var signaled int32
var wg sync.WaitGroup
wg.Add(wantWorkers)
gate := make(chan struct{})
enrich := func(ctx context.Context, item enrichmentItemRow) error {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
prev := atomic.LoadInt32(&maxInFlight)
if cur <= prev || atomic.CompareAndSwapInt32(&maxInFlight, prev, cur) {
break
}
}
if atomic.AddInt32(&signaled, 1) <= wantWorkers {
wg.Done()
}
select {
case <-gate:
case <-time.After(2 * time.Second):
}
return nil
}
go func() {
wg.Wait()
close(gate)
}()
e := &Enricher{workers: wantWorkers, batchSize: itemCount}
e.runBatch(context.Background(), items, enrich, nil)
if got := atomic.LoadInt32(&maxInFlight); got < wantWorkers {
t.Errorf("max in-flight = %d, want >= %d", got, wantWorkers)
}
}
func TestRunBatchRecordsFailuresForFailedItemsOnly(t *testing.T) {
items := []enrichmentItemRow{
{ContentID: "ok-1"},
{ContentID: "bad-1"},
{ContentID: "ok-2"},
{ContentID: "bad-2"},
}
enrich := func(_ context.Context, item enrichmentItemRow) error {
if strings.HasPrefix(item.ContentID, "bad") {
return errors.New("provider exploded")
}
return nil
}
var mu sync.Mutex
var recorded []string
record := func(_ context.Context, item enrichmentItemRow) {
mu.Lock()
defer mu.Unlock()
recorded = append(recorded, item.ContentID)
}
e := &Enricher{workers: 2, batchSize: len(items)}
enriched := e.runBatch(context.Background(), items, enrich, record)
if enriched != 2 {
t.Fatalf("enriched = %d, want 2", enriched)
}
sort.Strings(recorded)
if strings.Join(recorded, ",") != "bad-1,bad-2" {
t.Fatalf("recorded failures = %v, want exactly the failing items", recorded)
}
}
func TestRunBatchSkipsFailureRecordingOnCancellation(t *testing.T) {
items := []enrichmentItemRow{{ContentID: "cancelled-1"}}
enrich := func(context.Context, enrichmentItemRow) error {
return fmt.Errorf("search aborted: %w", context.Canceled)
}
var recorded int32
record := func(context.Context, enrichmentItemRow) {
atomic.AddInt32(&recorded, 1)
}
e := &Enricher{workers: 1, batchSize: len(items)}
if enriched := e.runBatch(context.Background(), items, enrich, record); enriched != 0 {
t.Fatalf("enriched = %d, want 0", enriched)
}
if got := atomic.LoadInt32(&recorded); got != 0 {
t.Fatalf("failure recordings = %d, want 0: cancellation must not count against the cap", got)
}
}
func TestClaimBatchQueryAppliesFailureBackoffAndCap(t *testing.T) {
// Pin the starvation guard: failing items are deprioritized, not retried
// at the head of every sweep, and capped items are never re-claimed.
if !strings.Contains(claimBatchQuery, "LEFT JOIN ebook_enrichment_state ees ON ees.content_id = mi.content_id") {
t.Fatalf("claimBatchQuery must read dedicated ebook enrichment failure state:\n%s", claimBatchQuery)
}
if !strings.Contains(claimBatchQuery, "COALESCE(ees.failures, 0) < $2") {
t.Fatalf("claimBatchQuery must exclude items at the failure cap:\n%s", claimBatchQuery)
}
if !strings.Contains(claimBatchQuery, "ORDER BY COALESCE(ees.failures, 0) ASC, mi.created_at ASC") {
t.Fatalf("claimBatchQuery must claim least-failed items first:\n%s", claimBatchQuery)
}
if strings.Contains(claimBatchQuery, "refresh_failures") {
t.Fatalf("claimBatchQuery must not read media_items.refresh_failures (owned by the metadata refresh-debt system):\n%s", claimBatchQuery)
}
if enrichFailureCap < 1 {
t.Fatalf("enrichFailureCap = %d, want >= 1", enrichFailureCap)
}
}
func TestEnrichItemSkipsItemWithoutLibraryFolder(t *testing.T) {
// Membership rows are inserted after the item upsert, so a scan-window
// race can claim an item before its folder link exists. The item must be
// skipped (retried next sweep), never stamped or counted as a failure.
e := &Enricher{}
err := e.enrichItem(context.Background(), enrichmentItemRow{ContentID: "no-folder", Title: "t"})
if !errors.Is(err, errEnrichmentSkipped) {
t.Fatalf("enrichItem error = %v, want errEnrichmentSkipped", err)
}
}
func TestEnrichWithProvidersSkipsWhenNoProvidersConfigured(t *testing.T) {
e := &Enricher{}
err := e.enrichWithProviders(context.Background(), enrichmentItemRow{ContentID: "c1", FolderID: 7}, nil)
if !errors.Is(err, errEnrichmentSkipped) {
t.Fatalf("enrichWithProviders error = %v, want errEnrichmentSkipped", err)
}
}
func TestEnrichWithProvidersReturnsFailureWhenAllProvidersError(t *testing.T) {
providerErr := errors.New("provider exploded")
providers := []metadata.Provider{
&fakeEbookMetadataProvider{slug: "p1", searchErr: providerErr, getErr: providerErr},
&fakeEbookMetadataProvider{slug: "p2", searchErr: providerErr, getErr: providerErr},
}
e := &Enricher{}
err := e.enrichWithProviders(context.Background(), enrichmentItemRow{ContentID: "c1", FolderID: 7, Title: "t"}, providers)
if err == nil {
t.Fatal("enrichWithProviders = nil, want error so the failure cap engages instead of stamping")
}
if errors.Is(err, errEnrichmentSkipped) {
t.Fatalf("enrichWithProviders error = %v, want a recordable failure, not a skip", err)
}
if !errors.Is(err, providerErr) {
t.Fatalf("enrichWithProviders error = %v, want wrapped provider error", err)
}
}
func TestEnrichWithProvidersStampsWhenProvidersAnswerWithNoMatch(t *testing.T) {
// Providers reachable, genuinely nothing found: nil means the no-match
// path ran and the item was stamped so it is not re-claimed every sweep.
providers := []metadata.Provider{
&fakeEbookMetadataProvider{slug: "p1"},
}
e := &Enricher{}
err := e.enrichWithProviders(context.Background(), enrichmentItemRow{ContentID: "c1", FolderID: 7, Title: "t"}, providers)
if err != nil {
t.Fatalf("enrichWithProviders = %v, want nil for a genuine no-match", err)
}
}
func TestEnrichWithProvidersReturnsContextErrorOverProviderFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
providers := []metadata.Provider{
&fakeEbookMetadataProvider{slug: "p1", searchErr: ctx.Err(), getErr: ctx.Err()},
}
e := &Enricher{}
err := e.enrichWithProviders(ctx, enrichmentItemRow{ContentID: "c1", FolderID: 7}, providers)
if !errors.Is(err, context.Canceled) {
t.Fatalf("enrichWithProviders error = %v, want context.Canceled so cancellation never counts against the cap", err)
}
}
func TestCollectEbookMetadataAccumulatesProviderErrors(t *testing.T) {
searchErr := errors.New("search down")
getErr := errors.New("metadata down")
providers := []metadata.Provider{
&fakeEbookMetadataProvider{slug: "broken", searchErr: searchErr, getErr: getErr},
&fakeEbookMetadataProvider{
slug: "working",
results: []metadata.SearchResult{{ProviderIDs: map[string]string{"openlibrary": "OL1M"}}},
result: &metadata.MetadataResult{HasMetadata: true, Overview: "found"},
},
}
accumulator, ids, errs := collectEbookMetadata(context.Background(), enrichmentItemRow{ContentID: "c1", Title: "t"}, providers)
if len(errs) != 2 || !errors.Is(errs[0], searchErr) || !errors.Is(errs[1], getErr) {
t.Fatalf("provider errors = %v, want both broken-provider errors", errs)
}
if accumulator.Overview != "found" {
t.Fatalf("accumulator overview = %q, want metadata from the working provider", accumulator.Overview)
}
if ids["openlibrary"] != "OL1M" {
t.Fatalf("accumulated IDs = %v, want search-result openlibrary ID", ids)
}
}
func TestRunBatchDoesNotRecordFailuresForSkippedItems(t *testing.T) {
items := []enrichmentItemRow{{ContentID: "skipped-1"}}
enrich := func(context.Context, enrichmentItemRow) error {
return fmt.Errorf("%w: no providers", errEnrichmentSkipped)
}
var recorded int32
record := func(context.Context, enrichmentItemRow) {
atomic.AddInt32(&recorded, 1)
}
e := &Enricher{workers: 1, batchSize: len(items)}
if enriched := e.runBatch(context.Background(), items, enrich, record); enriched != 0 {
t.Fatalf("enriched = %d, want 0", enriched)
}
if got := atomic.LoadInt32(&recorded); got != 0 {
t.Fatalf("failure recordings = %d, want 0: skips must not count against the cap", got)
}
}
func TestMergeEbookAuthorCreditsPreservesOtherPeopleKinds(t *testing.T) {
existing := []models.ItemPerson{
{Person: models.Person{ID: 10, Name: "Old Author"}, Kind: models.PersonKindAuthor, SortOrder: 0},
{Person: models.Person{ID: 20, Name: "Manual Writer"}, Kind: models.PersonKindWriter, SortOrder: 1, Character: "essay"},
{Person: models.Person{ID: 30, Name: "Stale Narrator"}, Kind: models.PersonKindNarrator, SortOrder: 2},
}
authors := []models.ItemPerson{
{Person: models.Person{ID: 40, Name: "Provider Author"}, Kind: models.PersonKindAuthor},
}
got := mergeEbookAuthorCredits(existing, authors)
if len(got) != 2 {
t.Fatalf("merged people len = %d, want 2: %+v", len(got), got)
}
if got[0].Person.ID != 20 || got[0].Kind != models.PersonKindWriter || got[0].Character != "essay" || got[0].SortOrder != 0 {
t.Fatalf("preserved non-author credit = %+v", got[0])
}
if got[1].Person.ID != 40 || got[1].Kind != models.PersonKindAuthor || got[1].SortOrder != 1 {
t.Fatalf("provider author credit = %+v", got[1])
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeEbookImageCacher{}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if cacher.req.ProviderID != ebookMetadataImageProviderID {
t.Fatalf("ProviderID = %q, want %q", cacher.req.ProviderID, ebookMetadataImageProviderID)
}
if cacher.req.ContentType != "ebooks" || cacher.req.ContentID != "content-1" {
t.Fatalf("cache target = %q/%q", cacher.req.ContentType, cacher.req.ContentID)
}
if result.PosterPath != "ebook-metadata/ebooks/content-1/poster/original.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
if result.PosterThumbhash != "thumb" {
t.Fatalf("PosterThumbhash = %q", result.PosterThumbhash)
}
}
func TestCacheRemotePosterSkipsNilCacher(t *testing.T) {
e := &Enricher{}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestCacheRemotePosterSkipsTypedNilCacher(t *testing.T) {
var cacher *fakeEbookImageCacher
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestCacheRemotePosterSkipsAlreadyCachedPath(t *testing.T) {
cacher := &fakeEbookImageCacher{}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "local/ebooks/content-1/poster/original.webp",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 0 {
t.Fatalf("CacheImage calls = %d, want 0", cacher.calls)
}
if result.PosterPath != "local/ebooks/content-1/poster/original.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
}
func TestCacheRemotePosterPreservesProviderURLOnCacheError(t *testing.T) {
cacher := &fakeEbookImageCacher{err: errors.New("cache failed")}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestCacheRemotePosterPreservesProviderURLOnNilCacheResult(t *testing.T) {
cacher := &fakeEbookImageCacher{returnNil: true}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestMergeEnrichmentProviderIDsKeepsExistingIDs(t *testing.T) {
dst := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "9780306406157"}}
src := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "new", "openlibrary": "OL1M", "empty": ""}}
mergeEnrichmentProviderIDs(dst, src)
if got := dst.ProviderIDs["isbn"]; got != "9780306406157" {
t.Fatalf("isbn = %q, want original", got)
}
if got := dst.ProviderIDs["openlibrary"]; got != "OL1M" {
t.Fatalf("openlibrary = %q, want OL1M", got)
}
if _, exists := dst.ProviderIDs["empty"]; exists {
t.Fatal("empty provider ID should not be merged")
}
}
func TestMergeEnrichmentProviderIDsDropsAsinIDs(t *testing.T) {
dst := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "9780306406157"}}
src := &metadata.MetadataResult{
ProviderIDs: map[string]string{
"ASIN": "B00TEST",
"audible_asin": "B00AUDIO",
"openlibrary": "OL1M",
},
}
mergeEnrichmentProviderIDs(dst, src)
if _, exists := dst.ProviderIDs["ASIN"]; exists {
t.Fatal("ASIN provider ID should not be merged for ebooks")
}
if _, exists := dst.ProviderIDs["audible_asin"]; exists {
t.Fatal("audible_asin provider ID should not be merged for ebooks")
}
if got := dst.ProviderIDs["openlibrary"]; got != "OL1M" {
t.Fatalf("openlibrary = %q, want OL1M", got)
}
}
func TestBuildEbookSearchQueryUsesFilteredScannerISBN(t *testing.T) {
item := enrichmentItemRow{
Title: "Tagged Ebook",
Year: 2024,
Language: "en",
ProviderIDs: map[string]string{
" ISBN ": " 9780306406157 ",
"ASIN": "B00TEST",
"audible_asin": "B00AUDIO",
},
}
query, ids := buildEbookSearchQuery(item)
if query.Title != "Tagged Ebook" || query.Year != 2024 || query.Language != "en" || query.ContentType != "ebook" {
t.Fatalf("query basics = %+v", query)
}
if got := query.ProviderIDs["isbn"]; got != "9780306406157" {
t.Fatalf("query isbn = %q, want scanner ISBN", got)
}
if _, exists := query.ProviderIDs["ASIN"]; exists {
t.Fatal("query should not include ASIN for ebooks")
}
if got := ids["isbn"]; got != "9780306406157" {
t.Fatalf("accumulated isbn = %q, want scanner ISBN", got)
}
}
func TestFilterEbookProviderIDsDropsAsinAliases(t *testing.T) {
got := filterEbookProviderIDs(map[string]string{
"ASIN": "B00TEST",
"audibleASIN": "B00AUDIO",
"audible-asin": "B00AUDIO2",
"audible_asin": "B00AUDIO3",
" ISBN ": " 9780306406157 ",
" OpenLibraryID": " OL1M ",
})
if got["asin"] != "" || got["audibleasin"] != "" || got["audible-asin"] != "" || got["audible_asin"] != "" {
t.Fatalf("ASIN aliases should be filtered, got %+v", got)
}
if got["isbn"] != "9780306406157" || got["openlibraryid"] != "OL1M" {
t.Fatalf("filtered IDs = %+v, want ISBN and OpenLibraryID only", got)
}
}
func TestBuildEbookMetadataRequestCarriesAccumulatedISBN(t *testing.T) {
req := buildEbookMetadataRequest(map[string]string{
" ISBN ": " 9780306406157 ",
"openlibrary": "OL1M",
}, "fr")
if req.ContentType != "ebook" || req.Language != "fr" {
t.Fatalf("request basics = %+v", req)
}
if got := req.ProviderIDs["isbn"]; got != "9780306406157" {
t.Fatalf("request isbn = %q, want normalized key/value", got)
}
if got := req.ProviderIDs["openlibrary"]; got != "OL1M" {
t.Fatalf("request openlibrary = %q, want OL1M", got)
}
}
type fakeEbookMetadataProvider struct {
slug string
searchErr error
results []metadata.SearchResult
getErr error
result *metadata.MetadataResult
}
func (f *fakeEbookMetadataProvider) Slug() string { return f.slug }
func (f *fakeEbookMetadataProvider) Name() string { return f.slug }
func (f *fakeEbookMetadataProvider) ForTypes() []string { return []string{"ebook"} }
func (f *fakeEbookMetadataProvider) Search(context.Context, metadata.SearchQuery) ([]metadata.SearchResult, error) {
return f.results, f.searchErr
}
func (f *fakeEbookMetadataProvider) GetMetadata(context.Context, metadata.MetadataRequest) (*metadata.MetadataResult, error) {
return f.result, f.getErr
}
type fakeEbookImageCacher struct {
calls int
req metadata.CacheImageRequest
err error
returnNil bool
}
func (f *fakeEbookImageCacher) CacheImage(_ context.Context, req metadata.CacheImageRequest) (*metadata.CacheImageResult, error) {
f.calls++
f.req = req
if f.err != nil {
return nil, f.err
}
if f.returnNil {
return nil, nil
}
return &metadata.CacheImageResult{
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
Thumbhash: "thumb",
Ext: ".webp",
}, nil
}
+6 -3
View File
@@ -869,8 +869,11 @@ func (r *Repository) UpsertImportedProgress(
if positionSeconds < 0 {
positionSeconds = 0
}
if completed && durationSeconds > 0 {
positionSeconds = durationSeconds
// Completed rows hold no resume point and `completed` is a one-way watched
// latch — mirrors the userstore write paths so imported rows can't surface
// as phantom Continue Watching entries.
if completed {
positionSeconds = 0
}
_, err := r.pool.Exec(ctx, `
INSERT INTO user_watch_progress (
@@ -879,7 +882,7 @@ func (r *Repository) UpsertImportedProgress(
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = excluded.position_seconds,
duration_seconds = excluded.duration_seconds,
completed = excluded.completed,
completed = user_watch_progress.completed OR excluded.completed,
updated_at = excluded.updated_at`,
userID, profileID, mediaItemID, positionSeconds, durationSeconds, completed, updatedAt,
)
+16
View File
@@ -114,6 +114,22 @@ func (c *Cacher) CacheAudiobookCover(ctx context.Context, data []byte, contentID
return res.BasePath, res.Ext, res.Thumbhash, nil
}
// CacheEbookCover stores an embedded ebook cover under
// "local/ebooks/{contentID}/poster/..." using the same poster variants as
// provider-hosted book artwork.
func (c *Cacher) CacheEbookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error) {
res, err := c.CacheBytes(ctx, data, CacheRequest{
ProviderID: "local",
ContentType: "ebooks",
ContentID: contentID,
ImageType: metadata.ImagePoster,
})
if err != nil {
return "", "", "", err
}
return res.BasePath, res.Ext, res.Thumbhash, nil
}
// CacheBytes performs the same variant generation, thumbhash, and S3 upload as
// Cache but starts from raw image bytes already in hand. Used by the
// audiobook scanner to push embedded M4B cover art into S3 without round-
@@ -0,0 +1,200 @@
package jellycompat
import (
"context"
"net/url"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestCompatScopedTypes(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", compatVideoTypes},
{"movie", "movie"},
{"movie,series", "movie,series"},
{"audiobook", compatNoMatchType},
{"movie,audiobook", "movie"},
{" Audiobook ", compatNoMatchType},
{"podcast", compatNoMatchType},
{"movie,podcast", "movie"},
// Non-video types outside the allowlist are dropped, not passed through.
{"musicalbum", compatNoMatchType},
{"movie,musicalbum", "movie"},
}
for _, tc := range cases {
if got := compatScopedTypes(tc.in); got != tc.want {
t.Errorf("compatScopedTypes(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestCompatScopedSearchTypes(t *testing.T) {
if got := compatScopedSearchTypes(nil); len(got) != 3 {
t.Fatalf("expected video type default, got %v", got)
}
got := compatScopedSearchTypes([]string{"movie", "audiobook"})
if len(got) != 1 || got[0] != "movie" {
t.Fatalf("expected [movie], got %v", got)
}
got = compatScopedSearchTypes([]string{"audiobook"})
if len(got) != 1 || got[0] != compatNoMatchType {
t.Fatalf("expected no-match sentinel, got %v", got)
}
}
func TestIsCompatHiddenLibraryType(t *testing.T) {
for _, val := range []string{"audiobooks", "audiobook", " Audiobooks ", "podcast", "podcasts"} {
if !isCompatHiddenLibraryType(val) {
t.Errorf("expected %q to be hidden from the compat surface", val)
}
}
for _, val := range []string{"movies", "series", "mixed", ""} {
if isCompatHiddenLibraryType(val) {
t.Errorf("expected %q to be visible on the compat surface", val)
}
}
}
func TestCompatAccessFilterResolverStampsExclusions(t *testing.T) {
resolved := compatAccessFilterResolver(nil)(context.Background(), 1, "p1")
if len(resolved.ExcludedMediaTypes) == 0 {
t.Fatalf("expected exclusions on nil base resolver, got %+v", resolved)
}
base := func(context.Context, int, string) catalog.AccessFilter {
return catalog.AccessFilter{MaxContentRating: "PG-13"}
}
resolved = compatAccessFilterResolver(base)(context.Background(), 1, "p1")
if resolved.MaxContentRating != "PG-13" {
t.Fatalf("expected base filter fields preserved, got %+v", resolved)
}
for _, want := range []string{"audiobook", "podcast"} {
found := false
for _, got := range resolved.ExcludedMediaTypes {
if got == want {
found = true
}
}
if !found {
t.Fatalf("expected %q in ExcludedMediaTypes, got %v", want, resolved.ExcludedMediaTypes)
}
}
// Pre-existing exclusions from the base resolver are preserved AND the
// compat exclusions are merged in (not conditionally skipped).
withExisting := func(context.Context, int, string) catalog.AccessFilter {
return catalog.AccessFilter{ExcludedMediaTypes: []string{"livetv"}}
}
resolved = compatAccessFilterResolver(withExisting)(context.Background(), 1, "p1")
for _, want := range []string{"livetv", "audiobook", "podcast"} {
found := false
for _, got := range resolved.ExcludedMediaTypes {
if got == want {
found = true
}
}
if !found {
t.Fatalf("expected %q in merged ExcludedMediaTypes, got %v", want, resolved.ExcludedMediaTypes)
}
}
}
// scopeFakeFolderRepo returns a fixed folder list.
type scopeFakeFolderRepo struct {
folders []*models.MediaFolder
}
func (f *scopeFakeFolderRepo) GetEnabled(context.Context) ([]*models.MediaFolder, error) {
return f.folders, nil
}
func (f *scopeFakeFolderRepo) ListByIDs(_ context.Context, ids []int) ([]*models.MediaFolder, error) {
out := make([]*models.MediaFolder, 0, len(ids))
for _, folder := range f.folders {
for _, id := range ids {
if folder.ID == id {
out = append(out, folder)
}
}
}
return out, nil
}
func TestListUserLibrariesExcludesABSFolders(t *testing.T) {
svc := &directContentService{
folderRepo: &scopeFakeFolderRepo{folders: []*models.MediaFolder{
{ID: 1, Name: "Movies", Type: "movies", Enabled: true},
{ID: 2, Name: "Audiobooks", Type: "audiobooks", Enabled: true},
{ID: 3, Name: "Shows", Type: "series", Enabled: true},
{ID: 4, Name: "Legacy Books", Type: "audiobook", Enabled: true},
{ID: 5, Name: "Podcasts", Type: "podcasts", Enabled: true},
}},
}
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "p1"})
if err != nil {
t.Fatalf("ListUserLibraries: %v", err)
}
if len(libraries) != 2 {
t.Fatalf("expected 2 libraries, got %d: %+v", len(libraries), libraries)
}
for _, lib := range libraries {
if lib.Name != "Movies" && lib.Name != "Shows" {
t.Fatalf("ABS-surface library %q leaked into compat views", lib.Name)
}
}
}
// scopeFakeBrowseSource captures the filters passed to BrowsePage.
type scopeFakeBrowseSource struct {
lastFilters catalog.BrowseFilters
}
func (f *scopeFakeBrowseSource) BrowsePage(_ context.Context, filters catalog.BrowseFilters, _ bool) (*catalog.BrowseResult, error) {
f.lastFilters = filters
return &catalog.BrowseResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false}, nil
}
func (f *scopeFakeBrowseSource) ListGenres(_ context.Context, filters catalog.BrowseFilters) ([]string, error) {
f.lastFilters = filters
return []string{}, nil
}
func TestBrowseItemsDefaultsToVideoTypeScope(t *testing.T) {
browse := &scopeFakeBrowseSource{}
svc := &directContentService{browseRepo: browse}
session := &Session{StreamAppUserID: 1, ProfileID: "p1"}
if _, err := svc.BrowseItems(context.Background(), session, url.Values{}); err != nil {
t.Fatalf("BrowseItems: %v", err)
}
if browse.lastFilters.Type != compatVideoTypes {
t.Fatalf("expected default type scope %q, got %q", compatVideoTypes, browse.lastFilters.Type)
}
params := url.Values{}
params.Set("type", "movie")
if _, err := svc.BrowseItems(context.Background(), session, params); err != nil {
t.Fatalf("BrowseItems: %v", err)
}
if browse.lastFilters.Type != "movie" {
t.Fatalf("expected explicit type to pass through, got %q", browse.lastFilters.Type)
}
}
func TestListItemFiltersDefaultsToVideoTypeScope(t *testing.T) {
browse := &scopeFakeBrowseSource{}
svc := &directContentService{browseRepo: browse}
if _, err := svc.ListItemFilters(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "p1"}, url.Values{}); err != nil {
t.Fatalf("ListItemFilters: %v", err)
}
if browse.lastFilters.Type != compatVideoTypes {
t.Fatalf("expected genre listing scoped to %q, got %q", compatVideoTypes, browse.lastFilters.Type)
}
}
+8 -2
View File
@@ -62,7 +62,8 @@ func ExtractToken(r *http.Request) (string, bool) {
if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" {
return token, true
}
if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" {
// Case-insensitive: Jellyfin clients vary the casing (api_key / Api_Key / API_KEY).
if token := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("api_key")); token != "" {
return token, true
}
@@ -185,7 +186,12 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
// no auth header or api_key. Resolve the negotiated session's
// CompatToken — which for an API-key stream is itself the sa_ key,
// so it must go through the same session-or-API-key resolution.
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
//
// The lookup must be case-insensitive: Wholphin's jellyfin-sdk-kotlin
// builds its own direct-play URL with a lowercase "playSessionId"
// (and no api_key / auth header), so a case-sensitive match would
// miss it and 401 the stream — forcing a needless transcode fallback.
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
if playSession, found := playbackStore.Get(playSessionID); found {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
+54
View File
@@ -119,6 +119,60 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
}
}
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(30*24*time.Hour, clock)
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
cases := []struct {
name string
rawQuery string
wantCode int
}{
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
{"no auth at all", "static=true", http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
rec := httptest.NewRecorder()
gotSession := false
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if SessionFromContext(r.Context()) != nil {
gotSession = true
}
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
}
if tc.wantCode == http.StatusOK && !gotSession {
t.Fatal("expected authenticated session in context")
}
})
}
}
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
for _, key := range []string{"api_key", "Api_Key", "API_KEY"} {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
if got, ok := ExtractToken(req); !ok || got != "tok123" {
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
}
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
+14 -1
View File
@@ -38,6 +38,7 @@ type itemRepoForBatchLoader interface {
// gated through the parent series item via GetByIDsWithAccess on media_items.
type episodeRepoForBatchLoader interface {
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.Episode, error)
HasFilesByIDs(ctx context.Context, contentIDs []string) (map[string]bool, error)
ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error)
ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error)
}
@@ -217,7 +218,11 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
COALESCE(si.backdrop_thumbhash, ''),
si.logo_path,
si.status,
si.updated_at
si.updated_at,
EXISTS (
SELECT 1 FROM media_files mf
WHERE mf.episode_id = e.content_id AND mf.missing_since IS NULL
)
FROM %s
WHERE %s
ORDER BY e.content_id
@@ -255,6 +260,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
seriesLogoPath string
status string
seriesUpdatedAt time.Time
hasMediaFiles bool
)
if err := rows.Scan(
&contentID,
@@ -280,6 +286,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&seriesLogoPath,
&status,
&seriesUpdatedAt,
&hasMediaFiles,
); err != nil {
return nil, fmt.Errorf("scanning compat episode target: %w", err)
}
@@ -310,6 +317,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
HasMediaFiles: &hasMediaFiles,
}
if airDate != nil {
listItem.AirDate = airDate.Format(time.DateOnly)
@@ -357,6 +365,10 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
if err != nil {
return nil, err
}
hasFiles, err := h.episodeRepo.HasFilesByIDs(ctx, filteredContentIDs)
if err != nil {
return nil, err
}
seriesIDs := make([]string, 0, len(episodes))
seenSeries := make(map[string]struct{}, len(episodes))
@@ -417,6 +429,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
SeasonNumber: intPtr(episode.SeasonNumber),
EpisodeNumber: intPtr(episode.EpisodeNumber),
Runtime: episode.Runtime,
HasMediaFiles: boolPtr(hasFiles[episode.ContentID]),
}
if episode.AirDate != nil {
listItem.AirDate = episode.AirDate.Format(time.DateOnly)
@@ -78,6 +78,7 @@ func (r *countingItemRepo) GetItemsInLibrary(_ context.Context, contentIDs []str
// countingEpisodeRepo is an in-memory episodeRepoForBatchLoader fake.
type countingEpisodeRepo struct {
episodesByID map[string]*models.Episode
hasFilesByID map[string]bool
getByIDsCalls int
}
@@ -92,6 +93,16 @@ func (r *countingEpisodeRepo) GetByIDs(_ context.Context, contentIDs []string) (
return out, nil
}
func (r *countingEpisodeRepo) HasFilesByIDs(_ context.Context, contentIDs []string) (map[string]bool, error) {
out := make(map[string]bool, len(contentIDs))
for _, id := range contentIDs {
if r.hasFilesByID[id] {
out[id] = true
}
}
return out, nil
}
func (r *countingEpisodeRepo) ListBySeason(context.Context, string, int) ([]*models.Episode, error) {
return nil, errors.New("ListBySeason not used in fallback test")
}
@@ -68,6 +68,10 @@ func (m *mockUserDataService) ListProgress(context.Context, *Session, string, in
panic("unused")
}
func (m *mockUserDataService) FilterResumeProgress(_ context.Context, _ *Session, entries []upstreamProgress) ([]upstreamProgress, error) {
return entries, nil
}
func (m *mockUserDataService) MarkPlayed(context.Context, *Session, string) error {
panic("unused")
}
+138 -7
View File
@@ -36,6 +36,125 @@ const (
compatBrowseMaxLimit = 1000
)
// compatVideoTypes is the media_items type scope the Jellyfin compat surface
// exposes when a request carries no explicit type filter. Silo serves
// audiobooks and podcasts through the audiobookshelf-compat API instead, so
// those rows must never leak into jellycompat responses.
const compatVideoTypes = "movie,series,episode"
// compatVideoTypeList is compatVideoTypes as a slice for APIs that take
// []string (e.g. ItemRepository.Search).
var compatVideoTypeList = strings.Split(compatVideoTypes, ",")
// compatExcludedMediaTypes lists the media_items.type values the Jellyfin
// compat surface never exposes. Injected into every catalog.AccessFilter the
// compat layer resolves (see withCompatAccessExclusions) so access-filtered
// queries — search, favorites, recommendations, detail, batch hydration —
// inherit the exclusion without per-call-site guards.
var compatExcludedMediaTypes = []string{"audiobook", "podcast"}
func isCompatExcludedMediaType(mediaType string) bool {
for _, excluded := range compatExcludedMediaTypes {
if strings.EqualFold(strings.TrimSpace(mediaType), excluded) {
return true
}
}
return false
}
// isCompatHiddenLibraryType reports whether a media folder type marks a
// library the Jellyfin compat surface hides. Audiobook ('audiobooks'
// canonical, 'audiobook' legacy — mirrors internal/audiobooks/media_store.go)
// and podcast libraries are served by the ABS-compat API instead.
func isCompatHiddenLibraryType(folderType string) bool {
switch strings.ToLower(strings.TrimSpace(folderType)) {
case "audiobooks", "audiobook", "podcasts", "podcast":
return true
}
return false
}
// withCompatAccessExclusions merges the compat surface's media-type
// exclusions into a resolved access filter, preserving any exclusions the
// base resolver already supplied. Centralizing the exclusion here lets
// catalog query builders enforce it everywhere an AccessFilter flows.
func withCompatAccessExclusions(filter catalog.AccessFilter) catalog.AccessFilter {
merged := make([]string, 0, len(filter.ExcludedMediaTypes)+len(compatExcludedMediaTypes))
merged = append(merged, filter.ExcludedMediaTypes...)
for _, excluded := range compatExcludedMediaTypes {
present := false
for _, existing := range merged {
if strings.EqualFold(existing, excluded) {
present = true
break
}
}
if !present {
merged = append(merged, excluded)
}
}
filter.ExcludedMediaTypes = merged
return filter
}
// compatAccessFilterResolver wraps an AccessFilterResolver so every filter it
// returns carries the compat media-type exclusions. A nil base resolver still
// yields exclusion-only filters.
func compatAccessFilterResolver(base AccessFilterResolver) AccessFilterResolver {
return func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
if base == nil {
return withCompatAccessExclusions(catalog.AccessFilter{})
}
return withCompatAccessExclusions(base(ctx, userID, profileID))
}
}
// compatNoMatchType is a sentinel media type that matches no media_items row.
// Used when a caller's explicit type filter reduces to nothing exposable so
// the query returns empty instead of silently broadening to all video types.
const compatNoMatchType = "__compat_none__"
// compatAllowedTypeSet is the closed set of catalog types an explicit compat
// type filter may pass through ("season" matches no media_items row but is
// kept so season-typed filters keep their empty-result semantics).
var compatAllowedTypeSet = map[string]struct{}{
"movie": {},
"series": {},
"episode": {},
"season": {},
}
// compatScopedSearchTypes clamps an item-type filter to the types the compat
// surface exposes: empty defaults to all video types, explicit filters keep
// only allowlisted entries, and a filter that reduces to nothing returns the
// no-match sentinel.
func compatScopedSearchTypes(itemTypes []string) []string {
if len(itemTypes) == 0 {
return compatVideoTypeList
}
kept := make([]string, 0, len(itemTypes))
for _, t := range itemTypes {
t = strings.ToLower(strings.TrimSpace(t))
if _, ok := compatAllowedTypeSet[t]; !ok {
continue
}
kept = append(kept, t)
}
if len(kept) == 0 {
return []string{compatNoMatchType}
}
return kept
}
// compatScopedTypes is compatScopedSearchTypes for comma-separated filters.
func compatScopedTypes(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return compatVideoTypes
}
return strings.Join(compatScopedSearchTypes(strings.Split(raw, ",")), ",")
}
// itemAccessSource is the subset of *catalog.ItemRepository that
// directContentService season/episode handlers rely on. Defined as an
// interface so tests can substitute a stub without standing up a Postgres
@@ -110,9 +229,9 @@ func newDirectContentService(
func (s *directContentService) resolveFilter(ctx context.Context, session *Session) catalog.AccessFilter {
if s.accessFilter != nil {
return s.accessFilter(ctx, session.StreamAppUserID, session.ProfileID)
return withCompatAccessExclusions(s.accessFilter(ctx, session.StreamAppUserID, session.ProfileID))
}
return catalog.AccessFilter{}
return withCompatAccessExclusions(catalog.AccessFilter{})
}
func applyCompatPresentationLibrary(filter catalog.AccessFilter, libraryID *int) catalog.AccessFilter {
@@ -168,6 +287,11 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
if _, ok := disabled[f.ID]; ok {
continue
}
// Audiobook and podcast libraries are served by the ABS-compat API,
// never here.
if isCompatHiddenLibraryType(f.Type) {
continue
}
lib := upstreamUserLibrary{
ID: f.ID,
Name: f.Name,
@@ -212,7 +336,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
}
filters := catalog.BrowseFilters{
Type: params.Get("type"),
Type: compatScopedTypes(params.Get("type")),
Genre: params.Get("genre"),
NamePrefix: params.Get("name_prefix"),
ContentIDs: contentIDs,
@@ -226,6 +350,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
Limit: fetchLimit,
MaxLimit: compatBrowseMaxLimit,
Offset: requestedOffset,
RequireBackdrop: parseBool(params.Get("require_backdrop"), false),
}
var collected []upstreamListItem
@@ -334,7 +459,7 @@ func parseContentIDParam(raw string) []string {
func (s *directContentService) SearchItems(ctx context.Context, session *Session, query string, itemTypes []string, limit, offset int, libraryID *int) (*upstreamBrowseResponse, error) {
filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID)
items, total, err := s.itemRepo.Search(ctx, query, itemTypes, limit, offset, filter)
items, total, err := s.itemRepo.Search(ctx, query, compatScopedSearchTypes(itemTypes), limit, offset, filter)
if err != nil {
return nil, fmt.Errorf("search items: %w", err)
}
@@ -365,6 +490,12 @@ func (s *directContentService) GetItemDetail(ctx context.Context, session *Sessi
if err != nil {
return nil, wrapCatalogError(err)
}
// ABS-surface media types are not exposed on the Jellyfin compat surface
// (this guard also blocks PlaybackInfo, which resolves items through
// GetItemDetail).
if isCompatExcludedMediaType(detail.Type) {
return nil, &HTTPError{StatusCode: 404, Message: "item not found"}
}
result := itemDetailToUpstream(detail)
@@ -378,7 +509,7 @@ func (s *directContentService) GetItemDetail(ctx context.Context, session *Sessi
PositionSeconds: progress.PositionSeconds,
DurationSeconds: progress.DurationSeconds,
Played: progress.Completed,
IsInProgress: progress.PositionSeconds > 0 && !progress.Completed,
IsInProgress: progress.PositionSeconds > 0,
}
}
@@ -549,7 +680,7 @@ func (s *directContentService) ListItemFilters(ctx context.Context, session *Ses
filter := s.resolveFilter(ctx, session)
filters := catalog.BrowseFilters{
Type: params.Get("type"),
Type: compatScopedTypes(params.Get("type")),
LibraryID: catalog.ParseIntParam(params.Get("library_id")),
LibraryIDs: filter.AllowedLibraryIDs,
DisabledLibraryIDs: filter.DisabledLibraryIDs,
@@ -810,7 +941,7 @@ func seasonUserDataFromProgress(progress userstore.WatchProgress) *catalog.Seaso
PositionSeconds: progress.PositionSeconds,
DurationSeconds: progress.DurationSeconds,
Played: progress.Completed,
IsInProgress: progress.PositionSeconds > 0 && !progress.Completed,
IsInProgress: progress.PositionSeconds > 0,
}
}
+5
View File
@@ -26,6 +26,11 @@ type UserDataService interface {
AddFavorite(ctx context.Context, session *Session, contentID string) error
RemoveFavorite(ctx context.Context, session *Session, contentID string) error
ListProgress(ctx context.Context, session *Session, status string, limit, offset int) ([]upstreamProgress, error)
// FilterResumeProgress drops in-progress entries that Continue Watching
// surfaces should hide: entries the user dismissed from the row and
// episodes superseded by a later-completed episode in the same series.
// It mirrors the first-party sections fetcher so both surfaces agree.
FilterResumeProgress(ctx context.Context, session *Session, entries []upstreamProgress) ([]upstreamProgress, error)
ListProgressByMediaItems(ctx context.Context, session *Session, mediaItemIDs []string) (map[string]*upstreamProgress, error)
GetProgress(ctx context.Context, session *Session, contentID string) (*upstreamProgress, error)
MarkPlayed(ctx context.Context, session *Session, contentID string) error
@@ -0,0 +1,88 @@
package jellycompat
import (
"context"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// TestHandleDownload_ServesOriginalFile verifies /Items/{id}/Download streams
// the original media file. The route backs CanDownload=true, which Infuse
// requires before it will Direct Play an item.
func TestHandleDownload_ServesOriginalFile(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
content := []byte("fake media bytes")
if err := os.WriteFile(filePath, content, 0o644); err != nil {
t.Fatal(err)
}
codec := NewResourceIDCodec()
contentID := "movie-1"
detail := &upstreamItemDetail{
ContentID: contentID,
Type: "movie",
Versions: []catalog.FileVersion{{
FileID: 42,
FilePath: filePath,
Container: "mkv",
Duration: 3600,
AddedAt: time.Now(),
}},
}
handler := &PlaybackHandler{
codec: codec,
content: &stubContentService{detail: detail},
fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: 42, FilePath: filePath}},
}
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleDownload(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != string(content) {
t.Errorf("expected file content %q; got %q", content, got)
}
if cd := rec.Header().Get("Content-Disposition"); cd == "" {
t.Error("expected Content-Disposition header on download response")
}
}
// TestItemDetail_AdvertisesCanDownload guards against regressing the
// CanDownload flag: Infuse refuses Direct Play (Static=true streaming) of
// items it believes it cannot download, so playable items must advertise it.
func TestItemDetail_AdvertisesCanDownload(t *testing.T) {
m := newMapper(NewResourceIDCodec(), nil)
detail := upstreamItemDetail{
ContentID: "movie-1",
Type: "movie",
Versions: []catalog.FileVersion{{
FileID: 42,
Container: "mkv",
Duration: 3600,
AddedAt: time.Now(),
}},
}
dto := m.itemFromDetailWithFields(detail, false, nil, nil)
if !dto.CanDownload {
t.Error("playable item detail must advertise CanDownload=true; Infuse requires it for Direct Play")
}
}
+12
View File
@@ -7,6 +7,16 @@ type queryResultDTO struct {
StartIndex int `json:"StartIndex"`
}
// themeMediaResultDTO mirrors Jellyfin's ThemeMediaResult. OwnerId is required:
// jellyfin-sdk-kotlin models it as non-nullable, so omitting it fails client
// deserialization even for an empty result.
type themeMediaResultDTO struct {
Items []baseItemDTO `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
OwnerID string `json:"OwnerId"`
}
type baseItemDTO struct {
ServerID string `json:"ServerId,omitempty"`
ID string `json:"Id"`
@@ -48,6 +58,7 @@ type baseItemDTO struct {
ProviderIDs map[string]string `json:"ProviderIds,omitempty"`
ProductionLocations []string `json:"ProductionLocations,omitempty"`
ImageTags map[string]string `json:"ImageTags"`
PrimaryImageItemID string `json:"PrimaryImageItemId,omitempty"`
BackdropImageTags []string `json:"BackdropImageTags,omitempty"`
PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"`
ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"`
@@ -73,6 +84,7 @@ type baseItemDTO struct {
SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"`
MovieCount int `json:"MovieCount,omitempty"`
SeriesCount int `json:"SeriesCount,omitempty"`
SeasonCount int `json:"SeasonCount,omitempty"`
EpisodeCount int `json:"EpisodeCount,omitempty"`
LockedFields []string `json:"LockedFields,omitempty"`
LockData bool `json:"LockData,omitempty"`
@@ -0,0 +1,362 @@
package jellycompat
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// collectionSource is the subset of *catalog.LibraryCollectionRepository the
// compat layer relies on to expose library collections as Jellyfin BoxSets.
type collectionSource interface {
ListAll(ctx context.Context, libraryID *int, opts catalog.ListLibraryCollectionsOptions) ([]*models.LibraryCollection, error)
GetByID(ctx context.Context, id string) (*models.LibraryCollection, error)
ListItems(ctx context.Context, collectionID string) ([]*models.LibraryCollectionItem, error)
}
// visibleLibraryIDSet returns the set of library IDs the session may see on
// the compat surface (access-filtered and ABS-library-excluded by
// ListUserLibraries).
func visibleLibraryIDSet(ctx context.Context, content ContentService, session *Session) (map[int]struct{}, error) {
libraries, err := content.ListUserLibraries(ctx, session)
if err != nil {
return nil, err
}
visible := make(map[int]struct{}, len(libraries))
for _, lib := range libraries {
visible[lib.ID] = struct{}{}
}
return visible, nil
}
func (h *ItemsHandler) visibleLibraryIDs(ctx context.Context, session *Session) (map[int]struct{}, error) {
return visibleLibraryIDSet(ctx, h.content, session)
}
// collectionVisible reports whether any of the collection's libraries is
// visible to the session. Collections scoped only to hidden or ABS-surface
// libraries stay off the compat surface.
func collectionVisible(c *models.LibraryCollection, visible map[int]struct{}) bool {
if len(c.LibraryIDs) == 0 {
_, ok := visible[c.LibraryID]
return ok
}
for _, id := range c.LibraryIDs {
if _, ok := visible[id]; ok {
return true
}
}
return false
}
// loadVisibleCollection fetches a collection and applies the compat
// visibility rules. Returns (nil, nil) when the collection does not exist or
// the session may not see it; infrastructure errors propagate so transient
// failures don't masquerade as 404s.
func (h *ItemsHandler) loadVisibleCollection(ctx context.Context, session *Session, collectionID string) (*models.LibraryCollection, error) {
if h.collections == nil {
return nil, nil
}
collection, err := h.collections.GetByID(ctx, collectionID)
if err != nil {
if errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
return nil, nil
}
return nil, err
}
if collection == nil || !strings.EqualFold(collection.Visibility, "visible") {
return nil, nil
}
visible, err := h.visibleLibraryIDs(ctx, session)
if err != nil {
return nil, err
}
if !collectionVisible(collection, visible) {
return nil, nil
}
return collection, nil
}
// boxSetFromCollection maps a library collection to a Jellyfin BoxSet DTO.
// Image tags are signed from the stable artwork key (like library views) so
// they survive restarts and presign rotation; the in-memory image cache is
// seeded as the warm path.
func (h *ItemsHandler) boxSetFromCollection(ctx context.Context, c *models.LibraryCollection) baseItemDTO {
routeID := h.codec.EncodeStringID(EncodedIDCollection, c.ID)
imgTags := map[string]string{}
if posterURL := h.presignCollectionPoster(ctx, c.PosterURL); posterURL != "" {
if h.images != nil {
h.images.RememberSized(routeID, "Primary", posterURL, compatCardImageSize)
}
imgTags["Primary"] = h.mapper.imageTagSigner.Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, c.PosterURL, "", time.Time{}),
posterURL,
)
}
dto := baseItemDTO{
ID: routeID,
Type: "BoxSet",
IsFolder: true,
Name: c.Title,
ServerID: h.mapper.serverID,
Overview: c.Description,
SortName: strings.ToLower(c.Title),
ChildCount: c.ItemCount,
RecursiveItemCount: c.ItemCount,
ImageTags: imgTags,
UserData: &itemUserDataDTO{
Key: routeID,
ItemID: routeID,
},
}
if backdropURL := h.presignCollectionPoster(ctx, c.BackdropURL); backdropURL != "" {
if h.images != nil {
h.images.RememberSized(routeID, "Backdrop", backdropURL, compatCardImageSize)
}
dto.BackdropImageTags = []string{h.mapper.imageTagSigner.Tag(
imageTagSeed(routeID, "Backdrop", compatCardImageSize, c.BackdropURL, "", time.Time{}),
backdropURL,
)}
}
return dto
}
// presignCollectionPoster resolves a collection artwork reference to a
// fetchable URL. Collection posters are stored as S3 keys in the
// general-purpose bucket (same bucket as library posters); absolute and
// app-relative references pass through untouched (matching the main API's
// presignGPURL semantics).
func (h *ItemsHandler) presignCollectionPoster(ctx context.Context, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "/") {
return path
}
if h.posterPresigner == nil {
return ""
}
ttl := h.presignTTL
if ttl <= 0 {
ttl = 4 * time.Hour
}
url, err := h.posterPresigner.PresignGetURL(ctx, h.posterPresigner.Bucket(), path, ttl)
if err != nil {
return ""
}
return url
}
// boxSetsByIDs maps the given collection IDs to BoxSet DTOs, skipping any the
// session may not see. Used by /Items?Ids= re-hydration.
func (h *ItemsHandler) boxSetsByIDs(ctx context.Context, session *Session, collectionIDs []string) ([]baseItemDTO, error) {
if len(collectionIDs) == 0 || h.collections == nil {
return nil, nil
}
items := make([]baseItemDTO, 0, len(collectionIDs))
for _, id := range collectionIDs {
collection, err := h.loadVisibleCollection(ctx, session, id)
if err != nil {
return nil, err
}
if collection == nil {
continue
}
items = append(items, h.boxSetFromCollection(ctx, collection))
}
return items, nil
}
// handleBoxSetsList serves GET /Items with IncludeItemTypes=BoxSet by listing
// visible library collections, optionally scoped to one library via ParentId.
// Filtering, sorting, and paging happen on the lightweight collection rows;
// DTOs (with artwork presigning) are built only for the returned page.
func (h *ItemsHandler) handleBoxSetsList(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
if h.collections == nil {
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
return
}
visible, err := h.visibleLibraryIDs(r.Context(), session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
var libFilter *int
if query.parentLibraryID > 0 {
if _, ok := visible[query.parentLibraryID]; !ok {
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
return
}
libFilter = &query.parentLibraryID
}
collections, err := h.collections.ListAll(r.Context(), libFilter, catalog.ListLibraryCollectionsOptions{})
if err != nil {
writeCompatUpstreamError(w, err)
return
}
searchTerm := strings.ToLower(strings.TrimSpace(query.searchTerm))
namePrefix := strings.ToLower(query.namePrefix)
matched := make([]*models.LibraryCollection, 0, len(collections))
for _, c := range collections {
if !collectionVisible(c, visible) {
continue
}
title := strings.ToLower(c.Title)
if searchTerm != "" && !strings.Contains(title, searchTerm) {
continue
}
if namePrefix != "" && !strings.HasPrefix(title, namePrefix) {
continue
}
matched = append(matched, c)
}
if query.sort == "sort_title" {
ascending := query.order != "desc"
sort.SliceStable(matched, func(i, j int) bool {
a, b := strings.ToLower(matched[i].Title), strings.ToLower(matched[j].Title)
if ascending {
return a < b
}
return a > b
})
}
page := slicePage(matched, query.startIndex, query.limit)
items := make([]baseItemDTO, 0, len(page))
for _, c := range page {
items = append(items, h.boxSetFromCollection(r.Context(), c))
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(matched),
StartIndex: query.startIndex,
})
}
// slicePage returns the [startIndex, startIndex+limit) window of items;
// limit <= 0 means no cap.
func slicePage[T any](items []T, startIndex, limit int) []T {
if startIndex < 0 {
startIndex = 0
}
if startIndex >= len(items) {
return nil
}
if limit <= 0 {
limit = len(items)
}
end := min(startIndex+limit, len(items))
return items[startIndex:end]
}
// handleBoxSetItem serves GET /Items/{id} when the ID decodes as a collection.
func (h *ItemsHandler) handleBoxSetItem(w http.ResponseWriter, r *http.Request, session *Session, collectionID string) {
collection, err := h.loadVisibleCollection(r.Context(), session, collectionID)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if collection == nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
writeJSON(w, http.StatusOK, h.boxSetFromCollection(r.Context(), collection))
}
// handleBoxSetChildren serves GET /Items?ParentId={boxsetId} by hydrating the
// collection's members. Without an explicit SortBy the curated collection
// position order is preserved; an explicit SortBy delegates ordering and
// paging to the catalog browse path.
func (h *ItemsHandler) handleBoxSetChildren(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
collection, err := h.loadVisibleCollection(r.Context(), session, query.parentCollectionID)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if collection == nil {
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
return
}
members, err := h.collections.ListItems(r.Context(), collection.ID)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
contentIDs := make([]string, 0, len(members))
for _, member := range members {
contentIDs = append(contentIDs, member.MediaItemID)
}
if len(contentIDs) == 0 {
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
return
}
routeID := h.codec.EncodeStringID(EncodedIDCollection, collection.ID)
if query.sortExplicit {
// Catalog handles ordering and paging; the member list acts as an
// access-filtered allowlist.
params := buildBrowseParams(query)
params.Set("content_ids", strings.Join(contentIDs, ","))
result, browseErr := h.content.BrowseItems(r.Context(), session, params)
if browseErr != nil {
writeCompatUpstreamError(w, browseErr)
return
}
h.writeCollectionItemsPage(w, r, session, query, routeID, result.Items, result.Total)
return
}
// Position order: hydrate the surviving members (collections are capped
// well below the browse limit), rebuild curated order, then page locally
// before building DTOs.
itemsByID, err := h.fetchCompatItemsByContentIDs(r.Context(), session, contentIDs, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
ordered := make([]upstreamListItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
if item, ok := itemsByID[contentID]; ok {
ordered = append(ordered, item)
}
}
page := slicePage(ordered, query.startIndex, query.limit)
h.writeCollectionItemsPage(w, r, session, query, routeID, page, len(ordered))
}
// writeCollectionItemsPage hydrates user state for one page of collection
// members and writes the /Items result with ParentId stamped on each child.
func (h *ItemsHandler) writeCollectionItemsPage(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery, routeID string, listItems []upstreamListItem, total int) {
h.rememberListImages(listItems)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDsFromListItems(listItems))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(listItems))
for _, item := range listItems {
dto := h.mapper.itemFromList(item, favorites[item.ContentID], progress[item.ContentID], query.requestedFields)
dto.ParentID = routeID
items = append(items, dto)
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: total,
StartIndex: query.startIndex,
})
}
@@ -0,0 +1,376 @@
package jellycompat
import (
"context"
"encoding/json"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeCollectionSource is an in-memory collectionSource.
type fakeCollectionSource struct {
collections []*models.LibraryCollection
items map[string][]*models.LibraryCollectionItem
listAllLib *int
}
func (f *fakeCollectionSource) ListAll(_ context.Context, libraryID *int, _ catalog.ListLibraryCollectionsOptions) ([]*models.LibraryCollection, error) {
f.listAllLib = libraryID
out := make([]*models.LibraryCollection, 0, len(f.collections))
for _, c := range f.collections {
if libraryID != nil && !collectionInLibrary(c, *libraryID) {
continue
}
if c.Visibility != "visible" {
continue
}
out = append(out, c)
}
return out, nil
}
func collectionInLibrary(c *models.LibraryCollection, libraryID int) bool {
if len(c.LibraryIDs) == 0 {
return c.LibraryID == libraryID
}
for _, id := range c.LibraryIDs {
if id == libraryID {
return true
}
}
return false
}
func (f *fakeCollectionSource) GetByID(_ context.Context, id string) (*models.LibraryCollection, error) {
for _, c := range f.collections {
if c.ID == id {
return c, nil
}
}
return nil, nil
}
func (f *fakeCollectionSource) ListItems(_ context.Context, collectionID string) ([]*models.LibraryCollectionItem, error) {
return f.items[collectionID], nil
}
// librariesContentService serves a fixed library list; other methods panic.
type librariesContentService struct {
countingContentService
libraries []upstreamUserLibrary
}
func (s *librariesContentService) ListUserLibraries(context.Context, *Session) ([]upstreamUserLibrary, error) {
out := make([]upstreamUserLibrary, len(s.libraries))
copy(out, s.libraries)
return out, nil
}
// fakeBatchItemRepo backs the compatPool()==nil hydration fallback.
type fakeBatchItemRepo struct {
items map[string]*models.MediaItem
}
func (f *fakeBatchItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
return f.byIDs(contentIDs), nil
}
func (f *fakeBatchItemRepo) GetByIDsWithAccess(_ context.Context, contentIDs []string, _ catalog.AccessFilter) ([]*models.MediaItem, error) {
return f.byIDs(contentIDs), nil
}
func (f *fakeBatchItemRepo) GetItemsInLibrary(_ context.Context, contentIDs []string, _ int) (map[string]bool, error) {
out := make(map[string]bool, len(contentIDs))
for _, id := range contentIDs {
out[id] = true
}
return out, nil
}
func (f *fakeBatchItemRepo) byIDs(contentIDs []string) []*models.MediaItem {
out := make([]*models.MediaItem, 0, len(contentIDs))
for _, id := range contentIDs {
if item, ok := f.items[id]; ok {
out = append(out, item)
}
}
return out
}
func newCollectionsTestHandler(collections *fakeCollectionSource, libraries []upstreamUserLibrary, itemRepo itemRepoForBatchLoader) *ItemsHandler {
codec := NewResourceIDCodec()
return &ItemsHandler{
content: &librariesContentService{libraries: libraries},
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
collections: collections,
itemRepo: itemRepo,
}
}
func collectionsTestSession() *Session {
return &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
}
func performItemsRequest(t *testing.T, h *ItemsHandler, target string) queryResultDTO {
t.Helper()
req := httptest.NewRequest("GET", target, nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, collectionsTestSession()))
rec := httptest.NewRecorder()
h.HandleItems(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var result queryResultDTO
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
return result
}
func TestHandleItems_BoxSetListingFiltersHiddenLibraries(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible", ItemCount: 3},
{ID: "102", LibraryID: 2, Title: "Audiobook Picks", Visibility: "visible", ItemCount: 5},
{ID: "103", LibraryID: 1, Title: "Hidden", Visibility: "hidden", ItemCount: 2},
},
}
// Library 2 is not visible (e.g. audiobook library, excluded upstream).
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
result := performItemsRequest(t, h, "/Items?IncludeItemTypes=BoxSet&Recursive=true")
if result.TotalRecordCount != 1 || len(result.Items) != 1 {
t.Fatalf("expected exactly the Marvel collection, got %+v", result.Items)
}
item := result.Items[0]
if item.Type != "BoxSet" || item.Name != "Marvel" || !item.IsFolder || item.ChildCount != 3 {
t.Fatalf("unexpected BoxSet DTO: %+v", item)
}
}
func TestHandleItems_BoxSetListingScopedToParentLibrary(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible"},
{ID: "104", LibraryID: 3, Title: "Shows Sets", Visibility: "visible"},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{
{ID: 1, Name: "Movies", Type: "movies"},
{ID: 3, Name: "Shows", Type: "series"},
}, nil)
parentID := h.codec.EncodeIntID(EncodedIDLibrary, 3)
result := performItemsRequest(t, h, "/Items?IncludeItemTypes=BoxSet&ParentId="+parentID)
if len(result.Items) != 1 || result.Items[0].Name != "Shows Sets" {
t.Fatalf("expected library-scoped collection list, got %+v", result.Items)
}
if collections.listAllLib == nil || *collections.listAllLib != 3 {
t.Fatalf("expected ListAll scoped to library 3, got %v", collections.listAllLib)
}
}
func TestHandleItems_BoxSetChildrenPreservePositionOrder(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible"},
},
items: map[string][]*models.LibraryCollectionItem{
"101": {
{CollectionID: "101", MediaItemID: "m-2", Position: 0},
{CollectionID: "101", MediaItemID: "m-1", Position: 1},
{CollectionID: "101", MediaItemID: "m-3", Position: 2},
},
},
}
itemRepo := &fakeBatchItemRepo{items: map[string]*models.MediaItem{
"m-1": {ContentID: "m-1", Type: "movie", Title: "Iron Man"},
"m-2": {ContentID: "m-2", Type: "movie", Title: "Captain America"},
"m-3": {ContentID: "m-3", Type: "movie", Title: "Thor"},
}}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, itemRepo)
parentID := h.codec.EncodeStringID(EncodedIDCollection, "101")
result := performItemsRequest(t, h, "/Items?ParentId="+parentID)
if len(result.Items) != 3 {
t.Fatalf("expected 3 children, got %d", len(result.Items))
}
gotNames := []string{result.Items[0].Name, result.Items[1].Name, result.Items[2].Name}
wantNames := []string{"Captain America", "Iron Man", "Thor"}
for i := range wantNames {
if gotNames[i] != wantNames[i] {
t.Fatalf("expected curated order %v, got %v", wantNames, gotNames)
}
}
if result.Items[0].ParentID != parentID {
t.Fatalf("expected ParentId %s, got %s", parentID, result.Items[0].ParentID)
}
}
func TestHandleItems_BoxSetChildrenPagination(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible"},
},
items: map[string][]*models.LibraryCollectionItem{
"101": {
{CollectionID: "101", MediaItemID: "m-1", Position: 0},
{CollectionID: "101", MediaItemID: "m-2", Position: 1},
{CollectionID: "101", MediaItemID: "m-3", Position: 2},
},
},
}
itemRepo := &fakeBatchItemRepo{items: map[string]*models.MediaItem{
"m-1": {ContentID: "m-1", Type: "movie", Title: "One"},
"m-2": {ContentID: "m-2", Type: "movie", Title: "Two"},
"m-3": {ContentID: "m-3", Type: "movie", Title: "Three"},
}}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, itemRepo)
parentID := h.codec.EncodeStringID(EncodedIDCollection, "101")
result := performItemsRequest(t, h, "/Items?ParentId="+parentID+"&StartIndex=1&Limit=1")
if result.TotalRecordCount != 3 {
t.Fatalf("expected total 3, got %d", result.TotalRecordCount)
}
if len(result.Items) != 1 || result.Items[0].Name != "Two" {
t.Fatalf("expected page [Two], got %+v", result.Items)
}
}
func TestHandleItem_BoxSetDetailAndHiddenCollection(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible", ItemCount: 4, Description: "Heroes"},
{ID: "103", LibraryID: 1, Title: "Hidden", Visibility: "hidden"},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
router := chi.NewRouter()
router.Get("/Items/{id}", h.HandleItem)
routeID := h.codec.EncodeStringID(EncodedIDCollection, "101")
req := httptest.NewRequest("GET", "/Items/"+routeID, nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, collectionsTestSession()))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200 for visible collection, got %d", rec.Code)
}
var dto baseItemDTO
if err := json.Unmarshal(rec.Body.Bytes(), &dto); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if dto.Type != "BoxSet" || dto.Name != "Marvel" || dto.Overview != "Heroes" || dto.ChildCount != 4 {
t.Fatalf("unexpected detail DTO: %+v", dto)
}
hiddenID := h.codec.EncodeStringID(EncodedIDCollection, "103")
req = httptest.NewRequest("GET", "/Items/"+hiddenID, nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, collectionsTestSession()))
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expected 404 for hidden collection, got %d", rec.Code)
}
}
func TestHandleItems_UnmappedTypeFilterReturnsEmpty(t *testing.T) {
h := newCollectionsTestHandler(&fakeCollectionSource{}, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
result := performItemsRequest(t, h, "/Items?IncludeItemTypes=Playlist")
if result.TotalRecordCount != 0 || len(result.Items) != 0 {
t.Fatalf("expected empty result for unmapped type filter, got %+v", result)
}
}
func TestHandleItems_BoxSetWithUserStateFilterReturnsEmpty(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible"},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
// Collections carry no favorite/played state; user-state-filtered BoxSet
// queries must stay empty (jellyfin-web Favorites tab relies on this).
for _, target := range []string{
"/Items?IncludeItemTypes=BoxSet&Filters=IsFavorite",
"/Items?IncludeItemTypes=BoxSet&Filters=IsResumable",
"/Items?IncludeItemTypes=BoxSet&IsPlayed=true",
} {
result := performItemsRequest(t, h, target)
if result.TotalRecordCount != 0 || len(result.Items) != 0 {
t.Fatalf("%s: expected empty result, got %+v", target, result.Items)
}
}
}
func TestHandleItems_CollectionFolderTypeReturnsViews(t *testing.T) {
h := newCollectionsTestHandler(&fakeCollectionSource{}, []upstreamUserLibrary{
{ID: 1, Name: "Movies", Type: "movies"},
{ID: 3, Name: "Shows", Type: "series"},
}, nil)
result := performItemsRequest(t, h, "/Items?IncludeItemTypes=CollectionFolder")
if len(result.Items) != 2 {
t.Fatalf("expected 2 library views, got %+v", result.Items)
}
for _, item := range result.Items {
if item.Type != "CollectionFolder" {
t.Fatalf("expected CollectionFolder items, got %+v", item)
}
}
}
func TestHandleItems_SpecificIdsIncludeBoxSets(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible", ItemCount: 2},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
boxSetID := h.codec.EncodeStringID(EncodedIDCollection, "101")
result := performItemsRequest(t, h, "/Items?Ids="+url.QueryEscape(boxSetID))
if len(result.Items) != 1 || result.Items[0].Type != "BoxSet" || result.Items[0].Name != "Marvel" {
t.Fatalf("expected the BoxSet DTO for Ids= re-hydration, got %+v", result.Items)
}
}
func TestParseItemsQuery_BoxSetFlags(t *testing.T) {
codec := NewResourceIDCodec()
req := httptest.NewRequest("GET", "/Items?IncludeItemTypes=BoxSet", nil)
query := parseItemsQuery(req, codec)
if !query.wantsBoxSets || len(query.itemTypes) != 0 || !query.hasItemTypeFilter {
t.Fatalf("expected BoxSet flags, got %+v", query)
}
collectionID := codec.EncodeStringID(EncodedIDCollection, "555")
req = httptest.NewRequest("GET", "/Items?ParentId="+url.QueryEscape(collectionID), nil)
query = parseItemsQuery(req, codec)
if query.parentCollectionID != "555" {
t.Fatalf("expected parentCollectionID 555, got %q", query.parentCollectionID)
}
if query.sortExplicit {
t.Fatalf("expected sortExplicit=false without SortBy")
}
req = httptest.NewRequest("GET", "/Items?SortBy=SortName", nil)
query = parseItemsQuery(req, codec)
if !query.sortExplicit {
t.Fatalf("expected sortExplicit=true with SortBy")
}
}
+12 -10
View File
@@ -14,16 +14,18 @@ import (
// displayPreferencesDTO mirrors Jellyfin's DisplayPreferences response.
type displayPreferencesDTO struct {
ID string `json:"Id"`
SortBy string `json:"SortBy"`
SortOrder string `json:"SortOrder"`
RememberIndexing bool `json:"RememberIndexing"`
RememberSorting bool `json:"RememberSorting"`
ScrollDirection string `json:"ScrollDirection"`
ShowBackdrop bool `json:"ShowBackdrop"`
ShowSidebar bool `json:"ShowSidebar"`
Client string `json:"Client"`
CustomPrefs map[string]string `json:"CustomPrefs"`
ID string `json:"Id"`
SortBy string `json:"SortBy"`
SortOrder string `json:"SortOrder"`
RememberIndexing bool `json:"RememberIndexing"`
RememberSorting bool `json:"RememberSorting"`
ScrollDirection string `json:"ScrollDirection"`
ShowBackdrop bool `json:"ShowBackdrop"`
ShowSidebar bool `json:"ShowSidebar"`
PrimaryImageHeight int `json:"PrimaryImageHeight"`
PrimaryImageWidth int `json:"PrimaryImageWidth"`
Client string `json:"Client"`
CustomPrefs map[string]string `json:"CustomPrefs"`
}
// DisplayPreferencesHandler serves Jellyfin display preferences endpoints,
@@ -0,0 +1,27 @@
package jellycompat
import (
"encoding/json"
"testing"
)
func TestDefaultDisplayPreferencesIncludesRequiredImageDimensions(t *testing.T) {
dto := defaultDisplayPreferences("default", "Wholphin")
body, err := json.Marshal(dto)
if err != nil {
t.Fatalf("marshal default display preferences: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatalf("unmarshal default display preferences: %v", err)
}
if _, ok := raw["PrimaryImageHeight"]; !ok {
t.Fatal("PrimaryImageHeight missing from display preferences JSON")
}
if _, ok := raw["PrimaryImageWidth"]; !ok {
t.Fatal("PrimaryImageWidth missing from display preferences JSON")
}
}
@@ -0,0 +1,64 @@
package jellycompat
import (
"context"
"encoding/json"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/config"
)
// genresContentService serves fixed genre filters; other methods panic.
type genresContentService struct {
countingContentService
genres []string
}
func (s *genresContentService) ListItemFilters(context.Context, *Session, url.Values) (*upstreamItemFiltersResponse, error) {
return &upstreamItemFiltersResponse{Genres: s.genres}, nil
}
func TestHandleGenreByName(t *testing.T) {
codec := NewResourceIDCodec()
h := &ItemsHandler{
content: &genresContentService{genres: []string{"Action", "Science Fiction"}},
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
router := chi.NewRouter()
router.Get("/Genres/{name}", h.HandleGenreByName)
req := httptest.NewRequest("GET", "/Genres/"+url.PathEscape("science fiction"), nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "p1"}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var dto baseItemDTO
if err := json.Unmarshal(rec.Body.Bytes(), &dto); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if dto.Type != "Genre" || dto.Name != "Science Fiction" {
t.Fatalf("expected canonical genre DTO, got %+v", dto)
}
if dto.ID != codec.EncodeStringID(EncodedIDGenre, "Science Fiction") {
t.Fatalf("genre ID does not round-trip: %q", dto.ID)
}
req = httptest.NewRequest("GET", "/Genres/Nope", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "p1"}))
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expected 404 for unknown genre, got %d", rec.Code)
}
}
+156 -15
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -16,7 +17,6 @@ import (
type ImagesHandler struct {
content ContentService
codec *ResourceIDCodec
httpClient *http.Client
sessions *SessionStore
images *ImageCache
personRepo *catalog.PersonRepository
@@ -29,6 +29,10 @@ type ImagesHandler struct {
posterSigner LibraryPosterPresigner
presignTTL time.Duration
imageTags *imageTagSigner
httpClient *http.Client
// collections is optional; when set, BoxSet (library collection) artwork
// resolves durably instead of depending on the in-memory image cache.
collections collectionSource
}
type imageItemRepository interface {
@@ -48,15 +52,14 @@ type imageFolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
// NewImagesHandler creates an image proxy handler.
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler {
// NewImagesHandler creates a Jellyfin-compatible image route handler.
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string, httpClient *http.Client) *ImagesHandler {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &ImagesHandler{
content: content,
codec: codec,
httpClient: httpClient,
sessions: sessions,
images: images,
personRepo: personRepo,
@@ -69,6 +72,7 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient
posterSigner: posterSigner,
presignTTL: presignTTL,
imageTags: newImageTagSigner(imageTagSecret),
httpClient: httpClient,
}
}
@@ -80,6 +84,10 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
imageType := chiURLParam(r, "imageType")
imageSize := compatRequestImageSize(r, imageType)
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if canonicalRouteID, ok := canonicalCompatImageRouteID(h.codec, routeID); ok {
routeID = canonicalRouteID
r = withCompatImageProxyRouteRequest(r)
}
if tag != "" {
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
if err != nil {
@@ -88,15 +96,15 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
}
if ok {
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
h.proxyImageURL(w, r, imageURL.URL)
h.serveImageURL(w, r, imageURL.URL)
return
}
if imageURL, ok := h.images.LookupTag(tag); ok {
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
return
}
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
return
}
@@ -116,6 +124,11 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
return
}
if collectionID, err := h.codec.DecodeStringID(EncodedIDCollection, routeID); err == nil {
h.handleCollectionImage(w, r, session, routeID, imageType, imageSize, collectionID)
return
}
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
@@ -133,7 +146,7 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
return
}
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
}
// handlePersonImage serves person photo images.
@@ -159,7 +172,7 @@ func (h *ImagesHandler) handlePersonImage(w http.ResponseWriter, r *http.Request
return
}
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
}
func (h *ImagesHandler) resolveItemImageURL(ctx context.Context, session *Session, contentID, imageType string, r *http.Request) (catalog.ResolvedImageURL, error) {
@@ -253,6 +266,9 @@ func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID,
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, routeID); err == nil {
return h.resolveLibraryImageURLFromTag(ctx, routeID, int(libraryID), imageType, imageSize, tag)
}
if collectionID, err := h.codec.DecodeStringID(EncodedIDCollection, routeID); err == nil {
return h.resolveCollectionImageURLFromTag(ctx, routeID, collectionID, imageType, tag)
}
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
@@ -260,6 +276,95 @@ func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID,
return h.resolveItemImageURLFromReposWithoutSession(ctx, routeID, contentID, imageType, imageSize, tag)
}
// collectionArtworkKey returns the stored artwork reference for the requested
// compat image type ("" when the collection has none of that type).
func collectionArtworkKey(c *models.LibraryCollection, imageType string) string {
switch imageType {
case "Primary":
return c.PosterURL
case "Backdrop":
return c.BackdropURL
}
return ""
}
// presignCollectionArtwork resolves a collection artwork reference like the
// main API's presignGPURL: absolute and app-relative references pass through,
// bare keys presign against the general-purpose bucket.
func (h *ImagesHandler) presignCollectionArtwork(ctx context.Context, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "/") {
return path
}
return h.presignLibraryPosterURL(ctx, path)
}
// resolveCollectionImageURLFromTag serves tag-authenticated BoxSet artwork.
// The tag must match the stable seed boxSetFromCollection signs.
func (h *ImagesHandler) resolveCollectionImageURLFromTag(ctx context.Context, routeID, collectionID, imageType, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.collections == nil {
return catalog.ResolvedImageURL{}, false, nil
}
collection, err := h.collections.GetByID(ctx, collectionID)
if err != nil || collection == nil {
return catalog.ResolvedImageURL{}, false, nil
}
key := collectionArtworkKey(collection, imageType)
if key == "" || !h.imageTags.Equal(
imageTagSeed(routeID, imageType, compatCardImageSize, key, "", time.Time{}),
"",
tag,
) {
return catalog.ResolvedImageURL{}, false, nil
}
imageURL := h.presignCollectionArtwork(ctx, key)
if imageURL == "" {
return catalog.ResolvedImageURL{}, false, nil
}
return catalog.ResolvedImageURL{URL: imageURL}, true, nil
}
// handleCollectionImage serves session-authenticated BoxSet artwork, applying
// the same visibility rules as the BoxSet item endpoints.
func (h *ImagesHandler) handleCollectionImage(w http.ResponseWriter, r *http.Request, session *Session, routeID, imageType, imageSize, collectionID string) {
if h.collections == nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
collection, err := h.collections.GetByID(r.Context(), collectionID)
if err != nil {
if errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
writeCompatUpstreamError(w, err)
return
}
if collection == nil || !strings.EqualFold(collection.Visibility, "visible") {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
visible, err := visibleLibraryIDSet(r.Context(), h.content, session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if !collectionVisible(collection, visible) {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
imageURL := h.presignCollectionArtwork(r.Context(), collectionArtworkKey(collection, imageType))
if imageURL == "" {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
h.images.RememberSized(routeID, imageType, imageURL, imageSize)
h.serveImageURL(w, r, imageURL)
}
func (h *ImagesHandler) resolveLibraryImageURLFromTag(ctx context.Context, routeID string, libraryID int, imageType, _ string, tag string) (catalog.ResolvedImageURL, bool, error) {
if imageType != "Primary" || h.folderRepo == nil || h.posterSigner == nil {
return catalog.ResolvedImageURL{}, false, nil
@@ -414,18 +519,45 @@ func firstResolvedImageURL(values ...catalog.ResolvedImageURL) catalog.ResolvedI
return catalog.ResolvedImageURL{}
}
func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
if shouldProxyCompatImageRequest(r) {
h.proxyImageURL(w, r, imageURL)
return
}
h.redirectImageURL(w, r, imageURL)
}
func (h *ImagesHandler) redirectImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
if _, err := parseRemoteImageURL(imageURL); err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
// Do not let clients cache the temporary redirect itself. The object-store
// response can still carry its own cache headers after the client follows it.
setCompatImageRouteNoStore(w.Header())
http.Redirect(w, r, imageURL, http.StatusFound)
}
func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, imageURL, nil)
target, err := parseRemoteImageURL(imageURL)
if err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
for _, header := range []string{"If-None-Match", "If-Modified-Since"} {
if value := r.Header.Get(header); value != "" {
req.Header.Set(header, value)
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
resp, err := h.httpClient.Do(req)
copyConditionalImageRequestHeaders(req.Header, r.Header)
client := h.httpClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
@@ -433,6 +565,15 @@ func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, im
proxyImage(w, resp)
}
func parseRemoteImageURL(imageURL string) (*url.URL, error) {
target, err := url.Parse(imageURL)
if err != nil || target.Scheme == "" || target.Host == "" ||
(target.Scheme != "http" && target.Scheme != "https") {
return nil, errors.New("invalid remote image URL")
}
return target, nil
}
// HandleUserImage returns a deterministic placeholder avatar.
func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())

Some files were not shown because too many files have changed in this diff Show More