Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee20310c46 | ||
|
|
81a8ea6417 | ||
|
|
0be9804888 | ||
|
|
61e939c3c6 | ||
|
|
9666bc231e | ||
|
|
fd5f7db015 | ||
|
|
243da4cd0e | ||
|
|
df02c0539f | ||
|
|
afe0e3320e | ||
|
|
d237506034 | ||
|
|
dc78baae54 | ||
|
|
7190a97de8 | ||
|
|
b5a77598d4 | ||
|
|
05b91b8681 | ||
|
|
1de6a7760b | ||
|
|
b8f5f36acb | ||
|
|
dc55a3cc10 | ||
|
|
0c60402362 | ||
|
|
6e2c710393 | ||
|
|
6665266603 | ||
|
|
78a6b40111 | ||
|
|
ea7a22068f | ||
|
|
fd14b4965f | ||
|
|
2817cca838 | ||
|
|
517fd7441d | ||
|
|
4635afb0a6 | ||
|
|
f86b84a5c2 | ||
|
|
908cc67a0b | ||
|
|
ce5d1ad4a4 | ||
|
|
14f8c4b0be | ||
|
|
39c06320e3 | ||
|
|
39d8ee804e | ||
|
|
78cd8ef7e0 | ||
|
|
ceb91f9432 | ||
|
|
235aec2f0d | ||
|
|
104c99579d | ||
|
|
322f3eb998 | ||
|
|
3852bc6f56 | ||
|
|
191524fd30 | ||
|
|
1125a7f1f4 | ||
|
|
ece83ff9ab | ||
|
|
60bf9b3943 | ||
|
|
91b33d97a5 | ||
|
|
689989a01a | ||
|
|
4ee14f56a9 | ||
|
|
532a40d2d1 | ||
|
|
c3193ce6b8 | ||
|
|
13c872fe01 | ||
|
|
2152473023 | ||
|
|
f9a73f8fc7 | ||
|
|
d39ca1354c | ||
|
|
30a071b9dc | ||
|
|
5b7447d5ef | ||
|
|
cfae599476 | ||
|
|
46bf9270df | ||
|
|
8eb1296e6d | ||
|
|
ce52a2a77a | ||
|
|
198d58c463 | ||
|
|
5c56334482 | ||
|
|
d94ab92d90 | ||
|
|
8401ba1162 | ||
|
|
7ddac36785 | ||
|
|
ac1a730a5e | ||
|
|
5577c65096 | ||
|
|
2e2e5e2c61 | ||
|
|
39386b14e5 | ||
|
|
e815fd115f | ||
|
|
ce934dc426 | ||
|
|
53689e86e0 | ||
|
|
0021a2168a | ||
|
|
f8bdd077c3 | ||
|
|
5f83c6e542 | ||
|
|
731da7fbc9 | ||
|
|
a9a324207d | ||
|
|
e002a14412 | ||
|
|
793b012daa | ||
|
|
6fd9656c90 | ||
|
|
20ebfe3793 | ||
|
|
8b4f58f9da | ||
|
|
bf273ab653 | ||
|
|
bf6cdb95f3 | ||
|
|
d5b34fa5cb | ||
|
|
3a5aad7336 | ||
|
|
3c46e08395 | ||
|
|
ad282a8d29 | ||
|
|
787f0c1d54 | ||
|
|
130cf50cd3 | ||
|
|
66713140a1 | ||
|
|
c560a874b7 | ||
|
|
ae4f8a1aa4 | ||
|
|
599d7641bb | ||
|
|
b87dbd80e7 | ||
|
|
aca6d3e9d5 | ||
|
|
35d0d32666 | ||
|
|
a0030fb52f | ||
|
|
18dff94280 | ||
|
|
c84424c9cd | ||
|
|
539448a2d5 | ||
|
|
1d18cfb231 | ||
|
|
eb21b2ed03 | ||
|
|
f70ee9a702 | ||
|
|
fababe1288 | ||
|
|
a2c6f0c86e | ||
|
|
10870d7313 | ||
|
|
54fbf4ad32 | ||
|
|
6570ddc47e | ||
|
|
93d0267e1e | ||
|
|
a1a05cf65f | ||
|
|
9138b67f07 | ||
|
|
eae6a3c68f | ||
|
|
3326c8b223 | ||
|
|
4c5e200b4d | ||
|
|
6e071fc1bc | ||
|
|
cb7ce9d61b | ||
|
|
bf163fda94 | ||
|
|
7f9f68a1c0 | ||
|
|
9ed1acc64f | ||
|
|
91b2bac8b2 | ||
|
|
c4d3e8c4f9 | ||
|
|
a3c6c736ae | ||
|
|
5d4239f7af | ||
|
|
149c2857e5 | ||
|
|
9fe158f0d1 | ||
|
|
3d7bdc8453 | ||
|
|
a6c8fe2ac2 | ||
|
|
f8d19c958b | ||
|
|
e58b14f742 | ||
|
|
9de6fe4312 |
@@ -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/ .
|
||||
|
||||
@@ -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"
|
||||
@@ -943,6 +944,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 +1026,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 +1056,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)
|
||||
@@ -1587,6 +1600,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:** `mapping.go` now sets `CanDownload=false`, so clients no longer attempt downloads. Revisit if a download route is ever implemented. | 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**: `CanDownload=false` until a download route exists.
|
||||
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,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.
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
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/net v0.53.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
@@ -71,7 +72,6 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -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,917 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// EbookReaderProgress is the persisted reader position for one profile and ebook.
|
||||
type EbookReaderProgress struct {
|
||||
UserID int `json:"-"`
|
||||
ProfileID string `json:"-"`
|
||||
ContentID string `json:"content_id"`
|
||||
FileID int `json:"file_id"`
|
||||
Location string `json:"location"`
|
||||
Progress float64 `json:"progress"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EbookReaderProgressStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
|
||||
Upsert(ctx context.Context, progress EbookReaderProgress) error
|
||||
}
|
||||
|
||||
// EbookReaderConfig is persisted reader configuration for one profile and ebook.
|
||||
type EbookReaderConfig struct {
|
||||
UserID int `json:"-"`
|
||||
ProfileID string `json:"-"`
|
||||
ContentID string `json:"content_id"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EbookReaderConfigStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderConfig, error)
|
||||
Upsert(ctx context.Context, config EbookReaderConfig) error
|
||||
}
|
||||
|
||||
// EbookReaderAnnotation is a persisted reader annotation or bookmark.
|
||||
type EbookReaderAnnotation struct {
|
||||
ID string `json:"id"`
|
||||
UserID int `json:"-"`
|
||||
ProfileID string `json:"-"`
|
||||
ContentID string `json:"content_id"`
|
||||
Kind string `json:"kind"`
|
||||
CFIRange string `json:"cfi_range,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
Note string `json:"note"`
|
||||
Style string `json:"style"`
|
||||
Color string `json:"color"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EbookReaderAnnotationStore interface {
|
||||
List(ctx context.Context, userID int, profileID string, contentID string) ([]EbookReaderAnnotation, error)
|
||||
Create(ctx context.Context, annotation EbookReaderAnnotation) error
|
||||
Update(ctx context.Context, annotation EbookReaderAnnotation) (*EbookReaderAnnotation, error)
|
||||
Delete(ctx context.Context, userID int, profileID string, contentID string, annotationID string) error
|
||||
}
|
||||
|
||||
// EbookReaderHandler serves ebook files for the in-app reader.
|
||||
type EbookReaderHandler struct {
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
ProgressStore EbookReaderProgressStore
|
||||
ConfigStore EbookReaderConfigStore
|
||||
AnnotationStore EbookReaderAnnotationStore
|
||||
}
|
||||
|
||||
func NewEbookReaderHandler(authorizer *MediaFileAuthorizer) *EbookReaderHandler {
|
||||
return &EbookReaderHandler{FileAuthorizer: authorizer}
|
||||
}
|
||||
|
||||
// HandleReadFile serves an ebook file inline with byte-range support.
|
||||
func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
if apimw.GetUserID(r.Context()) == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
fileID, err := strconv.Atoi(chi.URLParam(r, "file_id"))
|
||||
if contentID == "" || err != nil || fileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id and file_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.FileAuthorizer.Authorize(r, fileID)
|
||||
if err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := serveEbookInline(w, r, file); err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve ebook file")
|
||||
}
|
||||
}
|
||||
|
||||
type ebookReaderProgressRequest struct {
|
||||
FileID int `json:"file_id"`
|
||||
Location string `json:"location"`
|
||||
Progress float64 `json:"progress"`
|
||||
}
|
||||
|
||||
type ebookReaderConfigRequest struct {
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
type ebookReaderAnnotationRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
CFIRange string `json:"cfi_range"`
|
||||
Location string `json:"location"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
Note string `json:"note"`
|
||||
Style string `json:"style"`
|
||||
Color string `json:"color"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleGetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := h.ProgressStore.Get(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook progress")
|
||||
return
|
||||
}
|
||||
if progress == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, progress)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req ebookReaderProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req.Location = strings.TrimSpace(req.Location)
|
||||
if req.FileID <= 0 || req.Location == "" || req.Progress < 0 || req.Progress > 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "file_id, location, and progress are required")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.FileAuthorizer.Authorize(r, req.FileID)
|
||||
if err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
return
|
||||
}
|
||||
|
||||
progress := EbookReaderProgress{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
FileID: req.FileID,
|
||||
Location: req.Location,
|
||||
Progress: req.Progress,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := h.ProgressStore.Upsert(r.Context(), progress); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save ebook progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, progress)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.ConfigStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader config is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.ConfigStore.Get(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook reader config")
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"config": map[string]any{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.ConfigStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader config is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req ebookReaderConfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if !jsonObject(req.Config) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "config must be a JSON object")
|
||||
return
|
||||
}
|
||||
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
config := EbookReaderConfig{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
Config: req.Config,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := h.ConfigStore.Upsert(r.Context(), config); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save ebook reader config")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, config)
|
||||
}
|
||||
|
||||
func jsonObject(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var value map[string]any
|
||||
return json.Unmarshal(raw, &value) == nil && value != nil
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleListAnnotations(w http.ResponseWriter, r *http.Request) {
|
||||
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.AnnotationStore.List(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook annotations")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleCreateAnnotation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req ebookReaderAnnotationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
annotation, err := buildEbookReaderAnnotation(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
annotation.ID = uuid.NewString()
|
||||
annotation.UserID = userID
|
||||
annotation.ProfileID = profileID
|
||||
annotation.ContentID = contentID
|
||||
annotation.CreatedAt = now
|
||||
annotation.UpdatedAt = now
|
||||
if err := h.AnnotationStore.Create(r.Context(), annotation); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create ebook annotation")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, annotation)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleUpdateAnnotation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
annotationID := strings.TrimSpace(chi.URLParam(r, "annotation_id"))
|
||||
if annotationID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "annotation_id is required")
|
||||
return
|
||||
}
|
||||
var req ebookReaderAnnotationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
annotation, err := buildEbookReaderAnnotationPatch(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
annotation.ID = annotationID
|
||||
annotation.UserID = userID
|
||||
annotation.ProfileID = profileID
|
||||
annotation.ContentID = contentID
|
||||
annotation.UpdatedAt = time.Now().UTC()
|
||||
updated, err := h.AnnotationStore.Update(r.Context(), annotation)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update ebook annotation")
|
||||
return
|
||||
}
|
||||
if updated == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook annotation not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleDeleteAnnotation(w http.ResponseWriter, r *http.Request) {
|
||||
userID, profileID, contentID, ok := h.annotationRequestScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
annotationID := strings.TrimSpace(chi.URLParam(r, "annotation_id"))
|
||||
if annotationID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "annotation_id is required")
|
||||
return
|
||||
}
|
||||
if err := h.AnnotationStore.Delete(r.Context(), userID, profileID, contentID, annotationID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete ebook annotation")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) annotationRequestScope(w http.ResponseWriter, r *http.Request) (int, string, string, bool) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return 0, "", "", false
|
||||
}
|
||||
if h == nil || h.AnnotationStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook annotations are not configured")
|
||||
return 0, "", "", false
|
||||
}
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return 0, "", "", false
|
||||
}
|
||||
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return 0, "", "", false
|
||||
}
|
||||
return userID, profileID, contentID, true
|
||||
}
|
||||
|
||||
func buildEbookReaderAnnotation(req ebookReaderAnnotationRequest) (EbookReaderAnnotation, error) {
|
||||
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
||||
if kind == "" {
|
||||
kind = "highlight"
|
||||
}
|
||||
if kind != "highlight" && kind != "note" && kind != "bookmark" {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("kind must be highlight, note, or bookmark")
|
||||
}
|
||||
cfiRange := strings.TrimSpace(req.CFIRange)
|
||||
location := strings.TrimSpace(req.Location)
|
||||
if kind == "bookmark" {
|
||||
if location == "" {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("location is required for bookmarks")
|
||||
}
|
||||
} else if cfiRange == "" {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("cfi_range is required for annotations")
|
||||
}
|
||||
metadata := req.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
if !jsonObject(metadata) {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("metadata must be a JSON object")
|
||||
}
|
||||
style := strings.TrimSpace(req.Style)
|
||||
if style == "" {
|
||||
style = "highlight"
|
||||
}
|
||||
color := strings.TrimSpace(req.Color)
|
||||
if color == "" {
|
||||
color = "#facc15"
|
||||
}
|
||||
return EbookReaderAnnotation{
|
||||
Kind: kind,
|
||||
CFIRange: cfiRange,
|
||||
Location: location,
|
||||
SelectedText: strings.TrimSpace(req.SelectedText),
|
||||
Note: strings.TrimSpace(req.Note),
|
||||
Style: style,
|
||||
Color: color,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildEbookReaderAnnotationPatch(req ebookReaderAnnotationRequest) (EbookReaderAnnotation, error) {
|
||||
metadata := req.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
if !jsonObject(metadata) {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("metadata must be a JSON object")
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
||||
if kind != "" && kind != "highlight" && kind != "note" && kind != "bookmark" {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("kind must be highlight, note, or bookmark")
|
||||
}
|
||||
return EbookReaderAnnotation{
|
||||
Kind: kind,
|
||||
CFIRange: strings.TrimSpace(req.CFIRange),
|
||||
Location: strings.TrimSpace(req.Location),
|
||||
SelectedText: strings.TrimSpace(req.SelectedText),
|
||||
Note: strings.TrimSpace(req.Note),
|
||||
Style: strings.TrimSpace(req.Style),
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) writeReadError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize ebook file")
|
||||
}
|
||||
}
|
||||
|
||||
func serveEbookInline(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
|
||||
f, err := os.Open(file.FilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return fmt.Errorf("opening ebook file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat ebook file: %w", err)
|
||||
}
|
||||
|
||||
filename := sanitizeInlineFilename(filepath.Base(file.FilePath))
|
||||
w.Header().Set("Content-Type", ebookMimeType(file.FilePath, file.Container))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename))
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEbookFile(file *models.MediaFile) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(file.BaseType, "ebook") {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(file.FilePath), ".fb2.zip") {
|
||||
return true
|
||||
}
|
||||
return isEbookReaderFormat(file.Container) || isEbookReaderFormat(filepath.Ext(file.FilePath))
|
||||
}
|
||||
|
||||
func isEbookReaderFormat(value string) bool {
|
||||
format := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
|
||||
switch format {
|
||||
case "epub", "pdf", "mobi", "azw", "azw3", "cbz", "cbr", "fb2", "fbz", "md":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ebookMimeType(path, container string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if strings.HasSuffix(strings.ToLower(path), ".fb2.zip") {
|
||||
ext = ".fbz"
|
||||
} else if ext == "" && container != "" {
|
||||
ext = "." + strings.TrimPrefix(strings.ToLower(container), ".")
|
||||
}
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return "application/epub+zip"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".mobi":
|
||||
return "application/x-mobipocket-ebook"
|
||||
case ".azw":
|
||||
return "application/vnd.amazon.ebook"
|
||||
case ".azw3":
|
||||
return "application/vnd.amazon.mobi8-ebook"
|
||||
case ".cbz":
|
||||
return "application/vnd.comicbook+zip"
|
||||
case ".cbr":
|
||||
return "application/vnd.comicbook-rar"
|
||||
case ".fb2":
|
||||
return "application/x-fictionbook+xml"
|
||||
case ".fbz":
|
||||
return "application/x-zip-compressed-fb2"
|
||||
case ".md":
|
||||
return "text/markdown; charset=utf-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeInlineFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
return "ebook"
|
||||
}
|
||||
name = strings.ReplaceAll(name, `"`, "")
|
||||
name = strings.ReplaceAll(name, "\n", " ")
|
||||
name = strings.ReplaceAll(name, "\r", " ")
|
||||
return name
|
||||
}
|
||||
|
||||
type PGEbookReaderProgressStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGEbookReaderProgressStore(pool *pgxpool.Pool) *PGEbookReaderProgressStore {
|
||||
return &PGEbookReaderProgressStore{pool: pool}
|
||||
}
|
||||
|
||||
type PGEbookReaderConfigStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGEbookReaderConfigStore(pool *pgxpool.Pool) *PGEbookReaderConfigStore {
|
||||
return &PGEbookReaderConfigStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderConfigStore) Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderConfig, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader config store is not configured")
|
||||
}
|
||||
var config EbookReaderConfig
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT user_id, profile_id, content_id, config, updated_at
|
||||
FROM ebook_reader_config
|
||||
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
|
||||
userID, profileID, contentID,
|
||||
).Scan(
|
||||
&config.UserID,
|
||||
&config.ProfileID,
|
||||
&config.ContentID,
|
||||
&config.Config,
|
||||
&config.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ebook reader config: %w", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderConfigStore) Upsert(ctx context.Context, config EbookReaderConfig) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return fmt.Errorf("ebook reader config store is not configured")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ebook_reader_config
|
||||
(user_id, profile_id, content_id, config, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5)
|
||||
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
|
||||
config = EXCLUDED.config,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
config.UserID,
|
||||
config.ProfileID,
|
||||
config.ContentID,
|
||||
config.Config,
|
||||
config.UpdatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("upsert ebook reader config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PGEbookReaderAnnotationStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGEbookReaderAnnotationStore(pool *pgxpool.Pool) *PGEbookReaderAnnotationStore {
|
||||
return &PGEbookReaderAnnotationStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderAnnotationStore) List(ctx context.Context, userID int, profileID string, contentID string) ([]EbookReaderAnnotation, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader annotation store is not configured")
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, profile_id, content_id, kind,
|
||||
COALESCE(cfi_range, ''), COALESCE(location, ''),
|
||||
selected_text, note, style, color, metadata, created_at, updated_at
|
||||
FROM ebook_reader_annotations
|
||||
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3
|
||||
ORDER BY updated_at DESC`,
|
||||
userID, profileID, contentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ebook reader annotations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []EbookReaderAnnotation
|
||||
for rows.Next() {
|
||||
annotation, err := scanEbookReaderAnnotation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, annotation)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate ebook reader annotations: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderAnnotationStore) Create(ctx context.Context, annotation EbookReaderAnnotation) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return fmt.Errorf("ebook reader annotation store is not configured")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ebook_reader_annotations
|
||||
(id, user_id, profile_id, content_id, kind, cfi_range, location,
|
||||
selected_text, note, style, color, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''),
|
||||
$8, $9, $10, $11, $12::jsonb, $13, $14)`,
|
||||
annotation.ID,
|
||||
annotation.UserID,
|
||||
annotation.ProfileID,
|
||||
annotation.ContentID,
|
||||
annotation.Kind,
|
||||
annotation.CFIRange,
|
||||
annotation.Location,
|
||||
annotation.SelectedText,
|
||||
annotation.Note,
|
||||
annotation.Style,
|
||||
annotation.Color,
|
||||
annotation.Metadata,
|
||||
annotation.CreatedAt,
|
||||
annotation.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create ebook reader annotation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderAnnotationStore) Update(ctx context.Context, annotation EbookReaderAnnotation) (*EbookReaderAnnotation, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader annotation store is not configured")
|
||||
}
|
||||
updated, err := scanEbookReaderAnnotation(s.pool.QueryRow(ctx, `
|
||||
UPDATE ebook_reader_annotations
|
||||
SET kind = COALESCE(NULLIF($5, ''), kind),
|
||||
cfi_range = COALESCE(NULLIF($6, ''), cfi_range),
|
||||
location = COALESCE(NULLIF($7, ''), location),
|
||||
selected_text = COALESCE(NULLIF($8, ''), selected_text),
|
||||
note = COALESCE(NULLIF($9, ''), note),
|
||||
style = COALESCE(NULLIF($10, ''), style),
|
||||
color = COALESCE(NULLIF($11, ''), color),
|
||||
metadata = $12::jsonb,
|
||||
updated_at = $13
|
||||
WHERE id = $1 AND user_id = $2 AND profile_id = $3 AND content_id = $4
|
||||
RETURNING id, user_id, profile_id, content_id, kind,
|
||||
COALESCE(cfi_range, ''), COALESCE(location, ''),
|
||||
selected_text, note, style, color, metadata, created_at, updated_at`,
|
||||
annotation.ID,
|
||||
annotation.UserID,
|
||||
annotation.ProfileID,
|
||||
annotation.ContentID,
|
||||
annotation.Kind,
|
||||
annotation.CFIRange,
|
||||
annotation.Location,
|
||||
annotation.SelectedText,
|
||||
annotation.Note,
|
||||
annotation.Style,
|
||||
annotation.Color,
|
||||
annotation.Metadata,
|
||||
annotation.UpdatedAt,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update ebook reader annotation: %w", err)
|
||||
}
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderAnnotationStore) Delete(ctx context.Context, userID int, profileID string, contentID string, annotationID string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return fmt.Errorf("ebook reader annotation store is not configured")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM ebook_reader_annotations
|
||||
WHERE id = $1 AND user_id = $2 AND profile_id = $3 AND content_id = $4`,
|
||||
annotationID, userID, profileID, contentID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("delete ebook reader annotation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanEbookReaderAnnotation(scanner interface{ Scan(dest ...any) error }) (EbookReaderAnnotation, error) {
|
||||
var annotation EbookReaderAnnotation
|
||||
if err := scanner.Scan(
|
||||
&annotation.ID,
|
||||
&annotation.UserID,
|
||||
&annotation.ProfileID,
|
||||
&annotation.ContentID,
|
||||
&annotation.Kind,
|
||||
&annotation.CFIRange,
|
||||
&annotation.Location,
|
||||
&annotation.SelectedText,
|
||||
&annotation.Note,
|
||||
&annotation.Style,
|
||||
&annotation.Color,
|
||||
&annotation.Metadata,
|
||||
&annotation.CreatedAt,
|
||||
&annotation.UpdatedAt,
|
||||
); err != nil {
|
||||
return EbookReaderAnnotation{}, fmt.Errorf("scan ebook reader annotation: %w", err)
|
||||
}
|
||||
return annotation, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderProgressStore) Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
var progress EbookReaderProgress
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
|
||||
userID, profileID, contentID,
|
||||
).Scan(
|
||||
&progress.UserID,
|
||||
&progress.ProfileID,
|
||||
&progress.ContentID,
|
||||
&progress.FileID,
|
||||
&progress.Location,
|
||||
&progress.Progress,
|
||||
&progress.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ebook reader progress: %w", err)
|
||||
}
|
||||
return &progress, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderProgressStore) ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE user_id = $1 AND profile_id = $2 AND content_id = ANY($3::text[])`,
|
||||
userID, profileID, contentIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ebook reader progress: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
progressByContentID := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for rows.Next() {
|
||||
var progress EbookReaderProgress
|
||||
if err := rows.Scan(
|
||||
&progress.UserID,
|
||||
&progress.ProfileID,
|
||||
&progress.ContentID,
|
||||
&progress.FileID,
|
||||
&progress.Location,
|
||||
&progress.Progress,
|
||||
&progress.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan ebook reader progress: %w", err)
|
||||
}
|
||||
progressByContentID[progress.ContentID] = progress
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate ebook reader progress: %w", err)
|
||||
}
|
||||
return progressByContentID, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookReaderProgress) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ebook_reader_progress
|
||||
(user_id, profile_id, content_id, file_id, location, progress, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
|
||||
file_id = EXCLUDED.file_id,
|
||||
location = EXCLUDED.location,
|
||||
progress = EXCLUDED.progress,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
progress.UserID,
|
||||
progress.ProfileID,
|
||||
progress.ContentID,
|
||||
progress.FileID,
|
||||
progress.Location,
|
||||
progress.Progress,
|
||||
progress.UpdatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("upsert ebook reader progress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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
|
||||
created *EbookReaderAnnotation
|
||||
updated *EbookReaderAnnotation
|
||||
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
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Update(_ context.Context, annotation EbookReaderAnnotation) (*EbookReaderAnnotation, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
s.updated = &annotation
|
||||
return &annotation, 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 rr.Body.String() != "2345" {
|
||||
t.Fatalf("body = %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
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": "text/markdown; charset=utf-8",
|
||||
"book.unknown": "application/octet-stream",
|
||||
}
|
||||
|
||||
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 name == "book.unknown" {
|
||||
if isEbookFile(file) {
|
||||
t.Fatal("unknown extension should not be treated as an ebook reader format")
|
||||
}
|
||||
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 TestEbookReaderUpdatesAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
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(`{"note":"updated","color":"#bfdbfe"}`)}
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateAnnotation(rr, req)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@ type ItemsHandler struct {
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
metadataRefreshRequester MetadataRefreshRequester
|
||||
localWatchDispatcher LocalWatchEventDispatcher
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
EventsHub *evt.Hub
|
||||
UserRepo *auth.UserRepository
|
||||
}
|
||||
@@ -114,6 +119,10 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
|
||||
h.localWatchDispatcher = dispatcher
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
|
||||
h.ebookProgressStore = store
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
|
||||
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
|
||||
return
|
||||
@@ -373,8 +382,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)
|
||||
}
|
||||
|
||||
@@ -739,7 +748,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 +862,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,7 +903,7 @@ 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)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
@@ -900,6 +913,7 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
@@ -915,6 +929,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 +941,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 >= 0.9 {
|
||||
continue
|
||||
}
|
||||
ratio := ebookProgress.Progress
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
}
|
||||
case "date_viewed", "plays":
|
||||
@@ -955,6 +979,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 >= 0.9 {
|
||||
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
if ebookViewedAt > viewedAt {
|
||||
viewedAt = ebookViewedAt
|
||||
}
|
||||
}
|
||||
if viewedAt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -964,6 +994,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 >= 0.9 && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if playCount <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -974,6 +1007,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 +1155,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
|
||||
@@ -1134,6 +1183,36 @@ 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 := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: row.Progress,
|
||||
DurationSeconds: 1,
|
||||
IsInProgress: row.Progress < 0.9,
|
||||
Played: row.Progress >= 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
|
||||
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
|
||||
return
|
||||
|
||||
@@ -40,20 +40,86 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEbookReaderProgressLister struct {
|
||||
progress map[string]EbookReaderProgress
|
||||
}
|
||||
|
||||
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 {
|
||||
if progress, ok := f.progress[contentID]; ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2029,18 +2029,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
|
||||
}
|
||||
|
||||
@@ -2093,6 +2083,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type SectionHandler struct {
|
||||
DetailSvc *catalog.DetailService
|
||||
Settings catalog.SettingsStore
|
||||
CollectionRepo *catalog.LibraryCollectionRepository
|
||||
EbookProgress EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewSectionHandler creates a new SectionHandler.
|
||||
@@ -1377,7 +1378,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{}
|
||||
}
|
||||
|
||||
@@ -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 >= 0.9
|
||||
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,50 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -372,7 +372,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 +430,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).
|
||||
@@ -500,6 +526,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
|
||||
@@ -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
|
||||
}
|
||||
@@ -1120,6 +1152,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
|
||||
}
|
||||
@@ -1706,6 +1741,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 {
|
||||
|
||||
@@ -759,9 +759,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 +783,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 +946,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 +954,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
|
||||
|
||||
@@ -390,7 +390,7 @@ func normalizeCatalogMatch(raw string) string {
|
||||
|
||||
func parseCatalogMediaScope(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "movie", "series", "episode", "audiobook":
|
||||
case "movie", "series", "episode", "audiobook", "ebook":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -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 {
|
||||
@@ -1090,8 +1093,9 @@ func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFi
|
||||
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)
|
||||
def.MediaScope != "audiobook" &&
|
||||
def.MediaScope != "ebook" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'ebook'", ErrInvalidCatalogRequest)
|
||||
}
|
||||
if def.Match != "" && def.Match != "all" && def.Match != "any" {
|
||||
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+74
-12
@@ -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 {
|
||||
@@ -1107,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{}
|
||||
@@ -1157,13 +1198,21 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -1216,14 +1265,27 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1236,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
|
||||
|
||||
@@ -76,3 +76,27 @@ func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, path
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,26 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
|
||||
return &ItemRepository{pool: pool}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -50,13 +70,22 @@ const itemColumns = `content_id, type, title, sort_title, default_metadata_langu
|
||||
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,
|
||||
COALESCE(poster_path, ''), COALESCE(poster_thumbhash, ''), COALESCE(backdrop_path, ''), COALESCE(backdrop_thumbhash, ''), COALESCE(logo_path, ''),
|
||||
COALESCE(metadata_s3_path, ''), COALESCE(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`
|
||||
|
||||
func qualifiedNullableStringColumn(alias, col string) string {
|
||||
switch col {
|
||||
case "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag":
|
||||
return fmt.Sprintf("COALESCE(%s.%s, '')", alias, col)
|
||||
default:
|
||||
return alias + "." + col
|
||||
}
|
||||
}
|
||||
|
||||
func qualifiedItemColumns(alias string) string {
|
||||
cols := []string{
|
||||
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
|
||||
@@ -72,7 +101,7 @@ func qualifiedItemColumns(alias string) string {
|
||||
}
|
||||
prefixed := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
prefixed[i] = alias + "." + col
|
||||
prefixed[i] = qualifiedNullableStringColumn(alias, col)
|
||||
}
|
||||
return strings.Join(prefixed, ", ")
|
||||
}
|
||||
@@ -96,7 +125,7 @@ func qualifiedListItemColumns(alias string) string {
|
||||
prefixed[i] = effectiveLastAirDateExpr(alias)
|
||||
continue
|
||||
}
|
||||
prefixed[i] = alias + "." + col
|
||||
prefixed[i] = qualifiedNullableStringColumn(alias, col)
|
||||
}
|
||||
return strings.Join(prefixed, ", ")
|
||||
}
|
||||
|
||||
@@ -159,9 +159,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 +188,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 >= 0.9
|
||||
) src
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_history_hidden_items hhi
|
||||
@@ -195,7 +200,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, argIdx, argIdx+1)
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) BuildSortClause(sortConfig QuerySort) (string, []any, error) {
|
||||
@@ -298,7 +303,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 +391,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 +508,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 +960,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
|
||||
@@ -972,6 +988,24 @@ 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 < 0.9
|
||||
)`, qb.argIdx, qb.argIdx+1, qb.alias)
|
||||
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 +1104,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 +1157,20 @@ 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 >= 0.9
|
||||
)`, qb.argIdx, qb.argIdx+1, qb.alias)
|
||||
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,6 +1394,9 @@ 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,
|
||||
@@ -1371,7 +1425,31 @@ 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 < 0.9
|
||||
THEN erp.progress::double precision
|
||||
ELSE NULL
|
||||
END AS progress_ratio
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
) sort_progress ON sort_progress.content_id = %s.content_id`,
|
||||
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 +1500,30 @@ 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 >= 0.9
|
||||
THEN erp.updated_at
|
||||
ELSE NULL
|
||||
END AS viewed_at
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
) sort_ebook_viewed ON sort_ebook_viewed.content_id = %s.content_id`,
|
||||
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 +1574,26 @@ 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 >= 0.9
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS completed_play_count
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
) sort_ebook_plays ON sort_ebook_plays.content_id = %s.content_id`,
|
||||
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
|
||||
|
||||
@@ -390,6 +390,132 @@ 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"},
|
||||
expectExpr: []string{"sort_progress.progress_ratio"},
|
||||
},
|
||||
{
|
||||
field: "date_viewed",
|
||||
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress >= 0.9"},
|
||||
expectExpr: []string{"sort_ebook_viewed.viewed_at"},
|
||||
},
|
||||
{
|
||||
field: "plays",
|
||||
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress >= 0.9", "completed_play_count"},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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 +657,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 +722,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 +871,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,8 +186,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
normalized.MediaScope != "movie" &&
|
||||
normalized.MediaScope != "series" &&
|
||||
normalized.MediaScope != "episode" &&
|
||||
normalized.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
|
||||
normalized.MediaScope != "audiobook" &&
|
||||
normalized.MediaScope != "ebook" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'ebook'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
@@ -206,6 +207,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 +224,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",
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
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"
|
||||
"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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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) 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 {
|
||||
slog.Warn("ebook enrichment: item failed",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&enriched, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
ch <- item
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
return int(enriched)
|
||||
}
|
||||
|
||||
func (e *Enricher) claimBatch(ctx context.Context) ([]enrichmentItemRow, error) {
|
||||
rows, err := e.pool.Query(ctx, `
|
||||
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
|
||||
WHERE mi.type = 'ebook'
|
||||
AND (mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
AND mi.last_refreshed IS NULL
|
||||
ORDER BY mi.created_at ASC
|
||||
LIMIT $1
|
||||
`, e.batchSize)
|
||||
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 {
|
||||
slog.Debug("ebook enrichment: item has no library folder, skipping",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
return e.stampLastRefreshed(ctx, 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)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Debug("ebook enrichment: no providers in chain",
|
||||
"content_id", item.ContentID,
|
||||
"folder_id", item.FolderID,
|
||||
)
|
||||
return e.stampLastRefreshed(ctx, item.ContentID)
|
||||
}
|
||||
|
||||
searchQuery, accumulatedIDs := buildEbookSearchQuery(item)
|
||||
|
||||
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,
|
||||
)
|
||||
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(accumulatedIDs, item.Language))
|
||||
if getErr != nil {
|
||||
slog.Warn("ebook enrichment: GetMetadata error",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", getErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if result == nil || !result.HasMetadata {
|
||||
continue
|
||||
}
|
||||
mergeEnrichmentProviderIDs(accumulator, result)
|
||||
accumulatedIDs = accumulator.ProviderIDs
|
||||
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 != "",
|
||||
)
|
||||
}
|
||||
|
||||
if !accumulator.HasMetadata && accumulator.PosterPath == "" && accumulator.Overview == "" {
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
_, 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)
|
||||
return 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
|
||||
}
|
||||
return e.itemRepo.ReplacePeople(ctx, contentID, linked)
|
||||
}
|
||||
|
||||
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,340 @@
|
||||
package ebooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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)
|
||||
|
||||
if got := atomic.LoadInt32(&maxInFlight); got < wantWorkers {
|
||||
t.Errorf("max in-flight = %d, want >= %d", got, wantWorkers)
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -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-
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -542,6 +542,23 @@ func (h *ItemsHandler) HandleItemStub(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// HandleThemeSongsStub serves an empty ThemeMediaResult for
|
||||
// /Items/{id}/ThemeSongs. It cannot share HandleItemStub because this
|
||||
// response shape additionally requires OwnerId (see themeMediaResultDTO).
|
||||
func (h *ItemsHandler) HandleThemeSongsStub(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
if session == nil {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, themeMediaResultDTO{
|
||||
Items: []baseItemDTO{},
|
||||
TotalRecordCount: 0,
|
||||
StartIndex: 0,
|
||||
OwnerID: chi.URLParam(r, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
// mediaSegmentDTO mirrors Jellyfin's MediaSegmentDto shape. Times are
|
||||
// expressed in 100-nanosecond ticks (the convention shared with RunTimeTicks
|
||||
// and chapter position fields).
|
||||
|
||||
@@ -366,7 +366,9 @@ func (m *mapper) itemFromDetailWithFields(item upstreamItemDetail, isFavorite bo
|
||||
dto.RunTimeTicks = secondsToTicks(float64(firstVersion.Duration))
|
||||
}
|
||||
dto.DateCreated = formatCompatTime(firstVersion.AddedAt)
|
||||
dto.CanDownload = true
|
||||
// No /Items/{id}/Download route exists; advertising download support
|
||||
// sends clients (e.g. Wholphin's screensaver) into 404s.
|
||||
dto.CanDownload = false
|
||||
dto.HasSubtitles = versionsHaveSubtitles(item.Versions)
|
||||
dto.SupportsSync = false
|
||||
dto.Container = strings.ToLower(firstVersion.Container)
|
||||
|
||||
@@ -27,6 +27,7 @@ var compatPathSegments = map[string]string{
|
||||
"suggestions": "Suggestions",
|
||||
"similar": "Similar",
|
||||
"thememedia": "ThemeMedia",
|
||||
"themesongs": "ThemeSongs",
|
||||
"specialfeatures": "SpecialFeatures",
|
||||
"intros": "Intros",
|
||||
"images": "Images",
|
||||
|
||||
@@ -146,9 +146,11 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/Movies/{id}/Similar", itemsHandler.HandleSimilar)
|
||||
r.Get("/Shows/{id}/Similar", itemsHandler.HandleSimilar)
|
||||
r.Get("/Items/{id}/ThemeMedia", itemsHandler.HandleItemStub)
|
||||
r.Get("/Items/{id}/ThemeSongs", itemsHandler.HandleThemeSongsStub)
|
||||
r.Get("/Items/{id}/SpecialFeatures", itemsHandler.HandleItemStub)
|
||||
r.Get("/Items/{id}/Intros", itemsHandler.HandleItemStub)
|
||||
r.Get("/Users/{userId}/Items/{id}/ThemeMedia", itemsHandler.HandleItemStub)
|
||||
r.Get("/Users/{userId}/Items/{id}/ThemeSongs", itemsHandler.HandleThemeSongsStub)
|
||||
r.Get("/Users/{userId}/Items/{id}/SpecialFeatures", itemsHandler.HandleItemStub)
|
||||
r.Get("/Users/{userId}/Items/{id}/Intros", itemsHandler.HandleItemStub)
|
||||
r.Get("/Items/{id}", itemsHandler.HandleItem)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// TestHandleThemeSongsStub_IncludesOwnerID verifies the empty ThemeMediaResult
|
||||
// carries OwnerId: jellyfin-sdk-kotlin models it as non-nullable, so a plain
|
||||
// query-result envelope would fail client deserialization.
|
||||
func TestHandleThemeSongsStub_IncludesOwnerID(t *testing.T) {
|
||||
h := &ItemsHandler{}
|
||||
|
||||
itemID := "f27caa37e5142225cceded48f6553502"
|
||||
req := httptest.NewRequest("GET", "/Items/"+itemID+"/ThemeSongs", nil)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", itemID)
|
||||
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()
|
||||
h.HandleThemeSongsStub(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Items []json.RawMessage `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
OwnerID *string `json:"OwnerId"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if body.Items == nil || len(body.Items) != 0 || body.TotalRecordCount != 0 {
|
||||
t.Errorf("expected empty Items array; got body=%s", rec.Body.String())
|
||||
}
|
||||
if body.OwnerID == nil || *body.OwnerID != itemID {
|
||||
t.Errorf("expected OwnerId %q; got body=%s", itemID, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -722,6 +722,19 @@ var mediaItemMergeSteps = []mediaItemMergeStep{
|
||||
last_codec_video = CASE WHEN EXCLUDED.updated_at >= user_watch_progress.updated_at THEN EXCLUDED.last_codec_video ELSE user_watch_progress.last_codec_video END,
|
||||
last_edition_key = CASE WHEN EXCLUDED.updated_at >= user_watch_progress.updated_at THEN EXCLUDED.last_edition_key ELSE user_watch_progress.last_edition_key END`},
|
||||
{"delete source progress", `DELETE FROM user_watch_progress WHERE media_item_id = $1`},
|
||||
{"merge ebook reader progress", `
|
||||
INSERT INTO ebook_reader_progress (
|
||||
user_id, profile_id, content_id, file_id, location, progress, updated_at
|
||||
)
|
||||
SELECT user_id, profile_id, $2, file_id, location, progress, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE content_id = $1
|
||||
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE
|
||||
SET file_id = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.file_id ELSE ebook_reader_progress.file_id END,
|
||||
location = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.location ELSE ebook_reader_progress.location END,
|
||||
progress = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.progress ELSE ebook_reader_progress.progress END,
|
||||
updated_at = GREATEST(ebook_reader_progress.updated_at, EXCLUDED.updated_at)`},
|
||||
{"delete source ebook reader progress", `DELETE FROM ebook_reader_progress WHERE content_id = $1`},
|
||||
{"move history", `UPDATE user_watch_history SET media_item_id = $2 WHERE media_item_id = $1`},
|
||||
{"merge hidden history", `
|
||||
INSERT INTO user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before, updated_at)
|
||||
|
||||
@@ -110,6 +110,25 @@ func TestMergeStepPlaceholdersAreBounded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStepsPreserveEbookReaderProgress(t *testing.T) {
|
||||
var hasMerge bool
|
||||
var hasDelete bool
|
||||
for _, step := range mediaItemMergeSteps {
|
||||
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "INSERT INTO") {
|
||||
hasMerge = true
|
||||
}
|
||||
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "DELETE FROM") {
|
||||
hasDelete = true
|
||||
}
|
||||
}
|
||||
if !hasMerge {
|
||||
t.Fatal("media item merge steps should merge ebook_reader_progress rows")
|
||||
}
|
||||
if !hasDelete {
|
||||
t.Fatal("media item merge steps should delete source ebook_reader_progress rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothCandidatesHaveContentDetectsRealUserData(t *testing.T) {
|
||||
withFiles := canonicalCandidate{ContentID: "a", ActiveFiles: 2}
|
||||
withLibraries := canonicalCandidate{ContentID: "b", LibraryCount: 1}
|
||||
|
||||
@@ -111,6 +111,32 @@ func TestApplyMediaTypeFloorNoopsWithoutSupplementalType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMediaTypeFloorIncludesEbookSupplement(t *testing.T) {
|
||||
items := []ScoredItem{
|
||||
{MediaItemID: "s1", Score: 1.00},
|
||||
{MediaItemID: "s2", Score: 0.99},
|
||||
{MediaItemID: "s3", Score: 0.98},
|
||||
{MediaItemID: "s4", Score: 0.97},
|
||||
{MediaItemID: "s5", Score: 0.96},
|
||||
}
|
||||
candidates := append([]ScoredItem(nil), items...)
|
||||
candidates = append(candidates, ScoredItem{MediaItemID: "e1", Score: 0.95})
|
||||
mediaTypes := map[string]string{
|
||||
"s1": "series",
|
||||
"s2": "series",
|
||||
"s3": "series",
|
||||
"s4": "series",
|
||||
"s5": "series",
|
||||
"e1": "ebook",
|
||||
}
|
||||
|
||||
mixed := applyMediaTypeFloor(items, candidates, mediaTypes)
|
||||
|
||||
if got := countMediaType(mixed, mediaTypes, "ebook"); got != 1 {
|
||||
t.Fatalf("expected 1 ebook from supplemental candidates, got %d in %#v", got, mixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGenreCapDoesNotCollapseConcentratedRows(t *testing.T) {
|
||||
items := []ScoredItem{
|
||||
{MediaItemID: "a", Score: 1.0},
|
||||
|
||||
@@ -63,17 +63,20 @@ func BuildEmbeddingText(item *models.MediaItem) string {
|
||||
parts = append(parts, fmt.Sprintf(`"%s"`, item.Tagline))
|
||||
}
|
||||
|
||||
if item.Type == "audiobook" {
|
||||
// Audiobooks credit author (kind=7) and narrator (kind=8). Cast/
|
||||
// director/writer don't apply. Keep the SQL canonical_text builder
|
||||
// in recommendations/repo.go in sync with this shape.
|
||||
if item.Type == "audiobook" || item.Type == "ebook" {
|
||||
// Books credit authors (kind=7). Audiobooks also credit narrators
|
||||
// (kind=8); ebooks do not. Cast/director/writer don't apply. Keep
|
||||
// the SQL canonical_text builder in recommendations/repo.go in sync
|
||||
// with this shape.
|
||||
var authors, narrators []models.ItemPerson
|
||||
for _, p := range item.People {
|
||||
switch p.Kind {
|
||||
case models.PersonKindAuthor:
|
||||
authors = append(authors, p)
|
||||
case models.PersonKindNarrator:
|
||||
narrators = append(narrators, p)
|
||||
if item.Type == "audiobook" {
|
||||
narrators = append(narrators, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortItemPeople(authors)
|
||||
@@ -174,6 +177,8 @@ func mediaTypeLabel(t string) string {
|
||||
return "TV series"
|
||||
case "audiobook":
|
||||
return "audiobook"
|
||||
case "ebook":
|
||||
return "ebook"
|
||||
default:
|
||||
return "movie"
|
||||
}
|
||||
|
||||
@@ -61,6 +61,42 @@ func TestBuildEmbeddingTextAudiobookUsesAuthorAndNarrator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmbeddingTextEbookUsesAuthorWithoutNarratorOrCast(t *testing.T) {
|
||||
item := &models.MediaItem{
|
||||
Title: "A Wizard of Earthsea",
|
||||
Type: "ebook",
|
||||
Year: 1968,
|
||||
Genres: []string{"Fantasy"},
|
||||
Overview: "Ged learns the cost of power.",
|
||||
People: []models.ItemPerson{
|
||||
{Person: models.Person{ID: 1, Name: "Ursula K. Le Guin"}, Kind: models.PersonKindAuthor},
|
||||
{Person: models.Person{ID: 2, Name: "Narrator Should Not Apply"}, Kind: models.PersonKindNarrator},
|
||||
{Person: models.Person{ID: 3, Name: "Actor Should Not Apply"}, Kind: models.PersonKindActor, Character: "Ged"},
|
||||
{Person: models.Person{ID: 4, Name: "Writer Should Not Apply"}, Kind: models.PersonKindWriter},
|
||||
},
|
||||
}
|
||||
|
||||
text := BuildEmbeddingText(item)
|
||||
|
||||
if !strings.Contains(text, "Fantasy ebook about Ged learns the cost of power.") {
|
||||
t.Fatalf("ebook lede missing or wrong: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Written by Ursula K. Le Guin") {
|
||||
t.Fatalf("ebook author missing: %q", text)
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Narrated by",
|
||||
"Narrator Should Not Apply",
|
||||
"Cast:",
|
||||
"Actor Should Not Apply",
|
||||
"Writer Should Not Apply",
|
||||
} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("ebook text should not contain %q: %q", unwanted, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmbeddingTextSortsActorsBeforeTopFive(t *testing.T) {
|
||||
item := &models.MediaItem{
|
||||
Title: "Example",
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
const aggregateMediaTypeFloorDivisor = 5
|
||||
|
||||
var aggregateSupplementMediaTypes = []string{"movie", "series"}
|
||||
var aggregateSupplementMediaTypes = []string{"movie", "series", "ebook"}
|
||||
|
||||
// ForYou returns personalised recommendations grouped by taste clusters.
|
||||
// For cold-start users, non-personalized rows are returned.
|
||||
|
||||
+155
-131
@@ -39,15 +39,53 @@ func ensureCanonicalDimensions(vec []float32) ([]float32, error) {
|
||||
const embeddingLockSettingKey = "recommendations.embedding_lock"
|
||||
const minHNSWEfSearch = 200
|
||||
|
||||
const watchedActivityCTE = `
|
||||
watched_activity AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
wp.media_item_id AS leaf_item_id,
|
||||
wp.user_id,
|
||||
wp.profile_id,
|
||||
wp.user_id::text || ':' || COALESCE(wp.profile_id, '') AS watcher_id,
|
||||
wp.updated_at,
|
||||
wp.completed AS completed
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = wp.user_id
|
||||
AND hhi.profile_id = wp.profile_id
|
||||
AND hhi.media_item_id = wp.media_item_id
|
||||
AND wp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
UNION ALL
|
||||
SELECT erp.content_id AS item_id,
|
||||
erp.content_id AS leaf_item_id,
|
||||
erp.user_id,
|
||||
erp.profile_id,
|
||||
erp.user_id::text || ':' || COALESCE(erp.profile_id, '') AS watcher_id,
|
||||
erp.updated_at,
|
||||
erp.progress >= 0.9 AS completed
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.progress >= 0.5
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = erp.user_id
|
||||
AND hhi.profile_id = erp.profile_id
|
||||
AND hhi.media_item_id = erp.content_id
|
||||
AND erp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
)`
|
||||
|
||||
const tasteSeedCandidateQuery = `
|
||||
WITH watched_counts AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND wp.updated_at > NOW() - INTERVAL '180 days'
|
||||
GROUP BY 1
|
||||
WITH ` + watchedActivityCTE + `,
|
||||
watched_counts AS (
|
||||
SELECT item_id, COUNT(DISTINCT watcher_id) AS watch_count
|
||||
FROM watched_activity
|
||||
WHERE updated_at > NOW() - INTERVAL '180 days'
|
||||
GROUP BY item_id
|
||||
)
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
@@ -120,6 +158,18 @@ type EmbeddingTextCandidate struct {
|
||||
CanonicalText string
|
||||
}
|
||||
|
||||
func embeddingEligibilityWhereClause() string {
|
||||
return recommendationItemEligibilityWhereClause("mi")
|
||||
}
|
||||
|
||||
func recommendationItemEligibilityWhereClause(alias string) string {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
alias = "media_items"
|
||||
}
|
||||
return fmt.Sprintf("(%s.status = 'matched' OR %s.type = 'audiobook' OR %s.type = 'ebook')", alias, alias, alias)
|
||||
}
|
||||
|
||||
// UpsertEmbedding stores or updates an embedding for a media item.
|
||||
func (r *Repo) UpsertEmbedding(ctx context.Context, itemID string, embedding []float32, model, canonicalText string) error {
|
||||
padded, err := ensureCanonicalDimensions(embedding)
|
||||
@@ -416,18 +466,18 @@ func (r *Repo) findTasteProfileCandidates(
|
||||
|
||||
// ItemsNeedingEmbedding returns content IDs of media items that either have no
|
||||
// embedding or whose embedding was generated with a different model.
|
||||
// Audiobooks bypass the status='matched' gate because they don't go through
|
||||
// the external-provider match pipeline; their tag-derived metadata is
|
||||
// authoritative as soon as the scan completes.
|
||||
// Books bypass the status='matched' gate because their scanner/plugin-derived
|
||||
// metadata is authoritative as soon as the scan/enrichment completes.
|
||||
func (r *Repo) ItemsNeedingEmbedding(ctx context.Context, currentModel string, limit int) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
query := fmt.Sprintf(`
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN media_item_embeddings e ON e.media_item_id = mi.content_id
|
||||
WHERE (mi.status = 'matched' OR mi.type = 'audiobook')
|
||||
WHERE %s
|
||||
AND (e.media_item_id IS NULL OR e.model != $1)
|
||||
LIMIT $2
|
||||
`, currentModel, limit)
|
||||
`, embeddingEligibilityWhereClause())
|
||||
rows, err := r.pool.Query(ctx, query, currentModel, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("items needing embedding: %w", err)
|
||||
}
|
||||
@@ -448,11 +498,11 @@ func (r *Repo) ItemsNeedingEmbedding(ctx context.Context, currentModel string, l
|
||||
}
|
||||
|
||||
func (r *Repo) ListEmbeddingTextCandidates(ctx context.Context, afterID, currentModel string, limit int) ([]EmbeddingTextCandidate, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
query := fmt.Sprintf(`
|
||||
-- Keep current_text in sync with embeddings.BuildEmbeddingText. This lets
|
||||
-- the embedding job page over only missing, model-stale, or text-stale rows.
|
||||
-- Audiobook lines map to embeddings.mediaTypeLabel + the author/narrator
|
||||
-- branch in BuildEmbeddingText; any divergence here forces every audiobook
|
||||
-- Book lines map to embeddings.mediaTypeLabel + the author/narrator
|
||||
-- branch in BuildEmbeddingText; any divergence here forces book items
|
||||
-- to re-embed every job run.
|
||||
WITH text_candidates AS (
|
||||
SELECT mi.content_id,
|
||||
@@ -461,11 +511,11 @@ func (r *Repo) ListEmbeddingTextCandidates(ctx context.Context, afterID, current
|
||||
array_to_string(array_remove(ARRAY[
|
||||
CASE
|
||||
WHEN cardinality(COALESCE(mi.genres, ARRAY[]::text[])) > 0 AND COALESCE(mi.overview, '') <> ''
|
||||
THEN array_to_string(mi.genres, ', ') || ' ' || CASE WHEN mi.type = 'series' THEN 'TV series' WHEN mi.type = 'audiobook' THEN 'audiobook' ELSE 'movie' END || ' about ' || substr(mi.overview, 1, 1000)
|
||||
THEN array_to_string(mi.genres, ', ') || ' ' || CASE WHEN mi.type = 'series' THEN 'TV series' WHEN mi.type = 'audiobook' THEN 'audiobook' WHEN mi.type = 'ebook' THEN 'ebook' ELSE 'movie' END || ' about ' || substr(mi.overview, 1, 1000)
|
||||
WHEN cardinality(COALESCE(mi.genres, ARRAY[]::text[])) > 0
|
||||
THEN array_to_string(mi.genres, ', ') || ' ' || CASE WHEN mi.type = 'series' THEN 'TV series' WHEN mi.type = 'audiobook' THEN 'audiobook' ELSE 'movie' END
|
||||
THEN array_to_string(mi.genres, ', ') || ' ' || CASE WHEN mi.type = 'series' THEN 'TV series' WHEN mi.type = 'audiobook' THEN 'audiobook' WHEN mi.type = 'ebook' THEN 'ebook' ELSE 'movie' END
|
||||
WHEN COALESCE(mi.overview, '') <> ''
|
||||
THEN CASE WHEN mi.type = 'series' THEN 'TV series' WHEN mi.type = 'audiobook' THEN 'audiobook' ELSE 'movie' END || '. ' || substr(mi.overview, 1, 1000)
|
||||
THEN CASE WHEN mi.type = 'series' THEN 'TV series' WHEN mi.type = 'audiobook' THEN 'audiobook' WHEN mi.type = 'ebook' THEN 'ebook' ELSE 'movie' END || '. ' || substr(mi.overview, 1, 1000)
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE WHEN COALESCE(mi.year, 0) > 0
|
||||
@@ -474,11 +524,11 @@ func (r *Repo) ListEmbeddingTextCandidates(ctx context.Context, afterID, current
|
||||
END,
|
||||
CASE WHEN COALESCE(mi.content_rating, '') <> '' THEN 'Rated ' || mi.content_rating ELSE NULL END,
|
||||
CASE WHEN COALESCE(mi.tagline, '') <> '' THEN '"' || mi.tagline || '"' ELSE NULL END,
|
||||
CASE WHEN mi.type <> 'audiobook' AND actors.names <> '' THEN 'Cast: ' || actors.names ELSE NULL END,
|
||||
CASE WHEN mi.type <> 'audiobook' AND directors.names <> '' THEN 'Directed by ' || directors.names ELSE NULL END,
|
||||
CASE WHEN mi.type NOT IN ('audiobook', 'ebook') AND actors.names <> '' THEN 'Cast: ' || actors.names ELSE NULL END,
|
||||
CASE WHEN mi.type NOT IN ('audiobook', 'ebook') AND directors.names <> '' THEN 'Directed by ' || directors.names ELSE NULL END,
|
||||
CASE
|
||||
WHEN mi.type = 'audiobook' AND authors.names <> '' THEN 'Written by ' || authors.names
|
||||
WHEN mi.type <> 'audiobook' AND writers.names <> '' THEN 'Written by ' || writers.names
|
||||
WHEN mi.type IN ('audiobook', 'ebook') AND authors.names <> '' THEN 'Written by ' || authors.names
|
||||
WHEN mi.type NOT IN ('audiobook', 'ebook') AND writers.names <> '' THEN 'Written by ' || writers.names
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE WHEN mi.type = 'audiobook' AND narrators.names <> '' THEN 'Narrated by ' || narrators.names ELSE NULL END,
|
||||
@@ -536,7 +586,7 @@ func (r *Repo) ListEmbeddingTextCandidates(ctx context.Context, afterID, current
|
||||
WHERE ip.content_id = mi.content_id
|
||||
AND ip.kind = 8
|
||||
) narrators ON TRUE
|
||||
WHERE (mi.status = 'matched' OR mi.type = 'audiobook')
|
||||
WHERE %s
|
||||
AND ($1 = '' OR mi.content_id > $1)
|
||||
)
|
||||
SELECT mi.content_id,
|
||||
@@ -548,7 +598,8 @@ func (r *Repo) ListEmbeddingTextCandidates(ctx context.Context, afterID, current
|
||||
OR mi.canonical_text IS DISTINCT FROM mi.current_text
|
||||
ORDER BY mi.content_id
|
||||
LIMIT $3
|
||||
`, afterID, currentModel, limit)
|
||||
`, embeddingEligibilityWhereClause())
|
||||
rows, err := r.pool.Query(ctx, query, afterID, currentModel, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding text candidates: %w", err)
|
||||
}
|
||||
@@ -583,7 +634,8 @@ func (r *Repo) EmbeddingCount(ctx context.Context) (int, error) {
|
||||
// (used by the admin worker UI) lines up with what the job actually does.
|
||||
func (r *Repo) TotalMediaItemCount(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_items WHERE status = 'matched' OR type = 'audiobook'`).Scan(&count)
|
||||
query := fmt.Sprintf(`SELECT COUNT(*) FROM media_items mi WHERE %s`, embeddingEligibilityWhereClause())
|
||||
err := r.pool.QueryRow(ctx, query).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("total media item count: %w", err)
|
||||
}
|
||||
@@ -1137,6 +1189,32 @@ func (r *Repo) GetWatchProgressForUser(ctx context.Context, userID int, profileI
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) GetEbookReaderProgressForUser(ctx context.Context, userID int, profileID string) ([]WatchProgressRow, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT content_id,
|
||||
progress::double precision AS position_seconds,
|
||||
1::double precision AS duration_seconds,
|
||||
progress >= 0.9 AS completed,
|
||||
updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE user_id = $1 AND profile_id = $2`,
|
||||
userID, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ebook reader progress: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []WatchProgressRow
|
||||
for rows.Next() {
|
||||
var wp WatchProgressRow
|
||||
if err := rows.Scan(&wp.MediaItemID, &wp.PositionSeconds, &wp.DurationSeconds, &wp.Completed, &wp.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan ebook reader progress: %w", err)
|
||||
}
|
||||
result = append(result, wp)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// RewatchCount holds the number of completed watches for an item.
|
||||
type RewatchCount struct {
|
||||
MediaItemID string
|
||||
@@ -1181,29 +1259,20 @@ func (r *Repo) GetRewatchCounts(ctx context.Context, userID int, profileID strin
|
||||
// Used for co-watch matrix computation.
|
||||
func (r *Repo) GetItemWatchers(ctx context.Context, minWatchers int, maxPerUser int) (map[string][]string, error) {
|
||||
// Get all watch progress entries where progress >= 50% or completed
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH user_watches AS (
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s,
|
||||
user_watches AS (
|
||||
SELECT user_id::text || ':' || COALESCE(profile_id, '') AS watcher_id,
|
||||
media_item_id,
|
||||
item_id AS media_item_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id, profile_id ORDER BY updated_at DESC) AS rn
|
||||
FROM user_watch_progress
|
||||
WHERE (completed = true
|
||||
OR (duration_seconds > 0 AND position_seconds / duration_seconds >= 0.5))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = user_watch_progress.user_id
|
||||
AND hhi.profile_id = user_watch_progress.profile_id
|
||||
AND hhi.media_item_id = user_watch_progress.media_item_id
|
||||
AND user_watch_progress.updated_at <= hhi.hidden_before
|
||||
)
|
||||
FROM watched_activity
|
||||
)
|
||||
SELECT media_item_id, ARRAY_AGG(watcher_id) AS watchers
|
||||
FROM user_watches
|
||||
WHERE rn <= $1
|
||||
GROUP BY media_item_id
|
||||
HAVING COUNT(*) >= $2`,
|
||||
maxPerUser, minWatchers)
|
||||
HAVING COUNT(*) >= $2`, watchedActivityCTE)
|
||||
rows, err := r.pool.Query(ctx, query, maxPerUser, minWatchers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get item watchers: %w", err)
|
||||
}
|
||||
@@ -1226,30 +1295,20 @@ func (r *Repo) GetItemWatchers(ctx context.Context, minWatchers int, maxPerUser
|
||||
// GetPopularItems returns the most-watched series/movies over the given number of days.
|
||||
// Episodes are resolved to their parent series.
|
||||
func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH watched_items AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
wp.user_id::text || ':' || COALESCE(wp.profile_id, '') AS watcher_id
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND wp.updated_at > NOW() - ($1 || ' days')::interval
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = wp.user_id
|
||||
AND hhi.profile_id = wp.profile_id
|
||||
AND hhi.media_item_id = wp.media_item_id
|
||||
AND wp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s,
|
||||
watched_items AS (
|
||||
SELECT item_id, watcher_id
|
||||
FROM watched_activity
|
||||
WHERE updated_at > NOW() - ($1 || ' days')::interval
|
||||
)
|
||||
SELECT wi.item_id, COUNT(DISTINCT wi.watcher_id) AS watch_count
|
||||
FROM watched_items wi
|
||||
JOIN media_items mi ON mi.content_id = wi.item_id
|
||||
GROUP BY wi.item_id
|
||||
ORDER BY watch_count DESC
|
||||
LIMIT $2`,
|
||||
fmt.Sprintf("%d", days), limit)
|
||||
LIMIT $2`, watchedActivityCTE)
|
||||
rows, err := r.pool.Query(ctx, query, fmt.Sprintf("%d", days), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get popular items: %w", err)
|
||||
}
|
||||
@@ -1271,14 +1330,14 @@ func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredIt
|
||||
|
||||
// GetRecentlyAddedItems returns items added within the given number of days.
|
||||
func (r *Repo) GetRecentlyAddedItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT content_id, created_at
|
||||
FROM media_items
|
||||
WHERE status = 'matched'
|
||||
AND created_at > NOW() - ($1 || ' days')::interval
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
fmt.Sprintf("%d", days), limit)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT mi.content_id, mi.created_at
|
||||
FROM media_items mi
|
||||
WHERE %s
|
||||
AND mi.created_at > NOW() - ($1 || ' days')::interval
|
||||
ORDER BY mi.created_at DESC
|
||||
LIMIT $2`, recommendationItemEligibilityWhereClause("mi"))
|
||||
rows, err := r.pool.Query(ctx, query, fmt.Sprintf("%d", days), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get recently added: %w", err)
|
||||
}
|
||||
@@ -1352,21 +1411,11 @@ func (r *Repo) GetTasteSeedCandidates(ctx context.Context, limit, offset int) ([
|
||||
// GetTopGenres returns the most popular genres by watch count.
|
||||
// Episodes are resolved to their parent series for genre lookup.
|
||||
func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH watched_items AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
wp.user_id::text || ':' || COALESCE(wp.profile_id, '') AS watcher_id
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = wp.user_id
|
||||
AND hhi.profile_id = wp.profile_id
|
||||
AND hhi.media_item_id = wp.media_item_id
|
||||
AND wp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s,
|
||||
watched_items AS (
|
||||
SELECT item_id, watcher_id
|
||||
FROM watched_activity
|
||||
)
|
||||
SELECT g.genre, COUNT(DISTINCT wi.watcher_id) AS watchers
|
||||
FROM watched_items wi
|
||||
@@ -1374,8 +1423,8 @@ func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
|
||||
CROSS JOIN LATERAL UNNEST(mi.genres) AS g(genre)
|
||||
GROUP BY g.genre
|
||||
ORDER BY watchers DESC
|
||||
LIMIT $1`,
|
||||
limit)
|
||||
LIMIT $1`, watchedActivityCTE)
|
||||
rows, err := r.pool.Query(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get top genres: %w", err)
|
||||
}
|
||||
@@ -1395,21 +1444,11 @@ func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
|
||||
|
||||
// GetGenreSamplerItems returns the most-watched series/movies in a specific genre.
|
||||
func (r *Repo) GetGenreSamplerItems(ctx context.Context, genre string, limit int) ([]ScoredItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH watched_items AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
wp.user_id::text || ':' || COALESCE(wp.profile_id, '') AS watcher_id
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = wp.user_id
|
||||
AND hhi.profile_id = wp.profile_id
|
||||
AND hhi.media_item_id = wp.media_item_id
|
||||
AND wp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s,
|
||||
watched_items AS (
|
||||
SELECT item_id, watcher_id
|
||||
FROM watched_activity
|
||||
)
|
||||
SELECT wi.item_id, COUNT(DISTINCT wi.watcher_id) AS watch_count
|
||||
FROM watched_items wi
|
||||
@@ -1417,8 +1456,8 @@ func (r *Repo) GetGenreSamplerItems(ctx context.Context, genre string, limit int
|
||||
WHERE $1 = ANY(mi.genres)
|
||||
GROUP BY wi.item_id
|
||||
ORDER BY watch_count DESC
|
||||
LIMIT $2`,
|
||||
genre, limit)
|
||||
LIMIT $2`, watchedActivityCTE)
|
||||
rows, err := r.pool.Query(ctx, query, genre, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get genre sampler items: %w", err)
|
||||
}
|
||||
@@ -1617,21 +1656,12 @@ func (r *Repo) GetAllUsersWithTasteProfiles(ctx context.Context) ([]StaleProfile
|
||||
// GetWatchedItemIDs returns content IDs of series/movies the user has watched
|
||||
// (>= 50% progress or completed). Episodes are resolved to their parent series.
|
||||
func (r *Repo) GetWatchedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT DISTINCT COALESCE(e.series_id, wp.media_item_id) AS item_id
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE wp.user_id = $1 AND wp.profile_id = $2
|
||||
AND (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = wp.user_id
|
||||
AND hhi.profile_id = wp.profile_id
|
||||
AND hhi.media_item_id = wp.media_item_id
|
||||
AND wp.updated_at <= hhi.hidden_before
|
||||
)`,
|
||||
userID, profileID)
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s
|
||||
SELECT DISTINCT item_id
|
||||
FROM watched_activity
|
||||
WHERE user_id = $1 AND profile_id = $2`, watchedActivityCTE)
|
||||
rows, err := r.pool.Query(ctx, query, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get watched item IDs: %w", err)
|
||||
}
|
||||
@@ -1695,21 +1725,15 @@ func (r *Repo) GetRecentCompletedItemIDs(ctx context.Context, userID int, profil
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT media_item_id
|
||||
FROM user_watch_progress
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s
|
||||
SELECT leaf_item_id
|
||||
FROM watched_activity
|
||||
WHERE user_id = $1 AND profile_id = $2 AND completed = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = user_watch_progress.user_id
|
||||
AND hhi.profile_id = user_watch_progress.profile_id
|
||||
AND hhi.media_item_id = user_watch_progress.media_item_id
|
||||
AND user_watch_progress.updated_at <= hhi.hidden_before
|
||||
)
|
||||
ORDER BY updated_at DESC, media_item_id ASC
|
||||
ORDER BY updated_at DESC, leaf_item_id ASC
|
||||
LIMIT $3
|
||||
`, userID, profileID, limit)
|
||||
`, watchedActivityCTE)
|
||||
rows, err := r.pool.Query(ctx, query, userID, profileID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get recent completed item IDs: %w", err)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,67 @@ func TestTasteSeedCandidateQueryOrdersByReliableColdStartSignals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchedActivityCTEIncludesEbookReaderProgress(t *testing.T) {
|
||||
query := strings.Join(strings.Fields(watchedActivityCTE), " ")
|
||||
|
||||
for _, term := range []string{
|
||||
"FROM ebook_reader_progress erp",
|
||||
"erp.progress >= 0.5",
|
||||
"erp.progress >= 0.9 AS completed",
|
||||
"hhi.media_item_id = erp.content_id",
|
||||
} {
|
||||
if !strings.Contains(query, term) {
|
||||
t.Fatalf("watched activity CTE missing %q: %s", term, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingEligibilityWhereClauseIncludesBookTypes(t *testing.T) {
|
||||
clause := embeddingEligibilityWhereClause()
|
||||
|
||||
for _, term := range []string{"mi.status = 'matched'", "mi.type = 'audiobook'", "mi.type = 'ebook'"} {
|
||||
if !strings.Contains(clause, term) {
|
||||
t.Fatalf("embedding eligibility clause missing %q: %s", term, clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingEligibilityWhereClauseUsesMediaItemsAlias(t *testing.T) {
|
||||
clause := embeddingEligibilityWhereClause()
|
||||
|
||||
if strings.Contains(clause, " type =") || strings.Contains(clause, " status =") {
|
||||
t.Fatalf("embedding eligibility clause should qualify media_items columns: %s", clause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationItemEligibilityWhereClauseIncludesBookTypes(t *testing.T) {
|
||||
clause := recommendationItemEligibilityWhereClause("mi")
|
||||
|
||||
for _, term := range []string{"mi.status = 'matched'", "mi.type = 'audiobook'", "mi.type = 'ebook'"} {
|
||||
if !strings.Contains(clause, term) {
|
||||
t.Fatalf("recommendation item eligibility clause missing %q: %s", term, clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationItemEligibilityWhereClauseDefaultsAlias(t *testing.T) {
|
||||
clause := recommendationItemEligibilityWhereClause("")
|
||||
|
||||
if !strings.Contains(clause, "media_items.status = 'matched'") {
|
||||
t.Fatalf("default eligibility alias missing media_items qualification: %s", clause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasteProfileRefreshSubjectsQueryIncludesEbookReaderProgress(t *testing.T) {
|
||||
query := strings.Join(strings.Fields(tasteProfileRefreshSubjectsQuery), " ")
|
||||
|
||||
assertQueryTermsInOrder(t, query,
|
||||
"SELECT DISTINCT user_id, profile_id FROM user_watch_progress",
|
||||
"UNION",
|
||||
"SELECT DISTINCT user_id, profile_id FROM ebook_reader_progress",
|
||||
)
|
||||
}
|
||||
|
||||
func assertQueryTermsInOrder(t *testing.T, query string, terms ...string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const signalPageSize = 1000
|
||||
type signalRepo interface {
|
||||
GetWatchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error)
|
||||
GetWatchProgressForUser(ctx context.Context, userID int, profileID string) ([]WatchProgressRow, error)
|
||||
GetEbookReaderProgressForUser(ctx context.Context, userID int, profileID string) ([]WatchProgressRow, error)
|
||||
GetRecentCompletedItemIDs(ctx context.Context, userID int, profileID string, limit int) ([]string, error)
|
||||
GetRewatchCounts(ctx context.Context, userID int, profileID string) ([]RewatchCount, error)
|
||||
ResolveCanonicalItemIDSet(ctx context.Context, contentIDs []string) (map[string]struct{}, error)
|
||||
@@ -69,6 +70,15 @@ func (s *SignalReader) WatchedItemIDSet(ctx context.Context, userID int, profile
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ebookProgress, err := s.repo.GetEbookReaderProgressForUser(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, wp := range ebookProgress {
|
||||
if wp.Completed || watchedProgressThresholdMet(wp.PositionSeconds, wp.DurationSeconds) {
|
||||
rawIDs = append(rawIDs, wp.MediaItemID)
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.ResolveCanonicalItemIDSet(ctx, rawIDs)
|
||||
}
|
||||
@@ -97,6 +107,11 @@ func (s *SignalReader) WatchProgressForUser(ctx context.Context, userID int, pro
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ebookProgress, err := s.repo.GetEbookReaderProgressForUser(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, ebookProgress...)
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
@@ -118,20 +133,38 @@ func (s *SignalReader) RecentCompletedItemIDs(ctx context.Context, userID int, p
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list completed progress from store: %w", err)
|
||||
}
|
||||
sort.SliceStable(progress, func(i, j int) bool {
|
||||
left := parseSignalTime(progress[i].UpdatedAt, time.Time{})
|
||||
right := parseSignalTime(progress[j].UpdatedAt, time.Time{})
|
||||
if !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
return progress[i].MediaItemID < progress[j].MediaItemID
|
||||
})
|
||||
|
||||
ids := make([]string, 0, min(limit, len(progress)))
|
||||
completed := make([]WatchProgressRow, 0, len(progress))
|
||||
for _, wp := range progress {
|
||||
if !wp.Completed {
|
||||
continue
|
||||
}
|
||||
completed = append(completed, WatchProgressRow{
|
||||
MediaItemID: wp.MediaItemID,
|
||||
Completed: true,
|
||||
UpdatedAt: parseSignalTime(wp.UpdatedAt, time.Time{}),
|
||||
})
|
||||
}
|
||||
ebookProgress, err := s.repo.GetEbookReaderProgressForUser(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, wp := range ebookProgress {
|
||||
if !wp.Completed {
|
||||
continue
|
||||
}
|
||||
completed = append(completed, wp)
|
||||
}
|
||||
sort.SliceStable(completed, func(i, j int) bool {
|
||||
left := completed[i].UpdatedAt
|
||||
right := completed[j].UpdatedAt
|
||||
if !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
return completed[i].MediaItemID < completed[j].MediaItemID
|
||||
})
|
||||
|
||||
ids := make([]string, 0, min(limit, len(completed)))
|
||||
for _, wp := range completed {
|
||||
ids = append(ids, wp.MediaItemID)
|
||||
if len(ids) == limit {
|
||||
break
|
||||
|
||||
@@ -14,6 +14,7 @@ type fakeSignalRepo struct {
|
||||
|
||||
fallbackWatched map[string]struct{}
|
||||
fallbackProgress []WatchProgressRow
|
||||
ebookProgress []WatchProgressRow
|
||||
fallbackRecentCompleted []string
|
||||
fallbackRewatches []RewatchCount
|
||||
}
|
||||
@@ -26,6 +27,10 @@ func (r *fakeSignalRepo) GetWatchProgressForUser(context.Context, int, string) (
|
||||
return r.fallbackProgress, nil
|
||||
}
|
||||
|
||||
func (r *fakeSignalRepo) GetEbookReaderProgressForUser(context.Context, int, string) ([]WatchProgressRow, error) {
|
||||
return r.ebookProgress, nil
|
||||
}
|
||||
|
||||
func (r *fakeSignalRepo) GetRecentCompletedItemIDs(context.Context, int, string, int) ([]string, error) {
|
||||
return r.fallbackRecentCompleted, nil
|
||||
}
|
||||
@@ -161,6 +166,52 @@ func TestSignalReaderWatchedSetCanonicalizesStoreProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalReaderWatchedSetIncludesEbookReaderProgress(t *testing.T) {
|
||||
store := &fakeSignalStore{}
|
||||
repo := &fakeSignalRepo{
|
||||
ebookProgress: []WatchProgressRow{
|
||||
{MediaItemID: "ebook-half", PositionSeconds: 0.6, DurationSeconds: 1, UpdatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)},
|
||||
{MediaItemID: "ebook-low", PositionSeconds: 0.2, DurationSeconds: 1, UpdatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC)},
|
||||
},
|
||||
}
|
||||
reader := NewSignalReader(repo, fakeSignalProvider{store: store})
|
||||
|
||||
watched, err := reader.WatchedItemIDSet(context.Background(), 7, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("WatchedItemIDSet returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := watched["ebook-half"]; !ok {
|
||||
t.Fatalf("expected ebook-half in watched set, got %#v", watched)
|
||||
}
|
||||
if _, ok := watched["ebook-low"]; ok {
|
||||
t.Fatalf("did not expect low-progress ebook in watched set: %#v", watched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalReaderWatchProgressIncludesEbookReaderProgress(t *testing.T) {
|
||||
store := &fakeSignalStore{progress: []userstore.WatchProgress{
|
||||
{ProfileID: "p1", MediaItemID: "movie", PositionSeconds: 60, DurationSeconds: 100, UpdatedAt: "2026-06-01T10:00:00Z"},
|
||||
}}
|
||||
repo := &fakeSignalRepo{
|
||||
ebookProgress: []WatchProgressRow{
|
||||
{MediaItemID: "ebook", PositionSeconds: 0.42, DurationSeconds: 1, Completed: false, UpdatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC)},
|
||||
},
|
||||
}
|
||||
reader := NewSignalReader(repo, fakeSignalProvider{store: store})
|
||||
|
||||
progress, err := reader.WatchProgressForUser(context.Background(), 7, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("WatchProgressForUser returned error: %v", err)
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(progress, func(row WatchProgressRow) bool {
|
||||
return row.MediaItemID == "ebook" && row.PositionSeconds == 0.42 && row.DurationSeconds == 1
|
||||
}) {
|
||||
t.Fatalf("ebook progress missing from rows: %#v", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalReaderRecentCompletedUsesStoreUpdatedOrder(t *testing.T) {
|
||||
store := &fakeSignalStore{progress: []userstore.WatchProgress{
|
||||
{ProfileID: "p1", MediaItemID: "older", Completed: true, UpdatedAt: "2026-05-01T10:00:00Z"},
|
||||
@@ -181,6 +232,29 @@ func TestSignalReaderRecentCompletedUsesStoreUpdatedOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalReaderRecentCompletedIncludesEbookReaderProgress(t *testing.T) {
|
||||
store := &fakeSignalStore{progress: []userstore.WatchProgress{
|
||||
{ProfileID: "p1", MediaItemID: "movie", Completed: true, UpdatedAt: "2026-06-01T10:00:00Z"},
|
||||
}}
|
||||
repo := &fakeSignalRepo{
|
||||
ebookProgress: []WatchProgressRow{
|
||||
{MediaItemID: "ebook-done", PositionSeconds: 0.95, DurationSeconds: 1, Completed: true, UpdatedAt: time.Date(2026, 6, 3, 10, 0, 0, 0, time.UTC)},
|
||||
{MediaItemID: "ebook-open", PositionSeconds: 0.4, DurationSeconds: 1, Completed: false, UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)},
|
||||
},
|
||||
}
|
||||
reader := NewSignalReader(repo, fakeSignalProvider{store: store})
|
||||
|
||||
ids, err := reader.RecentCompletedItemIDs(context.Background(), 7, "p1", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentCompletedItemIDs returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"ebook-done", "movie"}
|
||||
if !slices.Equal(ids, want) {
|
||||
t.Fatalf("recent completed = %#v, want %#v", ids, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalReaderRewatchCountsAggregatesCompletedHistory(t *testing.T) {
|
||||
store := &fakeSignalStore{history: []userstore.WatchHistoryEntry{
|
||||
{ProfileID: "p1", MediaItemID: "rewatched", Completed: true, WatchedAt: "2026-05-01T10:00:00Z"},
|
||||
|
||||
@@ -31,6 +31,19 @@ type Worker struct {
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
const tasteProfileRefreshSubjectsQuery = `
|
||||
SELECT DISTINCT user_id, profile_id FROM user_ratings
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_taste_profiles
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_watch_progress
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM ebook_reader_progress
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_favorites
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_watchlist`
|
||||
|
||||
// NewWorker creates a new recommendation Worker.
|
||||
func NewWorker(engine *Engine, embeddingsCron, tasteProfilesCron, cowatchCron, recommendationsCron string) (*Worker, error) {
|
||||
w := &Worker{
|
||||
@@ -266,16 +279,7 @@ func (w *Worker) doTasteProfiles() {
|
||||
defer cancel()
|
||||
slog.Info("starting taste profile refresh")
|
||||
|
||||
rows, err := w.engine.pool.Query(ctx, `
|
||||
SELECT DISTINCT user_id, profile_id FROM user_ratings
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_taste_profiles
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_watch_progress
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_favorites
|
||||
UNION
|
||||
SELECT DISTINCT user_id, profile_id FROM user_watchlist`)
|
||||
rows, err := w.engine.pool.Query(ctx, tasteProfileRefreshSubjectsQuery)
|
||||
if err != nil {
|
||||
slog.Error("taste profile query failed", "error", err)
|
||||
return
|
||||
|
||||
@@ -17,6 +17,10 @@ type audiobookCoverCacher interface {
|
||||
CacheAudiobookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error)
|
||||
}
|
||||
|
||||
type ebookCoverCacher interface {
|
||||
CacheEbookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error)
|
||||
}
|
||||
|
||||
// FFmpegPathFromFFprobe derives the ffmpeg binary path from a configured
|
||||
// ffprobe path. They live side by side in every silo deployment.
|
||||
func FFmpegPathFromFFprobe(ffprobePath string) string {
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xhtml "golang.org/x/net/html"
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
textunicode "golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
const maxEPUBMetadataEntrySize = 8 * 1024 * 1024
|
||||
const maxPDFMetadataScanSize = 2 * 1024 * 1024
|
||||
|
||||
var ebookExtensions = map[string]bool{
|
||||
".epub": true,
|
||||
".pdf": true,
|
||||
".mobi": true,
|
||||
".azw": true,
|
||||
".azw3": true,
|
||||
".fb2": true,
|
||||
".fbz": true,
|
||||
".cbz": true,
|
||||
".cbr": true,
|
||||
".md": true,
|
||||
}
|
||||
|
||||
type parsedEbook struct {
|
||||
Format string
|
||||
Title string
|
||||
Authors []string
|
||||
Description string
|
||||
Publisher string
|
||||
PublishedAt time.Time
|
||||
Year int
|
||||
Language string
|
||||
ISBN string
|
||||
Series string
|
||||
SeriesIndex string
|
||||
Genres []string
|
||||
PageCount int
|
||||
Cover *parsedEbookCover
|
||||
}
|
||||
|
||||
type parsedEbookCover struct {
|
||||
ContentType string
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
func SupportsEbookFile(filePath string) bool {
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".fb2.zip") {
|
||||
return true
|
||||
}
|
||||
return ebookExtensions[strings.ToLower(filepath.Ext(filePath))]
|
||||
}
|
||||
|
||||
func parseEbookFile(path string) (book parsedEbook, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic parsing ebook %s: %v", path, r)
|
||||
}
|
||||
}()
|
||||
format := ebookFileFormat(path)
|
||||
switch format {
|
||||
case ".epub":
|
||||
book, err = parseEbookEPUB(path)
|
||||
case ".fb2":
|
||||
book, err = parseEbookFB2(path)
|
||||
case ".fbz":
|
||||
book, err = parseEbookFBZ(path)
|
||||
case ".cbz":
|
||||
book, err = parseEbookCBZ(path)
|
||||
case ".pdf":
|
||||
book, err = parseEbookPDF(path)
|
||||
case ".mobi", ".azw", ".azw3", ".cbr", ".md":
|
||||
book = parsedEbook{Format: strings.TrimPrefix(format, ".")}
|
||||
default:
|
||||
err = fmt.Errorf("unsupported ebook format: %s", filepath.Ext(path))
|
||||
}
|
||||
book.sanitize()
|
||||
return book, err
|
||||
}
|
||||
|
||||
func parseEbookPDF(path string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: "pdf"}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxPDFMetadataScanSize))
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
info := parsePDFInfoFields(data)
|
||||
book.Title = info["Title"]
|
||||
book.Authors = splitEbookAuthors(info["Author"])
|
||||
book.Description = info["Subject"]
|
||||
book.Genres = splitPDFKeywords(info["Keywords"])
|
||||
for _, value := range []string{
|
||||
info["ISBN"],
|
||||
info["Subject"],
|
||||
info["Keywords"],
|
||||
info["Title"],
|
||||
} {
|
||||
if isbn := normalizeEbookISBN(value); isbn != "" {
|
||||
book.ISBN = isbn
|
||||
break
|
||||
}
|
||||
}
|
||||
if t, ok := parsePDFDate(info["CreationDate"]); ok {
|
||||
book.PublishedAt = t
|
||||
book.Year = t.Year()
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func ebookFileFormat(path string) string {
|
||||
if strings.HasSuffix(strings.ToLower(path), ".fb2.zip") {
|
||||
return ".fbz"
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(path))
|
||||
}
|
||||
|
||||
func (b *parsedEbook) sanitize() {
|
||||
b.Format = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(b.Format)), ".")
|
||||
b.Title = strings.TrimSpace(b.Title)
|
||||
b.Description = cleanEbookDescription(b.Description)
|
||||
b.Publisher = strings.TrimSpace(b.Publisher)
|
||||
b.Language = strings.TrimSpace(b.Language)
|
||||
b.ISBN = normalizeEbookISBN(b.ISBN)
|
||||
b.Series = strings.TrimSpace(b.Series)
|
||||
b.SeriesIndex = strings.TrimSpace(b.SeriesIndex)
|
||||
b.Authors = uniqueTrimmedStrings(b.Authors)
|
||||
b.Genres = uniqueTrimmedStrings(b.Genres)
|
||||
if b.PageCount < 0 {
|
||||
b.PageCount = 0
|
||||
}
|
||||
if b.Year == 0 && !b.PublishedAt.IsZero() {
|
||||
b.Year = b.PublishedAt.Year()
|
||||
}
|
||||
}
|
||||
|
||||
func cleanEbookDescription(value string) string {
|
||||
value = strings.TrimSpace(html.UnescapeString(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(value, "<") || !strings.Contains(value, ">") {
|
||||
return strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
|
||||
tokenizer := xhtml.NewTokenizer(strings.NewReader(value))
|
||||
var out strings.Builder
|
||||
needsSpace := false
|
||||
writeSpace := func() {
|
||||
if out.Len() > 0 && !needsSpace {
|
||||
needsSpace = true
|
||||
}
|
||||
}
|
||||
for {
|
||||
switch tokenizer.Next() {
|
||||
case xhtml.ErrorToken:
|
||||
return strings.Join(strings.Fields(out.String()), " ")
|
||||
case xhtml.TextToken:
|
||||
text := strings.TrimSpace(html.UnescapeString(string(tokenizer.Text())))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if out.Len() > 0 && (needsSpace || !startsWithClosingPunctuation(text)) {
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
out.WriteString(text)
|
||||
needsSpace = false
|
||||
case xhtml.StartTagToken, xhtml.SelfClosingTagToken, xhtml.EndTagToken:
|
||||
name, _ := tokenizer.TagName()
|
||||
switch strings.ToLower(string(name)) {
|
||||
case "br", "p", "div", "section", "article", "li", "ul", "ol", "blockquote", "tr":
|
||||
writeSpace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startsWithClosingPunctuation(value string) bool {
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '.', ',', ';', ':', '!', '?', ')', ']', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeHTML(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return strings.Contains(value, "<") && strings.Contains(value, ">")
|
||||
}
|
||||
|
||||
func uniqueTrimmedStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
key := strings.ToLower(trimmed)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeEbookISBN(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
for _, prefix := range []string{"ISBN-13", "ISBN-10", "ISBN"} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
value = strings.TrimLeft(value, ": ")
|
||||
break
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
out.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == 'X' {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
candidate := out.String()
|
||||
switch len(candidate) {
|
||||
case 10:
|
||||
if validISBN10(candidate) {
|
||||
return candidate
|
||||
}
|
||||
case 13:
|
||||
if validISBN13(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validISBN10(candidate string) bool {
|
||||
if len(candidate) != 10 {
|
||||
return false
|
||||
}
|
||||
sum := 0
|
||||
for i, r := range candidate {
|
||||
value := 0
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
value = int(r - '0')
|
||||
case r == 'X' && i == 9:
|
||||
value = 10
|
||||
default:
|
||||
return false
|
||||
}
|
||||
sum += value * (10 - i)
|
||||
}
|
||||
return sum%11 == 0
|
||||
}
|
||||
|
||||
func validISBN13(candidate string) bool {
|
||||
if len(candidate) != 13 {
|
||||
return false
|
||||
}
|
||||
sum := 0
|
||||
for i, r := range candidate {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
value := int(r - '0')
|
||||
if i%2 == 1 {
|
||||
value *= 3
|
||||
}
|
||||
sum += value
|
||||
}
|
||||
return sum%10 == 0
|
||||
}
|
||||
|
||||
func parseEbookEPUB(path string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: "epub"}
|
||||
reader, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
container, err := readEPUBZipEntry(&reader.Reader, "META-INF/container.xml")
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
opfPath, err := epubOPFPath(container)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
opf, err := readEPUBZipEntry(&reader.Reader, opfPath)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
if err := parseEPUBOPFMetadata(opf, &book); err != nil {
|
||||
return book, err
|
||||
}
|
||||
if cover, err := extractEPUBCover(&reader.Reader, opfPath, opf); err == nil {
|
||||
book.Cover = cover
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func parseEbookFB2(path string) (parsedEbook, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return parsedEbook{Format: "fb2"}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return parseEbookFB2Reader(file, "fb2")
|
||||
}
|
||||
|
||||
func parseEbookFBZ(path string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: "fbz"}
|
||||
reader, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if !strings.HasSuffix(strings.ToLower(file.Name), ".fb2") {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > maxEPUBMetadataEntrySize {
|
||||
return book, fmt.Errorf("fbz entry too large: %s", file.Name)
|
||||
}
|
||||
entry, err := file.Open()
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer entry.Close()
|
||||
return parseEbookFB2Reader(io.LimitReader(entry, maxEPUBMetadataEntrySize+1), "fbz")
|
||||
}
|
||||
return book, fmt.Errorf("fbz archive has no fb2 entry")
|
||||
}
|
||||
|
||||
func parseEbookCBZ(path string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: "cbz"}
|
||||
reader, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var pages []*zip.File
|
||||
for _, file := range reader.File {
|
||||
if isComicArchivePage(file.Name) {
|
||||
book.PageCount++
|
||||
pages = append(pages, file)
|
||||
}
|
||||
}
|
||||
sort.Slice(pages, func(i, j int) bool {
|
||||
return normalizedArchivePath(pages[i].Name) < normalizedArchivePath(pages[j].Name)
|
||||
})
|
||||
if len(pages) > 0 {
|
||||
if cover, err := readArchiveImageCover(pages[0]); err == nil {
|
||||
book.Cover = cover
|
||||
}
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func isComicArchivePage(name string) bool {
|
||||
clean := normalizedArchivePath(name)
|
||||
if clean == "" || strings.HasSuffix(clean, "/") {
|
||||
return false
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(clean))
|
||||
if strings.HasPrefix(base, "._") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.ToLower(clean), "/")
|
||||
for _, part := range parts {
|
||||
if part == "__macosx" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch filepath.Ext(base) {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".bmp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedArchivePath(name string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
|
||||
}
|
||||
|
||||
func readArchiveImageCover(file *zip.File) (*parsedEbookCover, error) {
|
||||
if file == nil {
|
||||
return nil, fmt.Errorf("nil archive image")
|
||||
}
|
||||
if file.UncompressedSize64 > maxEPUBMetadataEntrySize {
|
||||
return nil, fmt.Errorf("archive cover entry too large: %s", file.Name)
|
||||
}
|
||||
entry, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer entry.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(entry, maxEPUBMetadataEntrySize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxEPUBMetadataEntrySize {
|
||||
return nil, fmt.Errorf("archive cover entry too large: %s", file.Name)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("archive cover entry empty: %s", file.Name)
|
||||
}
|
||||
return &parsedEbookCover{
|
||||
ContentType: ebookImageContentType(file.Name),
|
||||
Bytes: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseEbookFB2Reader(reader io.Reader, format string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: format}
|
||||
var fb2 struct {
|
||||
Description struct {
|
||||
TitleInfo struct {
|
||||
Genres []string `xml:"genre"`
|
||||
Authors []struct {
|
||||
FirstName string `xml:"first-name"`
|
||||
MiddleName string `xml:"middle-name"`
|
||||
LastName string `xml:"last-name"`
|
||||
Nickname string `xml:"nickname"`
|
||||
} `xml:"author"`
|
||||
BookTitle string `xml:"book-title"`
|
||||
Lang string `xml:"lang"`
|
||||
Date struct {
|
||||
Value string `xml:"value,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
} `xml:"date"`
|
||||
Sequences []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Number string `xml:"number,attr"`
|
||||
} `xml:"sequence"`
|
||||
} `xml:"title-info"`
|
||||
PublishInfo struct {
|
||||
ISBN string `xml:"isbn"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Year string `xml:"year"`
|
||||
} `xml:"publish-info"`
|
||||
} `xml:"description"`
|
||||
}
|
||||
if err := xml.NewDecoder(reader).Decode(&fb2); err != nil {
|
||||
return book, err
|
||||
}
|
||||
|
||||
book.Title = fb2.Description.TitleInfo.BookTitle
|
||||
book.Language = fb2.Description.TitleInfo.Lang
|
||||
book.Genres = fb2.Description.TitleInfo.Genres
|
||||
if t, ok := parseEbookDate(firstNonEmpty(fb2.Description.TitleInfo.Date.Value, fb2.Description.TitleInfo.Date.Text)); ok {
|
||||
book.PublishedAt = t
|
||||
}
|
||||
for _, author := range fb2.Description.TitleInfo.Authors {
|
||||
name := strings.Join(uniqueTrimmedStrings([]string{
|
||||
author.FirstName,
|
||||
author.MiddleName,
|
||||
author.LastName,
|
||||
}), " ")
|
||||
if name == "" {
|
||||
name = author.Nickname
|
||||
}
|
||||
book.Authors = append(book.Authors, name)
|
||||
}
|
||||
if len(fb2.Description.TitleInfo.Sequences) > 0 {
|
||||
book.Series = fb2.Description.TitleInfo.Sequences[0].Name
|
||||
book.SeriesIndex = fb2.Description.TitleInfo.Sequences[0].Number
|
||||
}
|
||||
book.ISBN = fb2.Description.PublishInfo.ISBN
|
||||
book.Publisher = fb2.Description.PublishInfo.Publisher
|
||||
if year, err := strconv.Atoi(strings.TrimSpace(fb2.Description.PublishInfo.Year)); err == nil {
|
||||
book.Year = year
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func parsePDFInfoFields(data []byte) map[string]string {
|
||||
fields := map[string]string{}
|
||||
for _, key := range []string{"Title", "Author", "Subject", "Keywords", "CreationDate", "ISBN"} {
|
||||
token := []byte("/" + key)
|
||||
idx := bytes.Index(data, token)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := bytes.TrimLeft(data[idx+len(token):], " \t\r\n")
|
||||
if len(rest) == 0 {
|
||||
continue
|
||||
}
|
||||
value, ok := readPDFString(rest)
|
||||
if ok {
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func decodeEbookXML(data []byte, v any) error {
|
||||
data = normalizeEbookXMLVersion(data)
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.CharsetReader = ebookXMLCharsetReader
|
||||
return decoder.Decode(v)
|
||||
}
|
||||
|
||||
func normalizeEbookXMLVersion(data []byte) []byte {
|
||||
for _, needle := range []string{`version="1.1"`, `version='1.1'`} {
|
||||
idx := bytes.Index(data, []byte(needle))
|
||||
if idx < 0 || idx > 128 {
|
||||
continue
|
||||
}
|
||||
out := append([]byte(nil), data...)
|
||||
copy(out[idx:], strings.Replace(needle, "1.1", "1.0", 1))
|
||||
return out
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func ebookXMLCharsetReader(charset string, input io.Reader) (io.Reader, error) {
|
||||
name := strings.ToLower(strings.TrimSpace(charset))
|
||||
var enc encoding.Encoding
|
||||
switch name {
|
||||
case "", "utf-8", "utf8":
|
||||
return input, nil
|
||||
case "iso-8859-1", "latin1", "latin-1":
|
||||
enc = charmap.ISO8859_1
|
||||
case "windows-1252", "cp1252":
|
||||
enc = charmap.Windows1252
|
||||
case "utf-16", "utf16":
|
||||
enc = textunicode.UTF16(textunicode.BigEndian, textunicode.ExpectBOM)
|
||||
case "utf-16be", "utf16be":
|
||||
enc = textunicode.UTF16(textunicode.BigEndian, textunicode.IgnoreBOM)
|
||||
case "utf-16le", "utf16le":
|
||||
enc = textunicode.UTF16(textunicode.LittleEndian, textunicode.IgnoreBOM)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ebook XML encoding %q", charset)
|
||||
}
|
||||
return enc.NewDecoder().Reader(input), nil
|
||||
}
|
||||
|
||||
func readPDFString(data []byte) (string, bool) {
|
||||
switch data[0] {
|
||||
case '(':
|
||||
return readPDFLiteralString(data)
|
||||
case '<':
|
||||
if len(data) > 1 && data[1] == '<' {
|
||||
return "", false
|
||||
}
|
||||
return readPDFHexString(data)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readPDFLiteralString(data []byte) (string, bool) {
|
||||
if len(data) == 0 || data[0] != '(' {
|
||||
return "", false
|
||||
}
|
||||
var out []byte
|
||||
depth := 1
|
||||
escaped := false
|
||||
for _, b := range data[1:] {
|
||||
if escaped {
|
||||
switch b {
|
||||
case 'n':
|
||||
out = append(out, '\n')
|
||||
case 'r':
|
||||
out = append(out, '\r')
|
||||
case 't':
|
||||
out = append(out, '\t')
|
||||
case 'b':
|
||||
out = append(out, '\b')
|
||||
case 'f':
|
||||
out = append(out, '\f')
|
||||
default:
|
||||
out = append(out, b)
|
||||
}
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch b {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case '(':
|
||||
depth++
|
||||
out = append(out, b)
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return strings.TrimSpace(decodePDFLiteralBytes(out)), true
|
||||
}
|
||||
out = append(out, b)
|
||||
default:
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func readPDFHexString(data []byte) (string, bool) {
|
||||
if len(data) == 0 || data[0] != '<' {
|
||||
return "", false
|
||||
}
|
||||
end := bytes.IndexByte(data[1:], '>')
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
raw := data[1 : end+1]
|
||||
var cleaned []byte
|
||||
for _, b := range raw {
|
||||
switch {
|
||||
case b == ' ' || b == '\t' || b == '\r' || b == '\n':
|
||||
continue
|
||||
case (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F'):
|
||||
cleaned = append(cleaned, b)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if len(cleaned)%2 == 1 {
|
||||
cleaned = append(cleaned, '0')
|
||||
}
|
||||
decoded := make([]byte, hex.DecodedLen(len(cleaned)))
|
||||
if _, err := hex.Decode(decoded, cleaned); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(decodePDFLiteralBytes(decoded)), true
|
||||
}
|
||||
|
||||
func decodePDFLiteralBytes(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case bytes.HasPrefix(data, []byte{0xfe, 0xff}):
|
||||
if decoded, err := textunicode.UTF16(textunicode.BigEndian, textunicode.ExpectBOM).NewDecoder().Bytes(data); err == nil {
|
||||
return string(decoded)
|
||||
}
|
||||
case bytes.HasPrefix(data, []byte{0xff, 0xfe}):
|
||||
if decoded, err := textunicode.UTF16(textunicode.LittleEndian, textunicode.ExpectBOM).NewDecoder().Bytes(data); err == nil {
|
||||
return string(decoded)
|
||||
}
|
||||
}
|
||||
if decoded, err := charmap.Windows1252.NewDecoder().Bytes(data); err == nil {
|
||||
return string(decoded)
|
||||
}
|
||||
return strings.ToValidUTF8(string(data), "")
|
||||
}
|
||||
|
||||
func splitEbookAuthors(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ';' || r == '|'
|
||||
})
|
||||
if len(parts) == 1 {
|
||||
parts = strings.Split(value, " and ")
|
||||
}
|
||||
return uniqueTrimmedStrings(parts)
|
||||
}
|
||||
|
||||
func splitPDFKeywords(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return uniqueTrimmedStrings(strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';'
|
||||
}))
|
||||
}
|
||||
|
||||
func parsePDFDate(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "D:") {
|
||||
value = strings.TrimPrefix(value, "D:")
|
||||
}
|
||||
value = strings.TrimSuffix(value, "Z")
|
||||
if len(value) >= 14 {
|
||||
if t, err := time.Parse("20060102150405", value[:14]); err == nil && t.Year() > 0 {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
if len(value) >= 8 {
|
||||
if t, err := time.Parse("20060102", value[:8]); err == nil && t.Year() > 0 {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
if len(value) >= 4 {
|
||||
if t, err := time.Parse("2006", value[:4]); err == nil && t.Year() > 0 {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func readEPUBZipEntry(reader *zip.Reader, name string) ([]byte, error) {
|
||||
for _, file := range reader.File {
|
||||
if file.Name != name {
|
||||
continue
|
||||
}
|
||||
entry, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer entry.Close()
|
||||
limited := io.LimitReader(entry, maxEPUBMetadataEntrySize+1)
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxEPUBMetadataEntrySize {
|
||||
return nil, fmt.Errorf("epub entry too large: %s", name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("epub entry not found: %s", name)
|
||||
}
|
||||
|
||||
func epubOPFPath(container []byte) (string, error) {
|
||||
var parsed struct {
|
||||
Rootfiles []struct {
|
||||
FullPath string `xml:"full-path,attr"`
|
||||
} `xml:"rootfiles>rootfile"`
|
||||
}
|
||||
if err := decodeEbookXML(container, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rootfile := range parsed.Rootfiles {
|
||||
if strings.TrimSpace(rootfile.FullPath) != "" {
|
||||
return rootfile.FullPath, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("epub container has no rootfile")
|
||||
}
|
||||
|
||||
func parseEPUBOPFMetadata(opf []byte, book *parsedEbook) error {
|
||||
var parsed struct {
|
||||
Metadata struct {
|
||||
Titles []string `xml:"title"`
|
||||
Creators []string `xml:"creator"`
|
||||
Identifiers []string `xml:"identifier"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Dates []string `xml:"date"`
|
||||
Language string `xml:"language"`
|
||||
Subjects []string `xml:"subject"`
|
||||
Descriptions []string `xml:"description"`
|
||||
Meta []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Property string `xml:"property,attr"`
|
||||
Content string `xml:"content,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
}
|
||||
if err := decodeEbookXML(opf, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
book.Title = firstNonEmpty(parsed.Metadata.Titles...)
|
||||
book.Authors = append(book.Authors, parsed.Metadata.Creators...)
|
||||
book.Publisher = parsed.Metadata.Publisher
|
||||
book.Language = parsed.Metadata.Language
|
||||
book.Genres = append(book.Genres, parsed.Metadata.Subjects...)
|
||||
book.Description = firstNonEmpty(parsed.Metadata.Descriptions...)
|
||||
for _, identifier := range parsed.Metadata.Identifiers {
|
||||
if isbn := normalizeEbookISBN(identifier); isbn != "" {
|
||||
book.ISBN = isbn
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, date := range parsed.Metadata.Dates {
|
||||
if t, ok := parseEbookDate(date); ok {
|
||||
book.PublishedAt = t
|
||||
book.Year = t.Year()
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, meta := range parsed.Metadata.Meta {
|
||||
name := strings.ToLower(strings.TrimSpace(firstNonEmpty(meta.Name, meta.Property)))
|
||||
value := strings.TrimSpace(firstNonEmpty(meta.Content, meta.Value))
|
||||
switch name {
|
||||
case "calibre:series", "belongs-to-collection":
|
||||
book.Series = value
|
||||
case "calibre:series_index", "group-position":
|
||||
book.SeriesIndex = value
|
||||
case "calibre:isbn", "isbn", "schema:isbn":
|
||||
if book.ISBN == "" {
|
||||
book.ISBN = normalizeEbookISBN(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractEPUBCover(reader *zip.Reader, opfPath string, opf []byte) (*parsedEbookCover, error) {
|
||||
var parsed struct {
|
||||
Metadata struct {
|
||||
Meta []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Content string `xml:"content,attr"`
|
||||
} `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
Manifest struct {
|
||||
Items []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
MediaType string `xml:"media-type,attr"`
|
||||
Properties string `xml:"properties,attr"`
|
||||
} `xml:"item"`
|
||||
} `xml:"manifest"`
|
||||
}
|
||||
if err := decodeEbookXML(opf, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
coverID := ""
|
||||
for _, meta := range parsed.Metadata.Meta {
|
||||
if strings.EqualFold(strings.TrimSpace(meta.Name), "cover") {
|
||||
coverID = strings.TrimSpace(meta.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var coverHref string
|
||||
var coverType string
|
||||
for _, item := range parsed.Manifest.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
props := strings.Fields(strings.ToLower(item.Properties))
|
||||
isCover := coverID != "" && id == coverID
|
||||
for _, prop := range props {
|
||||
if prop == "cover-image" {
|
||||
isCover = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isCover {
|
||||
continue
|
||||
}
|
||||
coverHref = strings.TrimSpace(item.Href)
|
||||
coverType = strings.TrimSpace(item.MediaType)
|
||||
break
|
||||
}
|
||||
if coverHref == "" {
|
||||
return nil, fmt.Errorf("epub cover not referenced")
|
||||
}
|
||||
|
||||
coverPath := resolveEPUBRelativePath(opfPath, coverHref)
|
||||
data, err := readEPUBZipEntry(reader, coverPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if coverType == "" {
|
||||
coverType = ebookImageContentType(coverPath)
|
||||
}
|
||||
return &parsedEbookCover{ContentType: coverType, Bytes: data}, nil
|
||||
}
|
||||
|
||||
func resolveEPUBRelativePath(baseFile string, href string) string {
|
||||
cleanHref := strings.TrimSpace(href)
|
||||
if decoded, err := urlPathUnescape(cleanHref); err == nil {
|
||||
cleanHref = decoded
|
||||
}
|
||||
baseDir := path.Dir(strings.ReplaceAll(baseFile, "\\", "/"))
|
||||
if baseDir == "." || strings.HasPrefix(cleanHref, "/") {
|
||||
baseDir = ""
|
||||
}
|
||||
return strings.TrimPrefix(path.Clean(path.Join(baseDir, cleanHref)), "/")
|
||||
}
|
||||
|
||||
func urlPathUnescape(value string) (string, error) {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] != '%' || i+2 >= len(value) {
|
||||
out.WriteByte(value[i])
|
||||
continue
|
||||
}
|
||||
hi := fromHex(value[i+1])
|
||||
lo := fromHex(value[i+2])
|
||||
if hi < 0 || lo < 0 {
|
||||
out.WriteByte(value[i])
|
||||
continue
|
||||
}
|
||||
out.WriteByte(byte(hi<<4 | lo))
|
||||
i += 2
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func fromHex(b byte) int {
|
||||
switch {
|
||||
case b >= '0' && b <= '9':
|
||||
return int(b - '0')
|
||||
case b >= 'a' && b <= 'f':
|
||||
return int(b-'a') + 10
|
||||
case b >= 'A' && b <= 'F':
|
||||
return int(b-'A') + 10
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func ebookImageContentType(name string) string {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".avif":
|
||||
return "image/avif"
|
||||
case ".bmp":
|
||||
return "image/bmp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func parseEbookDate(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,990 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/idgen"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/titleutil"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type ebookSQLExecutor interface {
|
||||
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type filesystemMediaItemReader interface {
|
||||
GetByIDs(ctx context.Context, ids []string) ([]*models.MediaItem, error)
|
||||
}
|
||||
|
||||
func ebookScanWorkers() int {
|
||||
if v := os.Getenv("SILO_EBOOK_SCAN_WORKERS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return audiobookScanWorkers()
|
||||
}
|
||||
|
||||
func (s *Scanner) ScanEbookFolder(ctx context.Context, folder *models.MediaFolder) error {
|
||||
if s == nil || folder == nil {
|
||||
return fmt.Errorf("ScanEbookFolder: nil scanner or folder")
|
||||
}
|
||||
return s.scanEbookPaths(ctx, folder, folder.Paths)
|
||||
}
|
||||
|
||||
func (s *Scanner) scanEbookPaths(ctx context.Context, folder *models.MediaFolder, roots []string) error {
|
||||
if s == nil || folder == nil {
|
||||
return fmt.Errorf("scanEbookPaths: nil scanner or folder")
|
||||
}
|
||||
var candidates []string
|
||||
for _, root := range roots {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
slog.Warn("ebook scan: walk error", "path", path, "error", walkErr)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if SupportsEbookFile(path) {
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
slog.Warn("ebook scan: walk root failed", "root", root, "error", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
workers := ebookScanWorkers()
|
||||
slog.Info("ebook scan: starting",
|
||||
"folder_id", folder.ID,
|
||||
"candidates", len(candidates),
|
||||
"workers", workers,
|
||||
)
|
||||
reportEbookScanProgress(ctx, folder.ID, len(candidates), 0, 0, 0)
|
||||
|
||||
ch := make(chan string, workers*2)
|
||||
groupLocks := newEbookGroupLocks()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
processed int64
|
||||
failed int64
|
||||
skipped int64
|
||||
failMu sync.Mutex
|
||||
failures []error
|
||||
cancelErr error
|
||||
)
|
||||
start := time.Now()
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for path := range ch {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.reconcileEbookFile(ctx, folder, path, &skipped, groupLocks); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
failMu.Lock()
|
||||
if cancelErr == nil {
|
||||
cancelErr = err
|
||||
}
|
||||
failMu.Unlock()
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&failed, 1)
|
||||
failMu.Lock()
|
||||
failures = append(failures, fmt.Errorf("%s: %w", path, err))
|
||||
failMu.Unlock()
|
||||
slog.Warn("ebook scan: file failed",
|
||||
"folder_id", folder.ID,
|
||||
"path", path,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
n := atomic.AddInt64(&processed, 1)
|
||||
if n%500 == 0 || n == int64(len(candidates)) {
|
||||
failedCount := atomic.LoadInt64(&failed)
|
||||
skippedCount := atomic.LoadInt64(&skipped)
|
||||
slog.Info("ebook scan: progress",
|
||||
"folder_id", folder.ID,
|
||||
"processed", n,
|
||||
"failed", failedCount,
|
||||
"skipped", skippedCount,
|
||||
"total", len(candidates),
|
||||
"elapsed_sec", int(time.Since(start).Seconds()),
|
||||
)
|
||||
reportEbookScanProgress(ctx, folder.ID, len(candidates), int(n), int(failedCount), int(skippedCount))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, p := range candidates {
|
||||
select {
|
||||
case ch <- p:
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelErr != nil {
|
||||
return cancelErr
|
||||
}
|
||||
|
||||
slog.Info("ebook scan: completed",
|
||||
"folder_id", folder.ID,
|
||||
"processed", atomic.LoadInt64(&processed),
|
||||
"failed", atomic.LoadInt64(&failed),
|
||||
"skipped", atomic.LoadInt64(&skipped),
|
||||
"elapsed_sec", int(time.Since(start).Seconds()),
|
||||
)
|
||||
if processedCount := atomic.LoadInt64(&processed); processedCount > 0 {
|
||||
failedCount := atomic.LoadInt64(&failed)
|
||||
skippedCount := atomic.LoadInt64(&skipped)
|
||||
if failedCount > 0 && skippedCount == 0 && failedCount == processedCount {
|
||||
return fmt.Errorf("ebook scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reportEbookScanProgress(ctx context.Context, folderID int, total, processed, failed, skipped int) {
|
||||
reportProgress(ctx, ProgressUpdate{
|
||||
Phase: "ebook_scan",
|
||||
Message: fmt.Sprintf("Scanning ebooks in folder %d", folderID),
|
||||
CurrentScope: strconv.Itoa(folderID),
|
||||
TotalFiles: total,
|
||||
FilesDiscovered: total,
|
||||
FilesProcessed: processed,
|
||||
Errors: failed,
|
||||
Unchanged: skipped,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFolder, filePath string, skipped *int64, groupLocks *ebookGroupLocks) error {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat ebook file %s: %w", filePath, err)
|
||||
}
|
||||
size := info.Size()
|
||||
modifiedAt := normalizeFileModifiedAt(info.ModTime())
|
||||
|
||||
isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt)
|
||||
if skipErr != nil {
|
||||
slog.Warn("ebook scan: skip-check failed, falling through",
|
||||
"folder_id", folder.ID,
|
||||
"path", filePath,
|
||||
"error", skipErr,
|
||||
)
|
||||
} else if isUnchanged {
|
||||
atomic.AddInt64(skipped, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := parseEbookFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse ebook file %s: %w", filePath, err)
|
||||
}
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
|
||||
unlock := groupLocks.lock(ebookContentGroupKey(&parsed, filePath))
|
||||
defer unlock()
|
||||
|
||||
contentID, err := s.upsertEbookMediaItem(ctx, folder.ID, filePath, &parsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert ebook item: %w", err)
|
||||
}
|
||||
if err := s.upsertEbookMediaFile(ctx, folder, contentID, filePath, size, modifiedAt, &parsed); err != nil {
|
||||
return fmt.Errorf("upsert ebook file: %w", err)
|
||||
}
|
||||
if err := applyEbookSidecarCover(ctx, s.itemRepo, s.imageCacher, contentID, filePath); err != nil {
|
||||
slog.Warn("ebook scan: sidecar cover upload failed",
|
||||
"folder_id", folder.ID,
|
||||
"content_id", contentID,
|
||||
"path", filePath,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if err := applyEbookEmbeddedCover(ctx, s.itemRepo, s.imageCacher, contentID, &parsed); err != nil {
|
||||
slog.Warn("ebook scan: embedded cover upload failed",
|
||||
"folder_id", folder.ID,
|
||||
"content_id", contentID,
|
||||
"path", filePath,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if err := s.upsertEbookPeople(ctx, contentID, &parsed); err != nil {
|
||||
return fmt.Errorf("upsert ebook people: %w", err)
|
||||
}
|
||||
if err := s.upsertEbookSeries(ctx, contentID, &parsed); err != nil {
|
||||
return fmt.Errorf("upsert ebook series: %w", err)
|
||||
}
|
||||
if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), contentID, folder.ID); err != nil {
|
||||
return fmt.Errorf("upsert ebook library membership: %w", err)
|
||||
}
|
||||
if parsed.ISBN != "" {
|
||||
if err := insertEbookISBNProviderID(ctx, s.fileRepo.Pool(), contentID, parsed.ISBN); err != nil {
|
||||
return fmt.Errorf("upsert ebook ISBN provider id: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("ebook scan: indexed",
|
||||
"folder_id", folder.ID,
|
||||
"content_id", contentID,
|
||||
"title", parsed.Title,
|
||||
"authors", parsed.Authors,
|
||||
"path", filePath,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) ebookFileShouldSkip(ctx context.Context, folder *models.MediaFolder, filePath string, size int64, modifiedAt time.Time) (bool, error) {
|
||||
if s.fileRepo == nil || s.itemRepo == nil {
|
||||
return false, nil
|
||||
}
|
||||
existing, err := s.fileRepo.ListByObservedRootPath(ctx, folder.ID, filePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("list existing files: %w", err)
|
||||
}
|
||||
if len(existing) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
mf := existing[0]
|
||||
if mf.FilePath != filePath || mf.FileSize != size || mf.FileModifiedAt == nil || !sameFileModifiedAt(mf.FileModifiedAt, modifiedAt) {
|
||||
return false, nil
|
||||
}
|
||||
if mf.ContentID == "" {
|
||||
return false, nil
|
||||
}
|
||||
statuses, err := s.itemRepo.GetStatusByIDs(ctx, []string{mf.ContentID})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get item status: %w", err)
|
||||
}
|
||||
return !strings.EqualFold(strings.TrimSpace(statuses[mf.ContentID]), "unmatched"), nil
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookMediaItem(ctx context.Context, folderID int, filePath string, book *parsedEbook) (string, error) {
|
||||
if s.itemRepo == nil {
|
||||
return "", fmt.Errorf("itemRepo not configured on Scanner")
|
||||
}
|
||||
if s.fileRepo == nil {
|
||||
return "", fmt.Errorf("fileRepo not configured on Scanner")
|
||||
}
|
||||
|
||||
existingID, err := s.fileRepo.FindContentIDByRootPath(ctx, folderID, filePath, "ebook")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find ebook by root path: %w", err)
|
||||
}
|
||||
if existingID != "" {
|
||||
if err := updateExistingEbookMediaItem(ctx, s.itemRepo, s.itemRepo, existingID, book); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
if existing := s.findEbookByFilePath(ctx, filePath); existing != nil {
|
||||
applyEbookToMediaItem(existing, book)
|
||||
if existing.SortTitle == "" {
|
||||
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
|
||||
}
|
||||
if err := s.itemRepo.Upsert(ctx, existing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existing.ContentID, nil
|
||||
}
|
||||
if existing := s.findEbookByContentGroupKey(ctx, folderID, ebookContentGroupKey(book, filePath)); existing != nil {
|
||||
applyEbookToMediaItem(existing, book)
|
||||
if existing.SortTitle == "" {
|
||||
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
|
||||
}
|
||||
if err := s.itemRepo.Upsert(ctx, existing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existing.ContentID, nil
|
||||
}
|
||||
return resolveEbookMediaItem(ctx, s.fileRepo, s.itemRepo, folderID, filePath, book)
|
||||
}
|
||||
|
||||
func resolveEbookMediaItem(
|
||||
ctx context.Context,
|
||||
rootFinder filesystemRootContentFinder,
|
||||
itemWriter filesystemMediaItemWriter,
|
||||
folderID int,
|
||||
filePath string,
|
||||
book *parsedEbook,
|
||||
) (string, error) {
|
||||
if rootFinder == nil {
|
||||
return "", fmt.Errorf("root content finder not configured")
|
||||
}
|
||||
if itemWriter == nil {
|
||||
return "", fmt.Errorf("media item writer not configured")
|
||||
}
|
||||
existingID, err := rootFinder.FindContentIDByRootPath(ctx, folderID, filePath, "ebook")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find ebook by root path: %w", err)
|
||||
}
|
||||
if existingID != "" {
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
cleanTitle := strings.TrimSpace(book.Title)
|
||||
if cleanTitle == "" {
|
||||
cleanTitle = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
return createEbookMediaItem(ctx, itemWriter, book, cleanTitle)
|
||||
}
|
||||
|
||||
func createEbookMediaItem(ctx context.Context, itemWriter filesystemMediaItemWriter, book *parsedEbook, cleanTitle string) (string, error) {
|
||||
id, err := idgen.NextID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate content_id: %w", err)
|
||||
}
|
||||
item := &models.MediaItem{
|
||||
ContentID: id,
|
||||
Type: "ebook",
|
||||
Title: cleanTitle,
|
||||
SortTitle: titleutil.DeriveDefaultSortTitle(cleanTitle),
|
||||
Year: book.Year,
|
||||
}
|
||||
applyEbookToMediaItem(item, book)
|
||||
if item.SortTitle == "" {
|
||||
item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title)
|
||||
}
|
||||
if err := itemWriter.Upsert(ctx, item); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func updateExistingEbookMediaItem(ctx context.Context, itemReader filesystemMediaItemReader, itemWriter filesystemMediaItemWriter, contentID string, book *parsedEbook) error {
|
||||
if itemReader == nil {
|
||||
return fmt.Errorf("media item reader not configured")
|
||||
}
|
||||
if itemWriter == nil {
|
||||
return fmt.Errorf("media item writer not configured")
|
||||
}
|
||||
items, err := itemReader.GetByIDs(ctx, []string{contentID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get ebook media item %s: %w", contentID, err)
|
||||
}
|
||||
if len(items) == 0 || items[0] == nil {
|
||||
return fmt.Errorf("ebook media item %s not found", contentID)
|
||||
}
|
||||
item := items[0]
|
||||
applyEbookToMediaItem(item, book)
|
||||
if item.SortTitle == "" {
|
||||
item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title)
|
||||
}
|
||||
return itemWriter.Upsert(ctx, item)
|
||||
}
|
||||
|
||||
func applyEbookToMediaItem(item *models.MediaItem, book *parsedEbook) {
|
||||
item.Type = "ebook"
|
||||
if title := strings.TrimSpace(book.Title); title != "" {
|
||||
item.Title = title
|
||||
}
|
||||
if book.Year > 0 {
|
||||
item.Year = book.Year
|
||||
}
|
||||
if book.Description != "" && (item.Overview == "" || looksLikeHTML(item.Overview)) {
|
||||
item.Overview = book.Description
|
||||
}
|
||||
if book.Publisher != "" {
|
||||
item.Studios = mergeUniqueStrings(item.Studios, []string{book.Publisher})
|
||||
}
|
||||
if len(book.Genres) > 0 {
|
||||
item.Genres = mergeUniqueStrings(item.Genres, book.Genres)
|
||||
}
|
||||
if !book.PublishedAt.IsZero() && item.ReleaseDate == nil {
|
||||
rd := book.PublishedAt.UTC().Format("2006-01-02")
|
||||
item.ReleaseDate = &rd
|
||||
}
|
||||
if book.Language != "" && item.OriginalLanguage == "" {
|
||||
item.OriginalLanguage = book.Language
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) findEbookByFilePath(ctx context.Context, filePath string) *models.MediaItem {
|
||||
if s.fileRepo == nil {
|
||||
return nil
|
||||
}
|
||||
var existingID string
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT mf.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.file_path = $1
|
||||
AND mi.type = 'ebook'
|
||||
LIMIT 1
|
||||
`, filePath).Scan(&existingID)
|
||||
if err != nil || existingID == "" {
|
||||
return nil
|
||||
}
|
||||
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return items[0]
|
||||
}
|
||||
|
||||
func (s *Scanner) findEbookByContentGroupKey(ctx context.Context, folderID int, groupKey string) *models.MediaItem {
|
||||
if s.fileRepo == nil || s.itemRepo == nil || strings.TrimSpace(groupKey) == "" {
|
||||
return nil
|
||||
}
|
||||
var existingID string
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT mf.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND mf.group_key_version = 1
|
||||
AND mf.content_group_key = $2
|
||||
AND mf.missing_since IS NULL
|
||||
AND mi.type = 'ebook'
|
||||
ORDER BY CASE WHEN lower(trim(mi.status)) = 'matched' THEN 0 ELSE 1 END,
|
||||
mf.id ASC
|
||||
LIMIT 1
|
||||
`, folderID, groupKey).Scan(&existingID)
|
||||
if err != nil || existingID == "" {
|
||||
return nil
|
||||
}
|
||||
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return items[0]
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookMediaFile(ctx context.Context, folder *models.MediaFolder, contentID string, filePath string, size int64, modifiedAt time.Time, book *parsedEbook) error {
|
||||
mf := buildEbookMediaFile(folder, contentID, filePath, size, modifiedAt, book)
|
||||
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
|
||||
return fmt.Errorf("upsert media file %s: %w", filePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEbookMediaFile(folder *models.MediaFolder, contentID string, filePath string, size int64, modifiedAt time.Time, book *parsedEbook) models.MediaFile {
|
||||
return models.MediaFile{
|
||||
ContentID: contentID,
|
||||
MediaFolderID: folder.ID,
|
||||
CanonicalRootPath: filePath,
|
||||
ObservedRootPath: filePath,
|
||||
ContentGroupKey: ebookContentGroupKey(book, filePath),
|
||||
GroupKeyVersion: 1,
|
||||
BaseTitle: book.Title,
|
||||
BaseYear: book.Year,
|
||||
BaseType: "ebook",
|
||||
IdentityConfidence: ebookIdentityConfidence(book),
|
||||
FilePath: filePath,
|
||||
FileSize: size,
|
||||
FileModifiedAt: &modifiedAt,
|
||||
Container: book.Format,
|
||||
Duration: book.PageCount,
|
||||
ProbeSource: "local",
|
||||
}
|
||||
}
|
||||
|
||||
func ebookContentGroupKey(book *parsedEbook, filePath string) string {
|
||||
if book != nil {
|
||||
if isbn := normalizeEbookISBN(book.ISBN); isbn != "" {
|
||||
return "ebook:isbn:" + isbn
|
||||
}
|
||||
}
|
||||
|
||||
title := ""
|
||||
authors := []string(nil)
|
||||
if book != nil {
|
||||
title = strings.TrimSpace(book.Title)
|
||||
authors = book.Authors
|
||||
}
|
||||
if title == "" {
|
||||
title = ebookTitleFromPath(filePath)
|
||||
}
|
||||
normalizedTitle := normalizeEbookIdentityPart(title)
|
||||
if normalizedTitle == "" {
|
||||
cleanPath := strings.TrimSpace(filepath.Clean(filePath))
|
||||
if cleanPath == "." {
|
||||
return ""
|
||||
}
|
||||
return "ebook:path:" + cleanPath
|
||||
}
|
||||
|
||||
normalizedAuthors := make([]string, 0, len(authors))
|
||||
seenAuthors := make(map[string]struct{}, len(authors))
|
||||
for _, author := range authors {
|
||||
normalized := normalizeEbookIdentityPart(author)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenAuthors[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seenAuthors[normalized] = struct{}{}
|
||||
normalizedAuthors = append(normalizedAuthors, normalized)
|
||||
}
|
||||
sort.Strings(normalizedAuthors)
|
||||
if len(normalizedAuthors) > 0 {
|
||||
return "ebook:title_author:" + normalizedTitle + "|" + strings.Join(normalizedAuthors, ",")
|
||||
}
|
||||
|
||||
dir := strings.TrimSpace(filepath.Clean(filepath.Dir(filePath)))
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
return "ebook:title:" + normalizedTitle + "|dir:" + normalizeEbookIdentityPart(dir)
|
||||
}
|
||||
|
||||
func ebookTitleFromPath(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return ""
|
||||
}
|
||||
if format := ebookFileFormat(base); format != "" {
|
||||
return strings.TrimSuffix(base, format)
|
||||
}
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
|
||||
func normalizeEbookIdentityPart(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
var b strings.Builder
|
||||
previousSpace := false
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
previousSpace = false
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(r) || r == '&' || r == '-' || r == '_' || r == ':' || r == '/' || r == '\\' || r == ',' || r == '.' {
|
||||
if b.Len() > 0 && !previousSpace {
|
||||
b.WriteByte(' ')
|
||||
previousSpace = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
type ebookGroupLocks struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func newEbookGroupLocks() *ebookGroupLocks {
|
||||
return &ebookGroupLocks{locks: make(map[string]*sync.Mutex)}
|
||||
}
|
||||
|
||||
func (l *ebookGroupLocks) lock(key string) func() {
|
||||
if l == nil || strings.TrimSpace(key) == "" {
|
||||
return func() {}
|
||||
}
|
||||
l.mu.Lock()
|
||||
groupLock := l.locks[key]
|
||||
if groupLock == nil {
|
||||
groupLock = &sync.Mutex{}
|
||||
l.locks[key] = groupLock
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
groupLock.Lock()
|
||||
return groupLock.Unlock
|
||||
}
|
||||
|
||||
type ebookCoverMetadataStore interface {
|
||||
GetPosterPath(ctx context.Context, contentID string) (string, error)
|
||||
UpdateMetadata(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error
|
||||
}
|
||||
|
||||
func applyEbookEmbeddedCover(ctx context.Context, store ebookCoverMetadataStore, cacher ebookCoverCacher, contentID string, book *parsedEbook) error {
|
||||
if store == nil || cacher == nil || contentID == "" || book == nil || book.Cover == nil || len(book.Cover.Bytes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return cacheEbookCoverBytes(ctx, store, cacher, contentID, book.Cover.Bytes)
|
||||
}
|
||||
|
||||
func applyEbookSidecarCover(ctx context.Context, store ebookCoverMetadataStore, cacher ebookCoverCacher, contentID string, ebookFilePath string) error {
|
||||
if store == nil || cacher == nil || contentID == "" || ebookFilePath == "" {
|
||||
return nil
|
||||
}
|
||||
cover, _, err := findSidecarBookCover(filepath.Dir(ebookFilePath))
|
||||
if err != nil || cover == nil {
|
||||
return err
|
||||
}
|
||||
return cacheEbookCoverBytes(ctx, store, cacher, contentID, cover.Bytes)
|
||||
}
|
||||
|
||||
func cacheEbookCoverBytes(ctx context.Context, store ebookCoverMetadataStore, cacher ebookCoverCacher, contentID string, data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
existingPosterPath, err := store.GetPosterPath(ctx, contentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get ebook poster path for cover: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(existingPosterPath) != "" {
|
||||
return nil
|
||||
}
|
||||
basePath, ext, thumbhash, err := cacher.CacheEbookCover(ctx, data, contentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
posterPath := strings.TrimRight(basePath, "/") + "/original" + ext
|
||||
update := &catalog.MetadataUpdate{PosterPath: &posterPath}
|
||||
if thumbhash != "" {
|
||||
update.PosterThumbhash = &thumbhash
|
||||
}
|
||||
return store.UpdateMetadata(ctx, contentID, update)
|
||||
}
|
||||
|
||||
var sidecarCoverNames = []string{"cover", "folder", "front", "poster", "thumbnail"}
|
||||
var sidecarCoverExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".bmp"}
|
||||
|
||||
func findSidecarBookCover(dir string) (*parsedEbookCover, string, error) {
|
||||
if dir == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
byName := make(map[string]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
byName[strings.ToLower(entry.Name())] = filepath.Join(dir, entry.Name())
|
||||
}
|
||||
for _, name := range sidecarCoverNames {
|
||||
for _, ext := range sidecarCoverExtensions {
|
||||
path := byName[name+ext]
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
data, err := readSidecarCover(path)
|
||||
if err != nil {
|
||||
return nil, path, err
|
||||
}
|
||||
return &parsedEbookCover{
|
||||
ContentType: ebookImageContentType(path),
|
||||
Bytes: data,
|
||||
}, path, nil
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func readSidecarCover(path string) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() > maxEPUBMetadataEntrySize {
|
||||
return nil, fmt.Errorf("sidecar cover too large: %s", path)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxEPUBMetadataEntrySize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxEPUBMetadataEntrySize {
|
||||
return nil, fmt.Errorf("sidecar cover too large: %s", path)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("sidecar cover empty: %s", path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func insertEbookLibraryMembership(ctx context.Context, exec ebookSQLExecutor, contentID string, folderID int) error {
|
||||
_, err := exec.Exec(ctx, `
|
||||
INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (content_id, media_folder_id) DO NOTHING
|
||||
`, contentID, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertEbookISBNProviderID(ctx context.Context, exec ebookSQLExecutor, contentID string, isbn string) error {
|
||||
_, err := exec.Exec(ctx, `
|
||||
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
|
||||
VALUES ($1, 'isbn', $2, 'ebook')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, contentID, isbn)
|
||||
return err
|
||||
}
|
||||
|
||||
type ebookCredit struct {
|
||||
Name string
|
||||
Kind models.PersonKind
|
||||
}
|
||||
|
||||
func ebookPeopleCreditsEqual(existing []models.ItemPerson, desired []ebookCredit) bool {
|
||||
existingAuthors := make([]models.ItemPerson, 0, len(existing))
|
||||
for _, p := range existing {
|
||||
if p.Kind == models.PersonKindNarrator {
|
||||
return false
|
||||
}
|
||||
if p.Kind == models.PersonKindAuthor {
|
||||
existingAuthors = append(existingAuthors, p)
|
||||
}
|
||||
}
|
||||
if len(existingAuthors) != len(desired) {
|
||||
return false
|
||||
}
|
||||
type key struct {
|
||||
name string
|
||||
kind models.PersonKind
|
||||
}
|
||||
have := make(map[key]struct{}, len(existingAuthors))
|
||||
for _, p := range existingAuthors {
|
||||
have[key{strings.ToLower(strings.TrimSpace(p.Person.Name)), p.Kind}] = struct{}{}
|
||||
}
|
||||
for _, d := range desired {
|
||||
k := key{strings.ToLower(strings.TrimSpace(d.Name)), d.Kind}
|
||||
if _, ok := have[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type ebookResolvedAuthor struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func mergeEbookPeople(existing []models.ItemPerson, authors []ebookResolvedAuthor) []models.ItemPerson {
|
||||
people := 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(people)
|
||||
people = append(people, p)
|
||||
}
|
||||
for _, author := range authors {
|
||||
people = append(people, models.ItemPerson{
|
||||
Person: models.Person{ID: author.ID, Name: author.Name},
|
||||
Kind: models.PersonKindAuthor,
|
||||
SortOrder: len(people),
|
||||
})
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func ebookPeopleForReplace(existing []models.ItemPerson, getErr error, authors []ebookResolvedAuthor) ([]models.ItemPerson, error) {
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("get ebook people: %w", getErr)
|
||||
}
|
||||
return mergeEbookPeople(existing, authors), nil
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookPeople(ctx context.Context, contentID string, book *parsedEbook) error {
|
||||
if s.personRepo == nil {
|
||||
return fmt.Errorf("personRepo not configured on Scanner")
|
||||
}
|
||||
if s.itemRepo == nil {
|
||||
return fmt.Errorf("itemRepo not configured on Scanner")
|
||||
}
|
||||
|
||||
var desired []ebookCredit
|
||||
for _, author := range book.Authors {
|
||||
if name := strings.TrimSpace(author); name != "" {
|
||||
desired = append(desired, ebookCredit{Name: name, Kind: models.PersonKindAuthor})
|
||||
}
|
||||
}
|
||||
if len(desired) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, err := s.itemRepo.GetPeople(ctx, contentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get ebook people: %w", err)
|
||||
}
|
||||
if ebookPeopleCreditsEqual(existing, desired) {
|
||||
return nil
|
||||
}
|
||||
|
||||
authors := make([]ebookResolvedAuthor, 0, len(desired))
|
||||
for _, c := range desired {
|
||||
personID, err := s.personRepo.FindOrCreate(ctx, models.Person{Name: c.Name})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find-or-create person %q: %w", c.Name, err)
|
||||
}
|
||||
authors = append(authors, ebookResolvedAuthor{ID: personID, Name: c.Name})
|
||||
}
|
||||
people, err := ebookPeopleForReplace(existing, nil, authors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.itemRepo.ReplacePeople(ctx, contentID, people)
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookSeries(ctx context.Context, contentID string, book *parsedEbook) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("Scanner not configured")
|
||||
}
|
||||
if s.fileRepo == nil {
|
||||
return fmt.Errorf("fileRepo not configured on Scanner")
|
||||
}
|
||||
|
||||
var currentName *string
|
||||
var currentIdx *float64
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT series_name, series_index FROM ebook_series WHERE content_id = $1
|
||||
`, contentID).Scan(¤tName, ¤tIdx)
|
||||
|
||||
plan, err := planEbookSeriesWrite(book, currentName, currentIdx, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch plan.Kind {
|
||||
case ebookSeriesWriteNone:
|
||||
return nil
|
||||
case ebookSeriesWriteDelete:
|
||||
if _, delErr := s.fileRepo.Pool().Exec(ctx,
|
||||
`DELETE FROM ebook_series WHERE content_id = $1`, contentID); delErr != nil {
|
||||
return fmt.Errorf("delete ebook_series row: %w", delErr)
|
||||
}
|
||||
return nil
|
||||
case ebookSeriesWriteUpsert:
|
||||
var idx any
|
||||
if plan.Index != nil {
|
||||
idx = *plan.Index
|
||||
}
|
||||
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
||||
INSERT INTO ebook_series (content_id, series_name, series_index, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
series_name = EXCLUDED.series_name,
|
||||
series_index = EXCLUDED.series_index,
|
||||
updated_at = NOW()
|
||||
`, contentID, plan.Name, idx); err != nil {
|
||||
return fmt.Errorf("upsert ebook_series row: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown ebook_series write kind: %d", plan.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func ebookSeriesDesired(book *parsedEbook) (string, *float64) {
|
||||
if book == nil {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(book.Series), parseSeriesIndex(book.SeriesIndex)
|
||||
}
|
||||
|
||||
type ebookSeriesWriteKind int
|
||||
|
||||
const (
|
||||
ebookSeriesWriteNone ebookSeriesWriteKind = iota
|
||||
ebookSeriesWriteDelete
|
||||
ebookSeriesWriteUpsert
|
||||
)
|
||||
|
||||
type ebookSeriesWritePlan struct {
|
||||
Kind ebookSeriesWriteKind
|
||||
Name string
|
||||
Index *float64
|
||||
}
|
||||
|
||||
func planEbookSeriesWrite(book *parsedEbook, currentName *string, currentIdx *float64, queryErr error) (ebookSeriesWritePlan, error) {
|
||||
if queryErr != nil {
|
||||
if !errors.Is(queryErr, pgx.ErrNoRows) {
|
||||
return ebookSeriesWritePlan{}, fmt.Errorf("query ebook_series: %w", queryErr)
|
||||
}
|
||||
currentName = nil
|
||||
currentIdx = nil
|
||||
}
|
||||
|
||||
desiredName, desiredIdx := ebookSeriesDesired(book)
|
||||
if desiredName == "" {
|
||||
if currentName == nil {
|
||||
return ebookSeriesWritePlan{Kind: ebookSeriesWriteNone}, nil
|
||||
}
|
||||
return ebookSeriesWritePlan{Kind: ebookSeriesWriteDelete}, nil
|
||||
}
|
||||
|
||||
if currentName != nil && *currentName == desiredName && floatPtrEqual(currentIdx, desiredIdx) {
|
||||
return ebookSeriesWritePlan{Kind: ebookSeriesWriteNone}, nil
|
||||
}
|
||||
|
||||
return ebookSeriesWritePlan{
|
||||
Kind: ebookSeriesWriteUpsert,
|
||||
Name: desiredName,
|
||||
Index: desiredIdx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ebookIdentityConfidence(book *parsedEbook) string {
|
||||
if book == nil {
|
||||
return "low"
|
||||
}
|
||||
|
||||
score := 0
|
||||
if book.Title != "" {
|
||||
score++
|
||||
}
|
||||
if len(book.Authors) > 0 {
|
||||
score++
|
||||
}
|
||||
if book.Year > 0 {
|
||||
score++
|
||||
}
|
||||
if book.ISBN != "" {
|
||||
score++
|
||||
}
|
||||
|
||||
switch {
|
||||
case score >= 4:
|
||||
return "high"
|
||||
case score > 0:
|
||||
return "medium"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,9 +97,12 @@ func shouldSkipMovieSupplementalFile(path string) bool {
|
||||
}
|
||||
|
||||
// Scanner discovers and indexes media files in media folders.
|
||||
// scannerImageCacher is the slice of imagecache.Cacher the audiobook
|
||||
// branch uses. Wired via SetImageCacher; nil-safe when absent.
|
||||
type scannerImageCacher = audiobookCoverCacher
|
||||
// scannerImageCacher is the slice of imagecache.Cacher the book scanners use.
|
||||
// Wired via SetImageCacher; nil-safe when absent.
|
||||
type scannerImageCacher interface {
|
||||
audiobookCoverCacher
|
||||
ebookCoverCacher
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
fileRepo *FileRepository
|
||||
@@ -126,9 +129,9 @@ type Scanner struct {
|
||||
seriesQueueSyncer SeriesQueueSyncer
|
||||
}
|
||||
|
||||
// SetImageCacher installs the imagecache.Cacher used by the audiobook
|
||||
// branch to push embedded M4B cover art into the public assets bucket.
|
||||
// Optional; if unset, audiobook covers are not extracted.
|
||||
// SetImageCacher installs the imagecache.Cacher used by book scanners to push
|
||||
// embedded cover art into the public assets bucket. Optional; if unset, local
|
||||
// covers are not extracted.
|
||||
func (s *Scanner) SetImageCacher(cacher scannerImageCacher) {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -241,6 +244,13 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if err := s.ScanEbookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
return s.scanPaths(watchCtx, folder, folder.Paths, folder.Paths, true)
|
||||
}
|
||||
|
||||
@@ -263,6 +273,12 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if err := s.scanEbookPaths(watchCtx, folder, []string{cleanSubtree}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
return s.scanPaths(watchCtx, folder, []string{cleanSubtree}, []string{cleanSubtree}, false)
|
||||
}
|
||||
|
||||
@@ -309,6 +325,15 @@ func isPodcastLibraryType(libraryType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isEbookLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "ebook", "ebooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// walkMode tells walkLogicalTree which file extensions to surface and
|
||||
// which library-specific filename heuristics (sample/extra skipping)
|
||||
// to apply.
|
||||
@@ -319,6 +344,7 @@ const (
|
||||
walkModeMovie // movie library: video extensions + sample/extra skipping
|
||||
walkModeAudiobook // audiobook library: audio extensions, no skipping
|
||||
walkModePodcast // podcast library: audio extensions, no skipping
|
||||
walkModeEbook // ebook library: ebook extensions, no skipping
|
||||
)
|
||||
|
||||
// walkModeFor derives a walkMode from a media_folders.type string.
|
||||
@@ -332,6 +358,8 @@ func walkModeFor(folderType string) walkMode {
|
||||
return walkModeAudiobook
|
||||
case isPodcastLibraryType(folderType):
|
||||
return walkModePodcast
|
||||
case isEbookLibraryType(folderType):
|
||||
return walkModeEbook
|
||||
default:
|
||||
return walkModeVideo
|
||||
}
|
||||
@@ -343,6 +371,8 @@ func (m walkMode) acceptsExt(ext string) bool {
|
||||
switch m {
|
||||
case walkModeAudiobook, walkModePodcast:
|
||||
return audioExtensions[ext]
|
||||
case walkModeEbook:
|
||||
return ebookExtensions[ext]
|
||||
default:
|
||||
return videoExtensions[ext]
|
||||
}
|
||||
|
||||
@@ -200,3 +200,76 @@ func TestIsPodcastLibraryType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEbookLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ebooks", true},
|
||||
{"ebook", true},
|
||||
{"Ebook", true},
|
||||
{" EBOOKS ", true},
|
||||
{"audiobooks", false},
|
||||
{"movies", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isEbookLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isEbookLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeForEbookLibraryTypes(t *testing.T) {
|
||||
for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} {
|
||||
if got := walkModeFor(libraryType); got != walkModeEbook {
|
||||
t.Fatalf("walkModeFor(%q) = %v, want %v", libraryType, got, walkModeEbook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeEbookAcceptsEbookExtensionsOnly(t *testing.T) {
|
||||
for _, ext := range []string{".epub", ".pdf", ".mobi", ".azw", ".azw3", ".fb2", ".fbz", ".cbz", ".cbr", ".md"} {
|
||||
if !walkModeEbook.acceptsExt(ext) {
|
||||
t.Fatalf("walkModeEbook should accept %s", ext)
|
||||
}
|
||||
}
|
||||
for _, ext := range []string{".txt", ".mp4", ".mp3", ".mkv"} {
|
||||
if walkModeEbook.acceptsExt(ext) {
|
||||
t.Fatalf("walkModeEbook should reject %s", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFolderEbookLibraryRoutesToEbookScanner(t *testing.T) {
|
||||
scanner := &Scanner{}
|
||||
result, err := scanner.ScanFolder(context.Background(), &models.MediaFolder{
|
||||
ID: 0,
|
||||
Type: " ebooks ",
|
||||
Paths: []string{t.TempDir()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFolder ebook error = %v, want nil", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("ScanFolder ebook result = nil, want empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSubtreeEbookLibraryRoutesToEbookScanner(t *testing.T) {
|
||||
subtree := t.TempDir()
|
||||
|
||||
scanner := &Scanner{}
|
||||
result, err := scanner.ScanSubtree(context.Background(), &models.MediaFolder{
|
||||
ID: 0,
|
||||
Type: "ebook",
|
||||
Paths: []string{subtree},
|
||||
}, subtree)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSubtree ebook error = %v, want nil", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("ScanSubtree ebook result = nil, want empty result")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,14 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-audiobooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
}
|
||||
case "ebooks", "ebook":
|
||||
return []*PageSection{
|
||||
{ID: "default-continue-reading", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Reading", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-recently-added-ebooks", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Ebooks", ItemLimit: 20, Config: defaultMediaScopeConfig("ebook"), Enabled: true},
|
||||
{ID: "default-recently-released-ebooks", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Ebooks", ItemLimit: 20, Config: defaultMediaScopeConfig("ebook"), Enabled: true},
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-ebooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("ebook"), Enabled: true},
|
||||
}
|
||||
default:
|
||||
return DefaultLibrarySections(libraryID)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,69 @@ func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeEbooks(t *testing.T) {
|
||||
libraryID := 11
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "ebooks")
|
||||
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("expected 5 ebook default sections, got %d", len(got))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
id string
|
||||
sectionType SectionType
|
||||
title string
|
||||
position int
|
||||
}{
|
||||
{index: 0, id: "default-continue-reading", sectionType: SectionContinueWatching, title: "Continue Reading", position: 0},
|
||||
{index: 1, id: "default-recently-added-ebooks", sectionType: SectionRecentlyAdded, title: "Recently Added Ebooks", position: 1},
|
||||
{index: 2, id: "default-recently-released-ebooks", sectionType: SectionRecentlyReleased, title: "Recently Released Ebooks", position: 2},
|
||||
{index: 3, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 3},
|
||||
{index: 4, id: "default-random-ebooks", sectionType: SectionRandom, title: "Random Picks", position: 4},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
section := got[tt.index]
|
||||
if section.ID != tt.id {
|
||||
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
|
||||
}
|
||||
if section.SectionType != tt.sectionType {
|
||||
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
|
||||
}
|
||||
if section.Title != tt.title {
|
||||
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
|
||||
}
|
||||
if section.Position != tt.position {
|
||||
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
|
||||
}
|
||||
if section.Featured {
|
||||
t.Fatalf("section %d featured = true, want false", tt.index)
|
||||
}
|
||||
}
|
||||
|
||||
assertEmptyJSON(t, got[0].Config)
|
||||
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
assertEmptyJSON(t, got[3].Config)
|
||||
assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) {
|
||||
libraryID := 99
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "mixed")
|
||||
|
||||
@@ -371,6 +371,11 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res
|
||||
if err != nil {
|
||||
return SectionWithItems{}, fmt.Errorf("listing progress: %w", err)
|
||||
}
|
||||
ebookProgressEntries, err := f.listEbookContinueWatchingProgress(ctx, userID, profileID, limit)
|
||||
if err != nil {
|
||||
return SectionWithItems{}, fmt.Errorf("listing ebook reader progress: %w", err)
|
||||
}
|
||||
progressEntries = mergeContinueWatchingProgress(progressEntries, ebookProgressEntries, limit)
|
||||
progressEntries = f.filterContinueWatchingDismissals(ctx, store, profileID, progressEntries)
|
||||
|
||||
// Build in-progress items
|
||||
@@ -484,6 +489,76 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) listEbookContinueWatchingProgress(ctx context.Context, userID int, profileID string, limit int) ([]userstore.WatchProgress, error) {
|
||||
if f.pool == nil || userID <= 0 || profileID == "" || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := f.pool.Query(ctx, `
|
||||
SELECT content_id, progress::double precision, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE user_id = $1
|
||||
AND profile_id = $2
|
||||
AND progress > 0
|
||||
AND progress < 0.9
|
||||
ORDER BY updated_at DESC, content_id ASC
|
||||
LIMIT $3
|
||||
`, userID, profileID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]userstore.WatchProgress, 0)
|
||||
for rows.Next() {
|
||||
var contentID string
|
||||
var progress float64
|
||||
var updatedAt time.Time
|
||||
if err := rows.Scan(&contentID, &progress, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, userstore.WatchProgress{
|
||||
MediaItemID: contentID,
|
||||
PositionSeconds: progress,
|
||||
DurationSeconds: 1,
|
||||
UpdatedAt: updatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func mergeContinueWatchingProgress(videoEntries, ebookEntries []userstore.WatchProgress, limit int) []userstore.WatchProgress {
|
||||
if len(ebookEntries) == 0 {
|
||||
return videoEntries
|
||||
}
|
||||
merged := make([]userstore.WatchProgress, 0, len(videoEntries)+len(ebookEntries))
|
||||
merged = append(merged, videoEntries...)
|
||||
merged = append(merged, ebookEntries...)
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
left := parseWatchProgressUpdatedAt(merged[i].UpdatedAt)
|
||||
right := parseWatchProgressUpdatedAt(merged[j].UpdatedAt)
|
||||
if left.Equal(right) {
|
||||
return merged[i].MediaItemID < merged[j].MediaItemID
|
||||
}
|
||||
return left.After(right)
|
||||
})
|
||||
if limit > 0 && len(merged) > limit {
|
||||
merged = merged[:limit]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseWatchProgressUpdatedAt(value string) time.Time {
|
||||
updatedAt, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return updatedAt
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchNextUpSection(ctx context.Context, resolved ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) (SectionWithItems, error) {
|
||||
emptyResult := SectionWithItems{
|
||||
ResolvedSection: resolved,
|
||||
|
||||
@@ -107,6 +107,36 @@ func TestFilterSupersededEpisodeProgressEntriesDropsOlderPartialsAfterLaterCompl
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeContinueWatchingProgressIncludesEbooksByUpdatedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
videoEntries := []userstore.WatchProgress{
|
||||
{
|
||||
MediaItemID: "movie-1",
|
||||
PositionSeconds: 240,
|
||||
DurationSeconds: 1200,
|
||||
UpdatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
ebookEntries := []userstore.WatchProgress{
|
||||
{
|
||||
MediaItemID: "ebook-1",
|
||||
PositionSeconds: 0.42,
|
||||
DurationSeconds: 1,
|
||||
UpdatedAt: time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeContinueWatchingProgress(videoEntries, ebookEntries, 10)
|
||||
|
||||
if got, want := progressIDs(merged), []string{"ebook-1", "movie-1"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("merged IDs = %v, want %v", got, want)
|
||||
}
|
||||
if merged[0].PositionSeconds != 0.42 || merged[0].DurationSeconds != 1 {
|
||||
t.Fatalf("ebook progress = %+v, want normalized reader progress", merged[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedProgressSnapshotsPagesThroughConfiguredStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -171,6 +201,14 @@ func contentIDs(items []*models.MediaItem) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func progressIDs(entries []userstore.WatchProgress) []string {
|
||||
ids := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
ids = append(ids, entry.MediaItemID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
type ebookMetadataEnricher interface {
|
||||
Run(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// SyncEbookMetadataTask runs the periodic ebook enrichment sweep.
|
||||
// It calls ebooks.Enricher.Run() which selects unenriched ebook media_items,
|
||||
// resolves the per-folder metadata-provider chain at content_level='ebook',
|
||||
// and writes results back to the database.
|
||||
type SyncEbookMetadataTask struct {
|
||||
enricher ebookMetadataEnricher
|
||||
}
|
||||
|
||||
// NewSyncEbookMetadataTask constructs the task.
|
||||
func NewSyncEbookMetadataTask(enricher ebookMetadataEnricher) *SyncEbookMetadataTask {
|
||||
return &SyncEbookMetadataTask{enricher: enricher}
|
||||
}
|
||||
|
||||
func (t *SyncEbookMetadataTask) Key() string { return "sync_ebook_metadata" }
|
||||
func (t *SyncEbookMetadataTask) Name() string { return "Sync Ebook Metadata" }
|
||||
func (t *SyncEbookMetadataTask) Description() string {
|
||||
return "Fetches metadata (cover art, overview, authors) for ebooks that have not yet been enriched"
|
||||
}
|
||||
func (t *SyncEbookMetadataTask) Category() taskmanager.TaskCategory {
|
||||
return taskmanager.TaskCategoryMetadata
|
||||
}
|
||||
func (t *SyncEbookMetadataTask) IsHidden() bool { return false }
|
||||
|
||||
func (t *SyncEbookMetadataTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
||||
return []taskmanager.TriggerConfig{
|
||||
{Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SyncEbookMetadataTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
|
||||
progress.Report(0, "Scanning for unenriched ebooks")
|
||||
|
||||
enriched, err := t.enricher.Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ebook metadata sync: %w", err)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]int{"items_enriched": enriched})
|
||||
progress.SetResultData(result)
|
||||
progress.Report(100, fmt.Sprintf("Ebook metadata sync complete (%d items enriched)", enriched))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
type fakeEbookMetadataEnricher struct {
|
||||
enriched int
|
||||
err error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeEbookMetadataEnricher) Run(context.Context) (int, error) {
|
||||
f.called = true
|
||||
return f.enriched, f.err
|
||||
}
|
||||
|
||||
type ebookMetadataProgressReporter struct {
|
||||
messages []string
|
||||
result json.RawMessage
|
||||
}
|
||||
|
||||
func (p *ebookMetadataProgressReporter) Report(_ float64, message string) {
|
||||
p.messages = append(p.messages, message)
|
||||
}
|
||||
|
||||
func (p *ebookMetadataProgressReporter) SetResultData(data json.RawMessage) {
|
||||
p.result = data
|
||||
}
|
||||
|
||||
func TestSyncEbookMetadataTaskProperties(t *testing.T) {
|
||||
task := NewSyncEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
||||
|
||||
if task.Key() != "sync_ebook_metadata" {
|
||||
t.Fatalf("Key() = %q, want sync_ebook_metadata", task.Key())
|
||||
}
|
||||
if task.Name() != "Sync Ebook Metadata" {
|
||||
t.Fatalf("Name() = %q, want Sync Ebook Metadata", task.Name())
|
||||
}
|
||||
if task.Category() != taskmanager.TaskCategoryMetadata {
|
||||
t.Fatalf("Category() = %q, want %q", task.Category(), taskmanager.TaskCategoryMetadata)
|
||||
}
|
||||
if task.IsHidden() {
|
||||
t.Fatal("IsHidden() = true, want false")
|
||||
}
|
||||
triggers := task.DefaultTriggers()
|
||||
if len(triggers) != 1 || triggers[0].Type != taskmanager.TriggerTypeInterval || triggers[0].IntervalMs != 5*60*1000 {
|
||||
t.Fatalf("DefaultTriggers() = %#v, want one 5 minute interval", triggers)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(task.Description()), "narrator") {
|
||||
t.Fatalf("Description() mentions narrator: %q", task.Description())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncEbookMetadataTaskExecuteReportsResult(t *testing.T) {
|
||||
enricher := &fakeEbookMetadataEnricher{enriched: 3}
|
||||
task := NewSyncEbookMetadataTask(enricher)
|
||||
progress := &ebookMetadataProgressReporter{}
|
||||
|
||||
if err := task.Execute(context.Background(), progress); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !enricher.called {
|
||||
t.Fatal("enricher was not called")
|
||||
}
|
||||
var result map[string]int
|
||||
if err := json.Unmarshal(progress.result, &result); err != nil {
|
||||
t.Fatalf("result data JSON error: %v", err)
|
||||
}
|
||||
if result["items_enriched"] != 3 {
|
||||
t.Fatalf("items_enriched = %d, want 3", result["items_enriched"])
|
||||
}
|
||||
if len(progress.messages) == 0 || !strings.Contains(progress.messages[len(progress.messages)-1], "3 items enriched") {
|
||||
t.Fatalf("progress messages = %#v, want completion count", progress.messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncEbookMetadataTaskExecuteWrapsError(t *testing.T) {
|
||||
enricher := &fakeEbookMetadataEnricher{err: errors.New("boom")}
|
||||
task := NewSyncEbookMetadataTask(enricher)
|
||||
|
||||
err := task.Execute(context.Background(), &ebookMetadataProgressReporter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "ebook metadata sync") {
|
||||
t.Fatalf("Execute() error = %v, want wrapped ebook metadata sync error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE ebook_series (
|
||||
content_id TEXT PRIMARY KEY REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
series_name TEXT NOT NULL,
|
||||
series_index NUMERIC,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_series_name_lower
|
||||
ON ebook_series (LOWER(series_name), series_index NULLS LAST);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS ebook_series;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE ebook_reader_progress (
|
||||
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,
|
||||
file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
|
||||
location TEXT NOT NULL,
|
||||
progress DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, profile_id, content_id),
|
||||
CHECK (progress >= 0 AND progress <= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_progress_profile_updated
|
||||
ON ebook_reader_progress (user_id, profile_id, updated_at DESC);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS ebook_reader_progress;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,41 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
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);
|
||||
|
||||
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);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS ebook_reader_annotations;
|
||||
DROP TABLE IF EXISTS ebook_reader_config;
|
||||
-- +goose StatementEnd
|
||||
@@ -23,15 +23,19 @@
|
||||
"@fontsource/noto-sans-thai": "^5.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"embla-carousel-wheel-gestures": "^8.1.0",
|
||||
"foliate-js": "file:vendor/foliate-js",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"jassub": "^2.4.2",
|
||||
"lucide-react": "^0.576.0",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
Generated
+167
@@ -37,6 +37,9 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.19
|
||||
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@zip.js/zip.js':
|
||||
specifier: ^2.8.26
|
||||
version: 2.8.26
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -52,6 +55,9 @@ importers:
|
||||
embla-carousel-wheel-gestures:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0(embla-carousel@8.6.0)
|
||||
foliate-js:
|
||||
specifier: file:vendor/foliate-js
|
||||
version: file:vendor/foliate-js
|
||||
framer-motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -64,6 +70,12 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.576.0
|
||||
version: 0.576.0(react@19.2.4)
|
||||
node-unrar-js:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
pdfjs-dist:
|
||||
specifier: ^5.7.284
|
||||
version: 5.7.284
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -586,6 +598,81 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -1695,6 +1782,10 @@ packages:
|
||||
'@vitest/utils@4.1.0':
|
||||
resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
|
||||
|
||||
'@zip.js/zip.js@2.8.26':
|
||||
resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==}
|
||||
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'}
|
||||
|
||||
abslink@1.1.6:
|
||||
resolution: {integrity: sha512-8fQgnUoVSgc1IhOrYzdDY+wTDPktbuYjds2LKf9kWYWKwDnHgXU168gdV3sPei7vevFf5m2fPZ6IQSKZkSjHVg==}
|
||||
|
||||
@@ -1800,6 +1891,9 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
construct-style-sheets-polyfill@3.1.0:
|
||||
resolution: {integrity: sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -2006,6 +2100,9 @@ packages:
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
foliate-js@file:vendor/foliate-js:
|
||||
resolution: {directory: vendor/foliate-js, type: directory}
|
||||
|
||||
framer-motion@12.38.0:
|
||||
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
|
||||
peerDependencies:
|
||||
@@ -2359,6 +2456,10 @@ packages:
|
||||
node-releases@2.0.36:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
node-unrar-js@2.0.2:
|
||||
resolution: {integrity: sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2396,6 +2497,10 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdfjs-dist@5.7.284:
|
||||
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3271,6 +3376,54 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.100
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.100
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.100
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
|
||||
optional: true
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -4406,6 +4559,8 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@zip.js/zip.js@2.8.26': {}
|
||||
|
||||
abslink@1.1.6: {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||
@@ -4495,6 +4650,8 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
construct-style-sheets-polyfill@3.1.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
@@ -4721,6 +4878,10 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
foliate-js@file:vendor/foliate-js:
|
||||
dependencies:
|
||||
construct-style-sheets-polyfill: 3.1.0
|
||||
|
||||
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.38.0
|
||||
@@ -4997,6 +5158,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.36: {}
|
||||
|
||||
node-unrar-js@2.0.2: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
@@ -5032,6 +5195,10 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdfjs-dist@5.7.284:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.100
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/* Copyright 2014 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.annotationLayer {
|
||||
color-scheme: only light;
|
||||
|
||||
--annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");
|
||||
--input-focus-border-color: Highlight;
|
||||
--input-focus-outline: 1px solid Canvas;
|
||||
--input-unfocused-border-color: transparent;
|
||||
--input-disabled-border-color: transparent;
|
||||
--input-hover-border-color: black;
|
||||
--link-outline: none;
|
||||
|
||||
@media screen and (forced-colors: active) {
|
||||
--input-focus-border-color: CanvasText;
|
||||
--input-unfocused-border-color: ActiveText;
|
||||
--input-disabled-border-color: GrayText;
|
||||
--input-hover-border-color: Highlight;
|
||||
--link-outline: 1.5px solid LinkText;
|
||||
|
||||
.textWidgetAnnotation :is(input, textarea):required,
|
||||
.choiceWidgetAnnotation select:required,
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) input:required {
|
||||
outline: 1.5px solid selectedItem;
|
||||
}
|
||||
|
||||
.linkAnnotation {
|
||||
outline: var(--link-outline);
|
||||
|
||||
&:hover {
|
||||
backdrop-filter: var(--hcm-highlight-filter);
|
||||
}
|
||||
|
||||
& > a:hover {
|
||||
opacity: 0 !important;
|
||||
background: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.popupAnnotation .popup {
|
||||
outline: calc(1.5px * var(--total-scale-factor)) solid CanvasText !important;
|
||||
background-color: ButtonFace !important;
|
||||
color: ButtonText !important;
|
||||
}
|
||||
|
||||
.highlightArea:hover::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: var(--hcm-highlight-filter);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.popupAnnotation.focused .popup {
|
||||
outline: calc(3px * var(--total-scale-factor)) solid Highlight !important;
|
||||
}
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
transform-origin: 0 0;
|
||||
|
||||
&[data-main-rotation="90"] .norotate {
|
||||
transform: rotate(270deg) translateX(-100%);
|
||||
}
|
||||
&[data-main-rotation="180"] .norotate {
|
||||
transform: rotate(180deg) translate(-100%, -100%);
|
||||
}
|
||||
&[data-main-rotation="270"] .norotate {
|
||||
transform: rotate(90deg) translateY(-100%);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
section,
|
||||
.popup {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.annotationContent {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
&.freetext {
|
||||
background: transparent;
|
||||
border: none;
|
||||
inset: 0;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
font: 10px sans-serif;
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
transform-origin: 0 0;
|
||||
user-select: none;
|
||||
|
||||
&:has(div.annotationContent) {
|
||||
canvas.annotationContent {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.overlaidText {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.textLayer.selecting ~ & section {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
||||
position: absolute;
|
||||
font-size: 1em;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
|
||||
> a:hover {
|
||||
opacity: 0.2;
|
||||
background-color: rgb(255 255 0);
|
||||
}
|
||||
|
||||
.linkAnnotation.hasBorder:hover {
|
||||
background-color: rgb(255 255 0 / 0.2);
|
||||
}
|
||||
|
||||
.hasBorder {
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.textAnnotation img {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation :is(input, textarea),
|
||||
.choiceWidgetAnnotation select,
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
|
||||
background-image: var(--annotation-unfocused-field-background);
|
||||
border: 2px solid var(--input-unfocused-border-color);
|
||||
box-sizing: border-box;
|
||||
font: calc(9px * var(--total-scale-factor)) sans-serif;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation :is(input, textarea):required,
|
||||
.choiceWidgetAnnotation select:required,
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) input:required {
|
||||
outline: 1.5px solid red;
|
||||
}
|
||||
|
||||
.choiceWidgetAnnotation select option {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.radioButton input {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation :is(input, textarea)[disabled],
|
||||
.choiceWidgetAnnotation select[disabled],
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) input[disabled] {
|
||||
background: none;
|
||||
border: 2px solid var(--input-disabled-border-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation :is(input, textarea):hover,
|
||||
.choiceWidgetAnnotation select:hover,
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) input:hover {
|
||||
border: 2px solid var(--input-hover-border-color);
|
||||
}
|
||||
.textWidgetAnnotation :is(input, textarea):hover,
|
||||
.choiceWidgetAnnotation select:hover,
|
||||
.buttonWidgetAnnotation.checkBox input:hover {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation :is(input, textarea):focus,
|
||||
.choiceWidgetAnnotation select:focus {
|
||||
background: none;
|
||||
border: 2px solid var(--input-focus-border-color);
|
||||
border-radius: 2px;
|
||||
outline: var(--input-focus-outline);
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) :focus {
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.checkBox :focus {
|
||||
border: 2px solid var(--input-focus-border-color);
|
||||
border-radius: 2px;
|
||||
outline: var(--input-focus-outline);
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.radioButton :focus {
|
||||
border: 2px solid var(--input-focus-border-color);
|
||||
outline: var(--input-focus-outline);
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.checkBox input:checked::before,
|
||||
.buttonWidgetAnnotation.checkBox input:checked::after,
|
||||
.buttonWidgetAnnotation.radioButton input:checked::before {
|
||||
background-color: CanvasText;
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.checkBox input:checked::before,
|
||||
.buttonWidgetAnnotation.checkBox input:checked::after {
|
||||
height: 80%;
|
||||
left: 45%;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.checkBox input:checked::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.checkBox input:checked::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation.radioButton input:checked::before {
|
||||
border-radius: 50%;
|
||||
height: 50%;
|
||||
left: 25%;
|
||||
top: 25%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation input.comb {
|
||||
font-family: monospace;
|
||||
padding-left: 2px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.textWidgetAnnotation input.comb:focus {
|
||||
/*
|
||||
* Letter spacing is placed on the right side of each character. Hence, the
|
||||
* letter spacing of the last character may be placed outside the visible
|
||||
* area, causing horizontal scrolling. We avoid this by extending the width
|
||||
* when the element has focus and revert this when it loses focus.
|
||||
*/
|
||||
width: 103%;
|
||||
}
|
||||
|
||||
.buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.fileAttachmentAnnotation .popupTriggerArea {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupAnnotation {
|
||||
position: absolute;
|
||||
font-size: calc(9px * var(--total-scale-factor));
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
max-width: 45%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.popup {
|
||||
background-color: rgb(255 255 153);
|
||||
color: black;
|
||||
box-shadow: 0 calc(2px * var(--total-scale-factor))
|
||||
calc(5px * var(--total-scale-factor)) rgb(136 136 136);
|
||||
border-radius: calc(2px * var(--total-scale-factor));
|
||||
outline: 1.5px solid rgb(255 255 74);
|
||||
padding: calc(6px * var(--total-scale-factor));
|
||||
cursor: pointer;
|
||||
font: message-box;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
pointer-events: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.popupAnnotation.focused .popup {
|
||||
outline-width: 3px;
|
||||
}
|
||||
|
||||
.popup * {
|
||||
font-size: calc(9px * var(--total-scale-factor));
|
||||
}
|
||||
|
||||
.popup > .header {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.popup > .header > .title {
|
||||
display: inline;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.popup > .header .popupDate {
|
||||
display: inline-block;
|
||||
margin-left: calc(5px * var(--total-scale-factor));
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.popupContent {
|
||||
border-top: 1px solid rgb(51 51 51);
|
||||
margin-top: calc(2px * var(--total-scale-factor));
|
||||
padding-top: calc(2px * var(--total-scale-factor));
|
||||
}
|
||||
|
||||
.richText > * {
|
||||
white-space: pre-wrap;
|
||||
font-size: calc(9px * var(--total-scale-factor));
|
||||
}
|
||||
|
||||
.popupTriggerArea {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
backdrop-filter: var(--hcm-highlight-filter);
|
||||
}
|
||||
}
|
||||
|
||||
section svg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.annotationTextContent {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
svg.quadrilateralsContainer {
|
||||
contain: strict;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user