Merge branch 'main' of https://github.com/Silo-Server/silo-server
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "a):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user