Files
silo-server/internal/sections/defaults_test.go
T
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the PR #124 review and hardening pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00

504 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sections
import (
"encoding/json"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) {
sections := DefaultHomeSections(nil)
// 1 continue-watching + 3 recipe-rich defaults (hidden_gems, trending, seasonal-auto-cycle)
if len(sections) != 4 {
t.Fatalf("expected 4 default home sections, got %d", len(sections))
}
if sections[0].SectionType != SectionContinueWatching {
t.Fatalf("expected continue watching, got %s", sections[0].SectionType)
}
assertContinueType(t, sections[0].Config, ContinueTypeWatching)
}
func TestDefaultHomeSectionsWithLibraries(t *testing.T) {
libraries := []*models.MediaFolder{
{ID: 7, Name: "Movies", Type: "movies", SortOrder: 1},
{ID: 9, Name: "Shows", Type: "series", SortOrder: 2},
}
got := DefaultHomeSections(libraries)
// 1 continue-watching + 2 libraries × 2 types + 3 recipe-rich defaults
if len(got) != 8 {
t.Fatalf("expected 8 default home sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
libraryID int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-home-recently_added-library-7", sectionType: SectionRecentlyAdded, title: "Recently Added in Movies", position: 1, libraryID: 7},
{index: 2, id: "default-home-recently_released-library-7", sectionType: SectionRecentlyReleased, title: "Recently Released in Movies", position: 2, libraryID: 7},
{index: 3, id: "default-home-recently_added-library-9", sectionType: SectionRecentlyAdded, title: "Recently Added in Shows", position: 3, libraryID: 9},
{index: 4, id: "default-home-recently_released_episodes-library-9", sectionType: SectionCustomFilter, title: "Recently Released Episodes in Shows", position: 4, libraryID: 9},
}
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 tt.libraryID == 0 {
assertContinueType(t, section.Config, ContinueTypeWatching)
continue
}
libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(section.Config)
if !ok {
t.Fatalf("section %d expected generated home config", tt.index)
}
if libraryID != tt.libraryID {
t.Fatalf("section %d config library id = %d, want %d", tt.index, libraryID, tt.libraryID)
}
}
assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{
LibraryIDs: []int{9},
MediaScope: "episode",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "release_date", Order: "desc"},
})
}
func TestDefaultHomeSectionsWithAudiobookLibrary(t *testing.T) {
libraries := []*models.MediaFolder{
{ID: 7, Name: "Movies", Type: "movies", SortOrder: 1},
{ID: 10, Name: "Books", Type: "audiobooks", SortOrder: 2},
}
got := DefaultHomeSections(libraries)
if len(got) != 9 {
t.Fatalf("expected 9 default home sections, got %d", len(got))
}
tests := []struct {
index int
id string
title string
position int
continueType ContinueType
}{
{index: 0, id: "default-continue-watching", title: "Continue Watching", position: 0, continueType: ContinueTypeWatching},
{index: 1, id: "default-continue-listening", title: "Continue Listening", position: 1, continueType: ContinueTypeListening},
}
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.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)
}
assertContinueType(t, section.Config, tt.continueType)
}
if got[2].Position != 2 || got[2].Title != "Recently Added in Movies" {
t.Fatalf("first generated library row = position %d title %q", got[2].Position, got[2].Title)
}
}
func TestDefaultLibrarySectionsForTypeMovies(t *testing.T) {
libraryID := 42
got := DefaultLibrarySectionsForType(&libraryID, "movies")
if len(got) != 6 {
t.Fatalf("expected 6 movie default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-recently-added-movies", sectionType: SectionRecentlyAdded, title: "Recently Added Movies", position: 1},
{index: 2, id: "default-recently-released-movies", sectionType: SectionRecentlyReleased, title: "Recently Released Movies", position: 2},
{index: 3, id: "default-top-rated-movies", sectionType: SectionCustomFilter, title: "Top Rated Movies", position: 3},
{index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4},
{index: 5, id: "default-random-movies", sectionType: SectionRandom, title: "Random Picks", position: 5},
}
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)
}
}
assertContinueType(t, got[0].Config, ContinueTypeWatching)
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
MediaScope: "movie",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{
MediaScope: "movie",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "rating_imdb", Order: "desc"},
})
assertEmptyJSON(t, got[4].Config)
assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{
MediaScope: "movie",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) {
libraryID := 17
got := DefaultLibrarySectionsForType(&libraryID, "series")
if len(got) != 6 {
t.Fatalf("expected 6 series default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-recently-added-tv", sectionType: SectionRecentlyAdded, title: "Recently Added TV", position: 1},
{index: 2, id: "default-recently-released-episodes", sectionType: SectionCustomFilter, title: "Recently Released Episodes", position: 2},
{index: 3, id: "default-top-rated-tv", sectionType: SectionCustomFilter, title: "Top Rated TV", position: 3},
{index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4},
{index: 5, id: "default-random-tv", sectionType: SectionRandom, title: "Random Picks", position: 5},
}
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)
}
}
assertContinueType(t, got[0].Config, ContinueTypeWatching)
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
MediaScope: "series",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
MediaScope: "episode",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "release_date", Order: "desc"},
})
assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{
MediaScope: "series",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "rating_imdb", Order: "desc"},
})
assertEmptyJSON(t, got[4].Config)
assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{
MediaScope: "series",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) {
libraryID := 10
got := DefaultLibrarySectionsForType(&libraryID, "audiobooks")
if len(got) != 6 {
t.Fatalf("expected 6 audiobook default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
featured bool
}{
{index: 0, id: "default-continue-listening", sectionType: SectionContinueWatching, title: "Continue Listening", position: 0, featured: true},
{index: 1, id: "default-next-in-series", sectionType: SectionNextInSeries, title: "Next in Your Series", position: 1},
{index: 2, id: "default-recently-added-audiobooks", sectionType: SectionRecentlyAdded, title: "Recently Added Audiobooks", position: 2},
{index: 3, id: "default-recently-released-audiobooks", sectionType: SectionRecentlyReleased, title: "Recently Released Audiobooks", position: 3},
{index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4},
{index: 5, id: "default-random-audiobooks", sectionType: SectionRandom, title: "Random Picks", position: 5},
}
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 != tt.featured {
t.Fatalf("section %d featured = %v, want %v", tt.index, section.Featured, tt.featured)
}
}
assertContinueType(t, got[0].Config, ContinueTypeListening)
assertEmptyJSON(t, got[1].Config)
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertEmptyJSON(t, got[4].Config)
assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
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)
}
}
assertContinueType(t, got[0].Config, ContinueTypeReading)
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")
if len(got) != 3 {
t.Fatalf("expected 3 mixed default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-recently-added", sectionType: SectionRecentlyAdded, title: "Recently Added", position: 1},
{index: 2, id: "default-recently-released", sectionType: SectionRecentlyReleased, title: "Recently Released", position: 2},
}
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)
}
}
assertContinueType(t, got[0].Config, ContinueTypeWatching)
}
func assertQueryDefinition(t *testing.T, raw json.RawMessage, want catalog.QueryDefinition) {
t.Helper()
got, err := ParseQueryDefinition(raw)
if err != nil {
t.Fatalf("ParseQueryDefinition() error = %v", err)
}
got = got.Normalize()
want = want.Normalize()
if got.MediaScope != want.MediaScope {
t.Fatalf("media_scope = %q, want %q", got.MediaScope, want.MediaScope)
}
if got.Match != want.Match {
t.Fatalf("match = %q, want %q", got.Match, want.Match)
}
if got.Sort != want.Sort {
t.Fatalf("sort = %+v, want %+v", got.Sort, want.Sort)
}
if len(got.Groups) != len(want.Groups) {
t.Fatalf("groups len = %d, want %d", len(got.Groups), len(want.Groups))
}
for i := range want.Groups {
if got.Groups[i].Match != want.Groups[i].Match {
t.Fatalf("groups[%d].match = %q, want %q", i, got.Groups[i].Match, want.Groups[i].Match)
}
if len(got.Groups[i].Rules) != len(want.Groups[i].Rules) {
t.Fatalf("groups[%d].rules len = %d, want %d", i, len(got.Groups[i].Rules), len(want.Groups[i].Rules))
}
for j := range want.Groups[i].Rules {
if got.Groups[i].Rules[j] != want.Groups[i].Rules[j] {
t.Fatalf("groups[%d].rules[%d] = %+v, want %+v", i, j, got.Groups[i].Rules[j], want.Groups[i].Rules[j])
}
}
}
}
func assertEmptyJSON(t *testing.T, raw json.RawMessage) {
t.Helper()
if string(raw) != "{}" {
t.Fatalf("config = %s, want {}", string(raw))
}
}
func assertContinueType(t *testing.T, raw json.RawMessage, want ContinueType) {
t.Helper()
got, err := ParseContinueType(raw)
if err != nil {
t.Fatalf("ParseContinueType(%s): %v", string(raw), err)
}
if got != want {
t.Fatalf("continue_type = %q, want %q (config %s)", got, want, string(raw))
}
}
func TestHomeDefaultsIncludeRecipeRichSet(t *testing.T) {
defs := DefaultHomeSections(nil)
want := []SectionType{
SectionContinueWatching,
SectionHiddenGems,
SectionTrendingOnServer,
SectionSeasonalThemed,
}
for _, w := range want {
found := false
for _, d := range defs {
if d.SectionType == w {
found = true
break
}
}
if !found {
t.Errorf("default home sections missing %s", w)
}
}
}