Files
silo-server/internal/catalog/catalog_resolver.go
T
c4cbcddeae feat(manga): manga library type — series grouping, reading loop, AniList/MangaDex metadata + status badge (#138)
* docs: design spec for manga library type (host sub-project)

Forks the ebooks library type into a 'manga' type: series detected from the
folder tree as a first-class type='manga' item, .cbz/.cbr chapters stay
readable ebook items linked via a new manga_chapters table, browse shows series
cards, enrichment targets the series item at content level 'manga'. Hands off to
a follow-on plugin spec for the manga metadata source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for manga library type (host)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): manga filename index/volume parser

* feat(scanner): manga series-name-from-folder detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): align manga DB/scanner tasks to scanner pure-planner pattern (no test-DB)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(scanner): manga parser corpus regression

Add TestParseMangaIndexCorpus — 36 real-world scanlation filenames
covering bare chapter, decimal chapter, v/vol-prefix volume, and
c/ch-prefix chapter patterns; asserts <5% miss rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(db): manga_chapters link table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): manga_chapters repository + pure chapter-write mapping

Adds mangaChapterWrite (pure, unit-tested), upsertMangaChapter, and
listMangaChapters following the ebook/audiobook thin-SQL pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): recognize manga library type

Add isMangaLibraryType helper (unexported, matching the style of
isEbookLibraryType / isAudiobookLibraryType) with a corresponding
TestIsMangaLibraryType unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): manga library content level

Map library type "manga" to content level ["manga"] in
metadataContentLevelsForLibraryType so that seedDefaultChain seeds a
manga-level metadata provider chain when a manga library is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): route manga libraries to a manga scan path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): group manga chapters under a manga series item

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): give manga series item a library membership so it browses

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): browse manga libraries as series

Accept "manga" as a valid media_scope so a manga library browses only its
type='manga' series items; the per-chapter type='ebook' items are naturally
excluded because MediaScopeItemTypes("manga") expands to {"manga"}. Add the
manga default library sections (scoped to media_scope='manga') so the library
feed shows series cards. Refresh the two media_scope validation error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): manga series detail lists chapters

For a type='manga' item, attach its chapters to the detail response via a new
MangaDetailExtension. fetchMangaChapters joins manga_chapters to media_items on
the chapter content ID, scopes to the series, and orders by chapter_index
(NULLS LAST) then sort_title — matching the scanner's chapter ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga detail types + library browse scoping

Add MangaChapter/MangaDetailExtension TS types mirroring the host
catalog structs, wire manga? onto ItemDetail, and admit "manga" as a
QueryDefinition.media_scope. Scope manga libraries to media_scope=manga
in browse (host expands it to type=manga series items) while reusing the
ebook sort universe via getLibrarySortRelevanceScope. Add isMangaLibraryType.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga series detail with volume-grouped chapter list

Add MangaContent detail view: a DetailHero series header plus a chapter
list grouped by volume. groupMangaChapters (pure, unit-tested) buckets
chapters by their volume token, orders chapters within a group by
chapter_index (nulls last) and orders groups by their minimum index;
loose (volume-less) chapters collapse into a trailing "Chapters" group.
Each chapter links to the existing ebook reader by content_id alone
(file_id is optional — the reader resolves the file server-side), reusing
buildMediaPlayHref. Admit "manga" into ItemDetail.type and wire the
detail switch. Continue-reading is deferred (needs per-chapter progress
fan-out / a last-read timestamp not in the current payload).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): handle manga in playable-type + collection filter-scope unions

Adding "manga" to the shared ItemDetail["type"] and
QueryDefinition["media_scope"] unions leaked into consumers with narrower
local types, breaking the production tsc build. Fixes:

- mediaNavigation: admit "manga" into PlayableMediaType. Manga series are
  not directly playable (you open the detail page and read a chapter,
  itself an ebook item), so buildMediaPlayHref falls through to the item
  href for them, like series/season.
- FilterRuleEditor: add "manga" to FilterRuleMediaScope and relabel
  "watched" -> "Read" for manga as well as ebook (manga is read).
- CollectionGuidedRulesEditor: add "manga" to GuidedFormState.mediaScope,
  a "Manga" media-type option, ebook-like "Read Status" labels, and map
  manga -> ebook sort-relevance scope (manga has no dedicated sort scope).
- CatalogFilterBar (cascading leak surfaced after the above): add a
  "Manga" scope option and map manga -> ebook sort-relevance scope in both
  scope handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): offer manga as a library type in the create dialog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): strip scene-release junk from manga series names

Add cleanMangaSeriesName which repeatedly strips trailing parenthetical
groups (year, year-range, Digital, release-group tags) then trims any
dangling dash, so folder names like "404 Demons (Digital) (Oak)" resolve
to "404 Demons". Wire it into mangaSeriesFromPath so both the series
title and the mangaSeriesGroupKey identity key use the cleaned value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): flat volume/chapter manga list; nest only multi-chapter volumes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): parse manga index after stripping series-name prefix

Numbers inside a series title (e.g. "404 Demons", "365 Days to the
Wedding") were wrongly grabbed as the chapter number because
parseMangaIndex matched the first bare number in the full filename.
mangaIndexForFile now strips the series-name prefix before delegating
to parseMangaIndex, so only the number that follows the title is used.
reconcileMangaFile in manga_scan.go is updated to call mangaIndexForFile
instead of parseMangaIndex directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): stop missing-file reconcile from deleting manga series items

Manga series items are file-less virtual parents; the shared
ReconcileFolderMembership swept them every scan because they have no
media_file. Exclude type='manga' from file-presence membership reconciliation,
and add a manga-scan step that deletes only series with zero remaining chapters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ebooks): exclude manga chapters from individual ebook enrichment

Manga chapters are type='ebook' parts of a series; the ebook enrichment sweep
was searching each one against book sources (Gutenberg/Anna's/etc.) and failing
in a pointless storm. Exclude items with a manga_chapters link; series-level
enrichment is handled separately.

* docs: design spec for manga metadata plugin + series enrichment (sub-project 2)

New silo-plugin-manga-metadata (AniList, high-confidence matching) + a host
MangaEnricher for type='manga' series; default-enabled metadata source for manga
libraries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for manga metadata plugin + series enrichment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): manga_enrichment_state table

Mirrors ebook_enrichment_state: dedicated failure counter for the manga
enrichment sweep so it does not contend with media_items.refresh_failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(manga): series enricher (claims type='manga', resolves manga chain)

* feat(manga): sync_manga_metadata task + enricher wiring

* feat(catalog): expose manga chapter/volume counts in browse

Add manga_chapter_count and manga_volume_count to browse cards so the
frontend can render a Vols N / Ch N chip on manga series. The counts come
from two index-backed correlated subqueries over manga_chapters in the
browse SELECT (mangaCountColumns), scanned positionally before added_at and
nilled out for non-manga rows. Threaded through models.MediaItem and exposed
on the itemListResponse JSON card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): scope manga home recent sections to type=manga series

A manga library mixes type='manga' series with type='ebook' chapters, so
the auto-generated home 'Recently Added/Released in <Library>' rows surfaced
the junk chapter filenames. Add GeneratedHomeLibraryRecentConfigScoped which
emits the modern QueryDefinition shape (library_ids + media_scope) so a manga
library's generated home rows filter to type='manga' only. Other library
types are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): exclude manga chapters from browse/section/search surfaces

Manga CHAPTER items (type='ebook' rows linked into a type='manga' series
via manga_chapters) were leaking into catalog browse, section resolution,
and search as standalone items showing junk filenames. They are internal
sub-units of the series and only the series should appear.

There is no single shared item-listing chokepoint: browse, the query/preview
executor, and search each build their own WHERE. Add a shared, index-backed
anti-join predicate (manga_chapters.chapter_content_id is the PK) via
mangaChapterExclusionWhere and wire it into all three builders. By-id fetch
paths that legitimately resolve chapters (ebook reader, continue-reading,
series detail chapter list) use separate queries and are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): use #NN as the manga volume for Vol.YYYY #NN releases

mangaVolYearIssue early-return was returning the year token (e.g. "Vol.2003")
as the volume label, which the frontend couldn't prettify to "Volume N".
Now returns "v<issue>" (e.g. "v04") so the existing frontend regex ^v?(\d+)$
renders it as "Volume 4" correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): manga count chip on posters

Add an optional manga_chapter_count / manga_volume_count to the browse
item type and render a top-right "Vols N" / "Ch N" chip on ItemCard,
strictly gated on type==='manga'. The label prefers "Vols" when the
volume count dominates, "Ch" otherwise; the chip is hidden when the
chapter count is missing or non-positive. No other card type renders it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): manga reader back returns to series (no loop)

The ebook reader's back action defaulted to the chapter's own item
detail (/item/<chapter>), whose back returned to the reader — an
infinite loop for manga chapters. The reader now honors an explicit
backTo search param when present, navigating there instead. Absent for
normal ebooks, so their back behavior is unchanged. Only manga chapter
rows pass backTo, keeping the fix manga-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga chapter row actions (read/mark-read/download)

Each manga chapter/volume row now offers Read (the existing reader link,
now carrying a backTo to the series), Mark-read (the shared watched-state
mutation per chapter content_id), and Download (lazily fetches the
chapter's file versions on demand and opens the shared
DownloadVersionPicker, gated on user.download_allowed). The
volume-unit / loose-chapter / section structure from buildMangaList is
unchanged. Scoped to MangaContent only; EbookContent is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): validate reader backTo param is a safe in-app relative path

Prevents open-redirect / javascript:-URI XSS from a crafted ?backTo= URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): include per-chapter read state in manga detail

Manga chapters are ebook items, so a chapter is "read" when the viewer's
ebook_reader_progress row crosses the finished threshold. fetchMangaChapters
now LEFT JOINs that table scoped to the AccessFilter's user_id/profile_id and
exposes a per-chapter Read bool on MangaChapter, threaded through
buildMangaExtension. The detail payload previously carried no read state, so
the row toggle always started unread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga rows reflect read state on load

MangaChapter now carries an optional read flag from the detail payload, and
MangaRow seeds its mark-read toggle from chapter.read instead of always
starting unread. The optimistic toggle + shared watched mutation are
unchanged; only the initial value is seeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): exclude manga chapters from recently-added/released/random + other library-listing sections

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sections): manga recently-added/released cards show the latest volume's cover

* fix(manga): keep enrichment honest about no-match vs enriched, batch 50->200

- sweep stats now separate enriched / no_match / failed: a stamped no-match
  was counted (and logged) as an enrichment, which masked a collapse of the
  real match rate during the backfill
- batch size 50 -> 200 (SILO_MANGA_ENRICH_BATCH overrides): with the plugin
  serving GetMetadata from its search cache an item costs one rate-limited
  AniList request, so a sweep still fits the 5-minute task interval

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

* fix(manga): size enrich batch to the 5-minute interval at AniList's real budget

140 items x ~2.1s/request fits the interval; an overlong sweep makes the task
manager drop the next trigger and the effective rate falls below the AniList
budget.

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

* fix(catalog): manga count chip data missing from library browse

manga_chapter_count/manga_volume_count were only added to BrowseRepository,
but /library/{id}?tab=library flows through previewQuerySource ->
QueryExecutor.PreviewPage, which selects qualifiedListItemColumns and scans
with scanItems - so manga cards never carried the counts and the Vols/Ch
poster chip stayed hidden.

Append mangaCountColumns to the preview-page SELECT and scan them via a new
scanItemsWithMangaCounts (nil for non-manga rows, mirroring scanBrowseItems).
Extract listItemScanDests so the three scan variants share one destination
list instead of duplicating the 48-column scan.

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

* feat(web): manga chip reads 'X Volumes · X Chapters', menu verbs say Read

- chip: show distinct-volume and loose-chapter counts side by side instead
  of the single 'Vols N'/'Ch N' heuristic; mangaCountColumns now counts
  DISTINCT volume tokens (rows sharing a volume are one volume) and only
  un-volumed rows as chapters
- watched-state labels: type='manga' fell through to the video default, so
  the card dot menu and detail page said 'Mark Watched' - manga now uses
  the ebook reading verbs (Mark Read / Mark Unread, 'Marked as read' toast)
- format MangaContent.test.tsx (pre-existing prettier miss)

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

* feat(manga): backdrop enrichment - banner hero art + backdrop-only backfill

- cache remote backdrops like posters (cacheRemoteImages generalizes the
  poster-only path; failures keep the provider URL, which still renders)
- claim arm for enriched items missing a backdrop: fetched by stored
  provider ID (search skipped - no rate spend, no re-match risk) and only
  the backdrop is written; stamping after the attempt keeps banner-less
  series from being re-claimed every sweep
- backfill = one-time SQL clearing last_refreshed for poster-set/
  backdrop-empty manga

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

* feat(manga): reading-loop UX - continue CTA, next chapter, series-aware cards, file details

Fixes the four high-priority findings from the manga UX review plus a
file-inspector request:

- H1: series hero gets a Continue / Start Reading / Read Again CTA
  targeting the first unread chapter (firstUnreadChapter over the ordered
  list), plus an overflow menu (View Details, admin Refresh Metadata)
- H2: the reader resolves its owning manga series (chapter detail now
  carries series_id/series_title) and offers next-chapter navigation: a
  header next button and an end-of-book floating CTA at >=99.5% progress;
  back defaults to the series even without a backTo param
- H3: chapter rows show a persistent read check + muted title, and the
  mark-read mutation carries series_id so the series detail cache
  invalidates (read states no longer revert on revisit)
- H4: continue-reading cards for manga chapters present the series:
  sections payload resolves chapter->series linkage, the card heading/image
  link to the series, and meta lines launch the reader
- View Details: manga series menus (card dot menu + detail overflow) open
  a file inspector showing folder paths and per-chapter file names/sizes
  via GET /catalog/items/{id}/manga-files; paths are stripped for viewers
  without file-path visibility (item-versions policy)

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

* feat(manga): UX mediums - richer detail page, smarter list, manga sort scope

Second batch from the manga UX review (M1-M7):

- M1: multi-chapter volume sections are collapsible (fully read sections
  start collapsed) with sticky headers, and long series get a 'Jump to
  <next unread>' anchor above the list
- M2: the series hero shows the author line (HeroCrewLine learns Author
  credits with person links; DetailHero now renders crewLine and genre
  chips independently) and Volumes/Chapters badges
- M3: browse-card count chip abbreviates to '12 Vol - 3 Ch' so it fits
  narrow cards without occluding covers
- M4: manga gets its own sort scope: Duration/Bitrate (meaningless for
  file-less series rows) disappear, reading labels (Date Read / Reads)
  apply, Author stays
- M5: global search labels manga results 'Manga' instead of the raw type
- M6: chapters carry the viewer's reading fraction; part-read rows show an
  inline progress bar + percent
- M7: chapter rows show the extracted cover thumbnail (presigned
  poster_url on the chapters payload) instead of a generic icon

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

* fix(manga): UX lows - volume token dedupe, comic reader chrome, empty-state hint

- buildMangaList buckets volumes by canonical numeric token so mixed
  release naming (v01 + 1) yields one Volume 1 instead of duplicates
- cbz/cbr readers start with the side panel closed and hide prose-only
  chrome (reading ruler, TTS, typography/font controls, hyphenation,
  writing mode) while keeping comic-relevant settings (theme, brightness,
  margin, right-to-left, spread, flow)
- manga empty state mentions chapters appear after the library scan

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

* feat(manga): publication status badge via new SDK status field

- vendor the unpublished plugin SDK (adds MetadataItem.status) under
  internal/compat/ with a relative go.mod replace, following the
  zishang520-webtransport-go convention; swap to the published module
  before the upstream PR
- map plugin status into MetadataResult.ShowStatus, persist it during
  manga enrichment, and show it as the hero status badge (show_status was
  already on the detail payload and MetadataBadges)

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

* feat(manga): generalize backdrop pass to secondary fields (backdrop + status)

The backdrop-only claim arm becomes a secondary-fields pass: enriched items
missing a backdrop and/or publication status are claimed, fetched by stored
provider ID, and only the missing secondary fields are written. Lets the
new status field backfill across the already-enriched library instead of
applying only to future enrichments.

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

* fix(metadata): merge ShowStatus through MergeMetadata/MergeGlobalMetadata

The new MetadataResult.ShowStatus never reached the accumulated result the
manga enricher persists from - the field-by-field merges didn't know it, so
the status backfill pass obtained nothing. Regression-tested on both paths.

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

* fix(manga): keep scanner identity IDs out of the metadata flow

filterMangaProviderIDs passed the scanner's manga_series identity row
through, so the search-skip-when-already-matched guard saw provider IDs on
every item and never searched: unmatched items went straight to a by-ID
fetch with no usable ID and were stamped as terminal no-match without a
single provider request (and the MangaDex fallback was never consulted).

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

* chore: gitignore docker-compose.override.yml (local deployment override)

The override unpublishes the bundled redis/postgres host ports
(ports: !override []). It is a per-deployment, local-only file: ignoring it
keeps a rebase from main and git clean -fd from disturbing it, and keeps it
out of any PR. Its accidental absence once exposed Redis to the internet.

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

* fix(manga): code-review fixes — no-match guard, sort comparator, volume-count consistency

- enrichWithProviders: set accumulator.HasMetadata after a provider result
  merges (MergeMetadata doesn't propagate it). Without this, a confident
  match carrying only genres/authors/status/year but no cover and no overview
  failed the no-match check and was discarded + terminally stamped.
- byChapterIndex: both un-indexed chapters yield POSITIVE_INFINITY, so the
  subtraction was Infinity-Infinity=NaN (Array.sort treats NaN as 0, leaving
  order undefined). Compare explicitly for a stable order.
- MangaContent volume/chapter badges: derive counts from the rendered
  buildMangaList entries (which canonicalize v01 ≡ 1) instead of raw distinct
  volume tokens, so the badge can no longer say '2 Volumes' over one row.

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

* docs(manga): clarify the enrichment claim's secondary arm is admin-reset-only

The secondary arm (poster present, backdrop/status missing) requires
last_refreshed IS NULL, so it is only reachable when an operator resets
last_refreshed to backfill a newly-added field — not an automatic periodic
re-check (which would re-fetch banner-less series every sweep). Documents the
intent so it does not read as dead code.

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

* fix(manga): collapse continue-reading chapters per series; batch provider-id lookup

- Continue Reading now collapses multiple in-progress chapters of the same
  manga into one card (most recently read kept), mirroring the episode→series
  collapse. The reading section resolves chapter→series linkage into itemMeta
  (applyMangaChapterSeriesMeta) and runs the shared
  collapseContinueWatchingSeriesCandidates, which the reading path previously
  skipped.
- claimBatch resolves provider IDs for the whole batch in one query via the
  new ProviderIDRepository.GetByContentIDs (content_id = ANY), replacing the
  per-item GetByContentID N+1.

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

* feat(web): manga publication-status chip on browse cards + more legible chips

- Color-coded publication status pill (Ongoing/Completed/Hiatus/Cancelled/
  Upcoming) in the manga card's top-left corner, mirroring the vol/chapter
  count chip top-right. Strictly manga-gated; show_status was already on the
  browse payload.
- New .glass-chip (78% surface vs glass-subtle's 40%) for the manga count +
  status pills so the labels stay legible over busy cover art.

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

* build(manga): depend on published silo-plugin-sdk v0.7.0

Replace the vendored internal/compat/silo-plugin-sdk copy with a normal
dependency on the published SDK module at v0.7.0, which adds
MetadataItem.status (publication/airing status) consumed by the manga
status badge at internal/metadata/plugin_provider.go.

- go.mod: pin v0.7.0, drop the local-path replace directive
- remove the vendored internal/compat/silo-plugin-sdk tree
- Dockerfile: drop the vendored-SDK COPY
- strip the manga design docs/plans from docs/superpowers (internal)

Requires Silo-Server/silo-plugin-sdk#4 merged and tagged v0.7.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manga): exclude chapters from the matcher's unmatched-item lister

Manga chapters are type='ebook' items that stay status='pending' by
design - provider metadata lives on the type='manga' series item. The
scan-final RetryUnmatchedItemsByFolderAndPathPrefix listed all of them
and ran a rate-limited ebook-plugin search per chapter: 31,564 chapters
x ~1s = 8h46m appended to a 2-minute manga library scan (observed
live), every one a guaranteed no-match. Earlier runs never survived to
completion, so the library's last_scanned_at stayed NULL forever.

Add the same manga_chapters NOT EXISTS guard the ebook enricher's
claim query already uses. Verified live: the same library now scans in
27s with retried_items=0.

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

* fix(scanner): never probe-repair ebook/comic files (ebook+manga detail-page killer)

NeedsCriticalProbeRepair was always true for BaseType 'ebook' files (epub, pdf,
cbz, cbr — incl. manga chapters): buildEbookMediaFile leaves ProbeUpdatedAt nil
and they have no audio/video, so probeEnsurer.Ensure spawned ffprobe per file on
every detail/watch load and never converged (ffprobe errors on zip/rar, result
never persisted). Short-circuit probe-repair for ebook base type — they're read
directly and never use the transcode/playback probe pipeline.

SHARED fix: benefits both the ebooks and manga library types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf+fix(ebooks): parallelize detail extension + preserve finished read-state

- buildEbookExtension ran its 3 related-content queries (series, also-by-author,
  similar) sequentially; run them concurrently like buildAudiobookExtension so
  ebook detail latency is the slowest query, not their sum.
- PGEbookReaderProgressStore.Upsert did an unconditional SET progress=EXCLUDED;
  a routine autosave (e.g. reopening a finished book) could drop it below the
  0.9 finished threshold and silently un-mark it read (and clear the manga
  chapter checkmark, which rides on the same row). Guard: once finished,
  progress only moves on an explicit unread (row delete); below threshold it
  tracks freely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(manga): batch chapter presign, index volume counts, quiet scan log

- fetchMangaChapters presigned each chapter poster individually; a long-running
  series has hundreds of chapters. Batch them in one PresignImageURLs call, and
  add the missing rows.Err() check (was silently returning partial lists).
- The browse manga count chip's count(DISTINCT volume) subquery wasn't covered
  by manga_chapters_series (series_content_id, chapter_index); add
  idx_manga_chapters_series_volume (series_content_id, volume) so both count
  subqueries are index-only.
- Downgrade the per-chapter "manga scan: indexed" log from Info to Debug (one
  line per .cbz; the 500-file progress log already covers operator visibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manga): address PR #138 code-review findings

Folds PR #142 into the manga branch (already done via fast-forward) and
remediates the issues surfaced in the #138 code review.

Correctness:
- Preserve the scanner's manga_series identity anchor through enrichment.
  ReplaceByContentID's DELETE was unconditional, so the first successful
  enrichment wiped the manga_series provider-id row the scanner relies on
  for idempotency, causing duplicate series + metadata loss on the next
  scan. excludedProviderIDs now also means "not deleted", and the DELETE
  preserves those rows. (internal/catalog/provider_id_repo.go)
- Fall back to the series cover when the latest chapter has no poster.
  Poster columns default to '' (not NULL), so the manga series-card poster
  override blanked cards via a plain COALESCE; wrap operands in NULLIF.
  (internal/sections/fetcher.go)
- Keep backTo a real query param on reader links when libraryId is absent.
  It was string-concatenated with '&', producing a malformed URL on
  deep-links; route it through the query helper instead.
  (web/src/lib/mediaNavigation.ts, EbookReader.tsx, MangaContent.tsx)

Quality:
- Hide manga chapters from favorites/watchlist browse, matching the
  exclusion enforced on every other listing surface.
  (internal/catalog/favorites_browse.go)
- Centralize the manga chapter exclusion predicate into a single exported
  catalog.MangaChapterExclusionWhere, removing four duplicated copies.
  (catalog, sections, ebooks)
- Skip the two manga count subqueries on browse scopes that cannot contain
  manga (non-manga type filters), substituting NULL placeholders.
  (internal/catalog/browse.go)
- Normalize provider publication status (AniList/MangaDex/SDK variants)
  into a stable label set so show_status carries one manga value-domain.
  (internal/manga/enrichment.go)

Adds unit tests for the poster NULLIF contract, browse gating, and status
normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate go.sum after rebase onto main

Drops stale silo-plugin-sdk v0.6.0 and other leftover hashes from the
intermediate rebased states; go.mod is now on the published v0.7.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scanner): adapt manga scan to ebookFileShouldSkip 3-value signature

main changed ebookFileShouldSkip to also return the existing content ID;
the manga scan path only needs the unchanged flag, so discard the new
return. Resolves a silent semantic conflict from the rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:13:10 -04:00

2339 lines
70 KiB
Go

package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
var ErrInvalidCatalogRequest = errors.New("invalid catalog request")
var ErrCatalogSourceNotFound = errors.New("catalog source not found")
type CatalogResult struct {
Items []*models.MediaItem
Total int
HasMore bool
TotalExact bool
SnapshotAt time.Time // pagination fence timestamp
}
type CatalogFiltersResult struct {
Genres []string
Studios []string
Networks []string
Countries []string
OriginalLanguages []string
ContentRatings []string
Resolutions []string
AudioLanguages []string
SubtitleLanguages []string
// 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
}
type CatalogFilterOptions struct {
IncludeTechnical bool
}
// facetFetcher is the seam used by ListFiltersWithOptions to load each facet
// (genres, studios, networks, …). The production implementation queries the
// pgx pool; tests substitute a stub that records concurrent invocations.
type facetFetcher interface {
DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// 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 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
// alphabetical matches and hasMore=true when more would be available.
SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchDistinctScalarColumn is the scalar-column variant (e.g.
// content_rating, original_language).
SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchPeopleByKind is the typeahead equivalent of PeopleByKind.
SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchAudiobookSeries is the typeahead equivalent of AudiobookSeries.
SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
}
// pgxFacetFetcher is the production facetFetcher. It dispatches each method to
// the existing package-level helpers using the supplied pool.
type pgxFacetFetcher struct {
pool *pgxpool.Pool
}
func (f *pgxFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctArrayColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctScalarColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listResolutionsWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctJSONBLanguageWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listSubtitleLanguagesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctArrayColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctScalarColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope, prefix, limit)
}
// previewExecutor is the seam consumed by previewQuerySource so tests can
// substitute a stub that records call counts without touching the database.
// The production implementation is *QueryExecutor.
type previewExecutor interface {
PreviewPage(ctx context.Context, def QueryDefinition, access AccessFilter, limit int, offset int, includeTotal bool) ([]*models.MediaItem, int, bool, error)
}
type CatalogResolver struct {
browseRepo *BrowseRepository
itemRepo *ItemRepository
episodeRepo *EpisodeRepository
storeProvider userstore.UserStoreProvider
facets facetFetcher
// previewExecutorForScope, when non-nil, is used by previewQuerySource
// instead of the default queryExecutorForScope. Tests inject a stub here
// to observe how many times the executor is asked for a result page.
previewExecutorForScope func(scope string, snapshot *time.Time) previewExecutor
}
func NewCatalogResolver(browseRepo *BrowseRepository, itemRepo *ItemRepository) *CatalogResolver {
r := &CatalogResolver{
browseRepo: browseRepo,
itemRepo: itemRepo,
}
if browseRepo != nil {
r.facets = &pgxFacetFetcher{pool: browseRepo.pool}
}
return r
}
func (r *CatalogResolver) WithUserStoreProvider(provider userstore.UserStoreProvider) *CatalogResolver {
if r == nil {
return nil
}
r.storeProvider = provider
return r
}
func (r *CatalogResolver) WithEpisodeRepository(repo *EpisodeRepository) *CatalogResolver {
if r == nil {
return nil
}
r.episodeRepo = repo
return r
}
func (r *CatalogResolver) queryExecutorForScope(scope string, snapshot *time.Time) *QueryExecutor {
return &QueryExecutor{
Pool: r.itemRepo.pool,
Scope: scope,
BaseRelationSQL: catalogBaseRelationForScope(scope),
SnapshotAt: snapshot,
}
}
func (r *CatalogResolver) Resolve(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
if r == nil || r.browseRepo == nil || r.itemRepo == nil {
return nil, fmt.Errorf("catalog resolver requires browse and item repositories")
}
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
return r.resolveQuerySource(ctx, req, access)
case CatalogSourceSection:
if err := validateCatalogSectionRequest(req); err != nil {
return nil, err
}
return r.resolveSectionSource(ctx, req, access)
case CatalogSourceLibraryCollection:
if err := validateCatalogExactCollectionRequest(req); err != nil {
return nil, err
}
return r.resolveLibraryCollectionSource(ctx, req, access)
case CatalogSourceUserCollection:
if err := validateCatalogExactCollectionRequest(req); err != nil {
return nil, err
}
return r.resolveUserCollectionSource(ctx, req, access)
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
return r.resolvePersonalSource(ctx, req, access)
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
return r.resolvePersonSource(ctx, req, access)
default:
return nil, fmt.Errorf("%w: unsupported catalog source %q", ErrInvalidCatalogRequest, req.Source)
}
}
func (r *CatalogResolver) resolveQuerySource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
if strings.TrimSpace(req.SearchQuery) == "" {
return r.previewQuerySource(ctx, req, access)
}
if useDirectSearchPath(req) {
return r.resolveDirectSearchSource(ctx, req, access)
}
items, err := r.fetchAllSearchCandidates(ctx, req, access)
if err != nil {
return nil, err
}
if NormalizeQuerySort(req.Query.Sort).Field == "relevance" {
items = filterCatalogSearchItems(items, req.SearchQuery)
items = filterCatalogNamePrefix(items, req.NamePrefix)
items = filterCatalogItems(items, req.Query)
total := len(items)
paged := paginateCatalogItems(items, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
return r.resolveCandidateItemsWithQuery(ctx, req, access, items, true)
}
func (r *CatalogResolver) resolveDirectSearchSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
searchAccess, itemTypes, earlyEmpty := catalogSearchAccess(req, access)
if earlyEmpty {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
items, total, err := r.itemRepo.Search(ctx, req.SearchQuery, itemTypes, req.Limit, req.Offset, searchAccess)
if err != nil {
return nil, fmt.Errorf("searching catalog items: %w", err)
}
return &CatalogResult{
Items: items,
Total: total,
HasMore: total > req.Offset+len(items),
TotalExact: true,
}, nil
}
func (r *CatalogResolver) previewQuerySource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
snapshot := time.Now()
if req.SnapshotAt != nil {
snapshot = *req.SnapshotAt
}
var executor previewExecutor
if r.previewExecutorForScope != nil {
executor = r.previewExecutorForScope(req.Query.MediaScope, &snapshot)
} else {
executor = r.queryExecutorForScope(req.Query.MediaScope, &snapshot)
}
// Push NamePrefix into the SQL WHERE clause so the database can use
// idx_media_items_sort_key instead of returning every match for an
// in-memory filter pass.
access.NamePrefix = req.NamePrefix
items, total, hasMore, err := executor.PreviewPage(ctx, req.Query, access, req.Limit, req.Offset, !req.SkipTotal)
if err != nil {
return nil, err
}
return &CatalogResult{
Items: items,
Total: total,
HasMore: hasMore,
TotalExact: !req.SkipTotal,
SnapshotAt: snapshot,
}, nil
}
func (r *CatalogResolver) resolveSectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
section, err := r.loadCatalogSection(ctx, req)
if err != nil {
return nil, err
}
switch section.SectionType {
case "collection":
// User collection takes precedence when set.
if userCollID := strings.TrimSpace(section.UserCollectionID); userCollID != "" {
return r.resolveUserCollectionSource(ctx, CatalogRequest{
Source: CatalogSourceUserCollection,
CollectionID: userCollID,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}, access)
}
collectionID := strings.TrimSpace(section.CollectionID)
if collectionID == "" {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
return r.resolveLibraryCollectionSource(ctx, CatalogRequest{
Source: CatalogSourceLibraryCollection,
CollectionID: collectionID,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}, access)
case "favorites":
return r.resolvePersonalSource(ctx, CatalogRequest{
Source: CatalogSourceFavorites,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}, access)
case "watchlist":
return r.resolvePersonalSource(ctx, CatalogRequest{
Source: CatalogSourceWatchlist,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}, access)
case "recently_added":
return r.resolveSectionBrowseSource(ctx, req, access, section, "added_at", "desc")
case "recently_released":
return r.resolveSectionBrowseSource(ctx, req, access, section, "release_date", "desc")
case "random":
return r.resolveSectionBrowseSource(ctx, req, access, section, "random", "desc")
case "genre", "custom_filter":
def, err := parseCatalogSectionQueryDefinition(section.Config)
if err != nil {
return nil, fmt.Errorf("%w: parsing section query definition: %v", ErrInvalidCatalogRequest, err)
}
if section.Scope == "library" && section.LibraryID != nil {
def.LibraryIDs = []int{*section.LibraryID}
}
return r.resolveQuerySource(ctx, CatalogRequest{
Source: CatalogSourceQuery,
Query: def,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
}, stripCatalogUserScope(access))
default:
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
}
func (r *CatalogResolver) resolveSectionBrowseSource(ctx context.Context, req CatalogRequest, access AccessFilter, section catalogPageSection, sort, order string) (*CatalogResult, error) {
snapshot := time.Now()
if req.SnapshotAt != nil {
snapshot = *req.SnapshotAt
}
sectionFilters := parseCatalogSectionFilters(section.Config)
query := QueryDefinition{
MediaScope: sectionFilters.FilterType,
LibraryIDs: append([]int(nil), sectionFilters.LibraryIDs...),
Sort: QuerySort{
Field: sort,
Order: order,
},
}.Normalize()
if section.Scope == "library" && section.LibraryID != nil {
query.LibraryIDs = []int{*section.LibraryID}
}
browseReq := CatalogRequest{
Source: CatalogSourceQuery,
Query: query,
Limit: req.Limit,
Offset: req.Offset,
NamePrefix: req.NamePrefix,
SnapshotAt: req.SnapshotAt,
}
browseAccess := stripCatalogUserScope(access)
filters, earlyEmpty, err := catalogBrowseFilters(browseReq, browseAccess)
if err != nil {
return nil, err
}
if earlyEmpty {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
filters.Sort = sort
filters.Order = order
filters.Limit = req.Limit
filters.Offset = req.Offset
filters.SnapshotAt = &snapshot
result, err := r.browseRepo.BrowsePage(ctx, filters, !req.SkipTotal)
if err != nil {
return nil, fmt.Errorf("browsing section source: %w", err)
}
return &CatalogResult{
Items: result.Items,
Total: result.Total,
HasMore: result.HasMore,
TotalExact: !req.SkipTotal,
SnapshotAt: snapshot,
}, nil
}
func (r *CatalogResolver) resolveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || collection.Visibility != "visible" {
return nil, ErrCatalogSourceNotFound
}
if IsLiveQueryType(collection.CollectionType) {
return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection)
}
if catalogCollectionUsesLiveQuery(collection.QueryDefinition) {
return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection)
}
collectionItems, err := collectionRepo.ListItems(ctx, collection.ID)
if err != nil {
return nil, err
}
contentIDs := make([]string, 0, len(collectionItems))
for _, item := range collectionItems {
contentIDs = append(contentIDs, item.MediaItemID)
}
return r.resolveExactOrderedItems(ctx, contentIDs, req, access)
}
func (r *CatalogResolver) resolveLiveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter, collection *models.LibraryCollection) (*CatalogResult, error) {
def, err := parseCatalogCollectionQueryDefinition(collection.QueryDefinition)
if err != nil {
return nil, fmt.Errorf("%w: parsing library collection query_definition: %v", ErrInvalidCatalogRequest, err)
}
if len(collection.LibraryIDs) > 0 {
def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, collection.LibraryIDs)
} else if collection.LibraryID > 0 {
def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, []int{collection.LibraryID})
}
def = ApplySmartCollectionItemLimit(def)
return r.resolveQuerySource(ctx, CatalogRequest{
Source: CatalogSourceQuery,
Query: def,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
}, stripCatalogUserScope(access))
}
func (r *CatalogResolver) resolveUserCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
if strings.EqualFold(strings.TrimSpace(collection.CollectionType), "smart") || catalogCollectionUsesLiveQuery([]byte(collection.QueryDefinition)) {
def, err := parseCatalogCollectionQueryDefinition([]byte(collection.QueryDefinition))
if err != nil {
return nil, fmt.Errorf("%w: parsing user collection query_definition: %v", ErrInvalidCatalogRequest, err)
}
def = ApplySmartCollectionItemLimit(def)
return r.resolveQuerySource(ctx, CatalogRequest{
Source: CatalogSourceQuery,
Query: def,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
}, access)
}
items, err := store.ListCollectionItems(ctx, collection.ID)
if err != nil {
return nil, err
}
contentIDs := make([]string, 0, len(items))
for _, item := range items {
contentIDs = append(contentIDs, item.MediaItemID)
}
return r.resolveExactOrderedItems(ctx, contentIDs, req, access)
}
func (r *CatalogResolver) resolvePersonalSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
if historySourceCanUseOptimizedPageQuery(req) {
return r.resolveHistorySourcePage(ctx, req, access)
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
return r.resolveExactOrderedItems(ctx, contentIDs, req, access)
}
func (r *CatalogResolver) resolvePersonSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
filters, earlyEmpty, err := catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
if earlyEmpty {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
filters.PersonID = req.PersonID
items, err := r.fetchAllBrowseCandidatesByFilters(ctx, filters)
if err != nil {
return nil, err
}
return r.resolveCandidateItemsWithQuery(ctx, req, access, items, true)
}
func (r *CatalogResolver) resolveExactOrderedItems(ctx context.Context, contentIDs []string, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
items, err := r.fetchAccessibleItemsByID(ctx, contentIDs, req, access)
if err != nil {
return nil, err
}
items = filterCatalogSearchItems(items, req.SearchQuery)
items = filterCatalogNamePrefix(items, req.NamePrefix)
if req.UseSourceOrder {
items = filterCatalogItems(items, req.Query)
total := len(items)
paged := paginateCatalogItems(items, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
return r.resolveCandidateItemsWithQuery(ctx, req, access, items, false)
}
func (r *CatalogResolver) resolveCandidateItemsWithQuery(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
items []*models.MediaItem,
applyNamePrefixAfterQuery bool,
) (*CatalogResult, error) {
contentIDs := make([]string, 0, len(items))
for _, item := range items {
if item == nil || strings.TrimSpace(item.ContentID) == "" {
continue
}
contentIDs = append(contentIDs, item.ContentID)
}
if len(contentIDs) == 0 {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
queryAccess := access
queryAccess.AllowedContentIDs = contentIDs
executor := r.queryExecutorForScope(req.Query.MediaScope, nil)
if applyNamePrefixAfterQuery && strings.TrimSpace(req.NamePrefix) != "" {
sorted, _, err := executor.Preview(ctx, req.Query, queryAccess, len(contentIDs))
if err != nil {
return nil, err
}
sorted = filterCatalogNamePrefix(sorted, req.NamePrefix)
total := len(sorted)
paged := paginateCatalogItems(sorted, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
limit := req.Limit
if req.Offset > 0 {
limit = req.Offset + req.Limit
}
sorted, total, err := executor.Preview(ctx, req.Query, queryAccess, limit)
if err != nil {
return nil, err
}
paged := paginateCatalogItems(sorted, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
func (r *CatalogResolver) fetchAllBrowseCandidatesByFilters(ctx context.Context, filters BrowseFilters) ([]*models.MediaItem, error) {
allItems := make([]*models.MediaItem, 0)
page := filters
page.Limit = 100
page.Offset = 0
page.Sort = "created_at"
page.Order = "desc"
for {
result, err := r.browseRepo.Browse(ctx, page)
if err != nil {
return nil, fmt.Errorf("browsing catalog items: %w", err)
}
allItems = append(allItems, result.Items...)
if len(allItems) >= result.Total || len(result.Items) == 0 {
break
}
page.Offset += page.Limit
}
return allItems, nil
}
func (r *CatalogResolver) ListFilters(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogFiltersResult, error) {
return r.ListFiltersWithOptions(ctx, req, access, CatalogFilterOptions{IncludeTechnical: true})
}
func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req CatalogRequest, access AccessFilter, options CatalogFilterOptions) (*CatalogFiltersResult, error) {
if r == nil || r.browseRepo == nil {
return nil, fmt.Errorf("catalog resolver requires a browse repository")
}
var (
filters BrowseFilters
earlyEmpty bool
err error
)
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
if access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.PersonID = req.PersonID
default:
return nil, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if earlyEmpty {
return &CatalogFiltersResult{}, nil
}
if isEpisodeCatalogScope(req.Query.MediaScope) {
return r.listFiltersForSource(ctx, filters, options, episodeCatalogBaseRelation, req.Query.MediaScope)
}
return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope)
}
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
// is in alphabetical order; HasMore is true when the underlying result
// set held more rows than the supplied limit.
type CatalogFacetSearchResult struct {
Matches []string
HasMore bool
}
// catalogFacetSearchMaxLimit caps how many matches a single typeahead
// query can request. 50 is enough for a dropdown without overwhelming
// the page; clients that need more pages should narrow the prefix.
const catalogFacetSearchMaxLimit = 50
// SearchFacet powers /api/v1/catalog/filters/search. The request scope
// (libraries / access / media scope) mirrors ListFiltersWithOptions;
// the additional facet + prefix + limit arguments come from the URL.
// Empty or whitespace-only prefix returns no matches — the caller
// should fall back to the bulk /catalog/filters endpoint for the
// initial dropdown render.
func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, access AccessFilter, facet string, prefix string, limit int) (*CatalogFacetSearchResult, error) {
if r == nil || r.browseRepo == nil {
return nil, fmt.Errorf("catalog resolver requires a browse repository")
}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return &CatalogFacetSearchResult{Matches: []string{}}, nil
}
if limit <= 0 {
limit = 20
}
if limit > catalogFacetSearchMaxLimit {
limit = catalogFacetSearchMaxLimit
}
var (
filters BrowseFilters
earlyEmpty bool
err error
)
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
if access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.PersonID = req.PersonID
default:
return nil, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if earlyEmpty {
return &CatalogFacetSearchResult{Matches: []string{}}, nil
}
baseRelation := "media_items mi"
mediaScope := req.Query.MediaScope
if isEpisodeCatalogScope(req.Query.MediaScope) {
baseRelation = episodeCatalogBaseRelation
}
facets := r.facets
if facets == nil {
facets = &pgxFacetFetcher{pool: r.browseRepo.pool}
}
matches, hasMore, err := dispatchFacetSearch(ctx, facets, facet, filters, baseRelation, mediaScope, prefix, limit)
if err != nil {
return nil, err
}
return &CatalogFacetSearchResult{Matches: matches, HasMore: hasMore}, nil
}
// dispatchFacetSearch routes a facet name to the right facetFetcher
// method. The set of supported facet names mirrors what
// /api/v1/catalog/filters returns; anything else is rejected as an
// invalid request so callers learn about typos at the API boundary.
func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
switch facet {
case "genre":
return facets.SearchDistinctArrayColumn(ctx, "genres", filters, baseRelation, mediaScope, prefix, limit)
case "studio":
return facets.SearchDistinctArrayColumn(ctx, "studios", filters, baseRelation, mediaScope, prefix, limit)
case "network":
return facets.SearchDistinctArrayColumn(ctx, "networks", filters, baseRelation, mediaScope, prefix, limit)
case "country":
return facets.SearchDistinctArrayColumn(ctx, "countries", filters, baseRelation, mediaScope, prefix, limit)
case "original_language":
return facets.SearchDistinctScalarColumn(ctx, "original_language", filters, baseRelation, mediaScope, prefix, limit)
case "content_rating":
return facets.SearchDistinctScalarColumn(ctx, "content_rating", filters, baseRelation, mediaScope, prefix, limit)
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)
default:
return nil, false, fmt.Errorf("%w: unknown facet %q", ErrInvalidCatalogRequest, facet)
}
}
// catalogFacetConcurrency caps how many facet queries run in parallel for a
// single ListFiltersWithOptions invocation. With nine independent facet
// lookups and a default pgx pool of ten connections, capping at six leaves
// headroom for other concurrent work and avoids saturating the pool.
const catalogFacetConcurrency = 6
// catalogFacetMaxValues caps how many distinct values each facet query
// returns. Above ~1000 entries a typeahead UI is the only sensible way to
// present a dropdown — and the audiobook library on this server has
// >88k distinct authors / >92k narrators / >161k series, which produced
// an 11.7 MB /api/v1/catalog/filters response before this cap landed.
// The SearchableSelect dropdown stays responsive at 1000 client-side
// entries; beyond that, the filter response should switch to a server-
// side typeahead surface (out of scope for the cap-only fix).
const catalogFacetMaxValues = 1000
func (r *CatalogResolver) listFiltersForSource(
ctx context.Context,
filters BrowseFilters,
options CatalogFilterOptions,
baseRelation string,
mediaScope string,
) (*CatalogFiltersResult, error) {
facets := r.facets
if facets == nil {
facets = &pgxFacetFetcher{pool: r.browseRepo.pool}
}
var (
genres []string
studios []string
networks []string
countries []string
originalLanguages []string
contentRatings []string
resolutions []string
audioLanguages []string
subtitleLanguages []string
authors []string
narrators []string
series []string
)
eg, gctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, catalogFacetConcurrency)
withLimit := func(fn func() error) func() error {
return func() error {
select {
case sem <- struct{}{}:
case <-gctx.Done():
return gctx.Err()
}
defer func() { <-sem }()
return fn()
}
}
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "genres", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog genres: %w", err)
}
genres = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "studios", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog studios: %w", err)
}
studios = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "networks", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog networks: %w", err)
}
networks = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "countries", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog countries: %w", err)
}
countries = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctScalarColumn(gctx, "original_language", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog original languages: %w", err)
}
originalLanguages = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctScalarColumn(gctx, "content_rating", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog content ratings: %w", err)
}
contentRatings = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindAuthor, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog authors: %w", err)
}
authors = 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 {
return fmt.Errorf("listing catalog audiobook series: %w", err)
}
series = out
return nil
}))
if options.IncludeTechnical {
eg.Go(withLimit(func() error {
out, err := facets.Resolutions(gctx, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog resolutions: %w", err)
}
resolutions = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.JSONBLanguages(gctx, "audio_tracks", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog audio languages: %w", err)
}
audioLanguages = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.SubtitleLanguages(gctx, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog subtitle languages: %w", err)
}
subtitleLanguages = out
return nil
}))
}
if err := eg.Wait(); err != nil {
return nil, err
}
result := &CatalogFiltersResult{
Genres: genres,
Studios: studios,
Networks: networks,
Countries: countries,
OriginalLanguages: originalLanguages,
ContentRatings: contentRatings,
Authors: authors,
Narrators: narrators,
Series: series,
}
if options.IncludeTechnical {
result.Resolutions = resolutions
result.AudioLanguages = audioLanguages
result.SubtitleLanguages = subtitleLanguages
}
return result, nil
}
func validateCatalogQueryRequest(req CatalogRequest, allowPersonalizedSorts bool) error {
if req.Source != CatalogSourceQuery {
return fmt.Errorf("%w: source %q is not supported yet", ErrInvalidCatalogRequest, req.Source)
}
return validateCatalogOverlayQuery(
req.SearchQuery,
req.Query,
catalogQueryRuleFields,
QuerySortFieldSet(allowPersonalizedSorts),
true,
)
}
func validateCatalogPersonalRequest(req CatalogRequest) error {
switch req.Source {
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
default:
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
return validateCatalogOverlayQuery(req.SearchQuery, req.Query, catalogPersonalRuleFields, catalogPersonalSortFields(), false)
}
func validateCatalogPersonRequest(req CatalogRequest) error {
if req.Source != CatalogSourcePerson {
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if req.PersonID <= 0 {
return fmt.Errorf("%w: person_id is required", ErrInvalidCatalogRequest)
}
return validateCatalogOverlayQuery(req.SearchQuery, req.Query, catalogQueryRuleFields, catalogQuerySortFields(), false)
}
func validateCatalogSectionRequest(req CatalogRequest) error {
if req.Source != CatalogSourceSection {
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if strings.TrimSpace(req.SectionID) == "" {
return fmt.Errorf("%w: section_id is required", ErrInvalidCatalogRequest)
}
if req.Scope != "home" && req.Scope != "library" {
return fmt.Errorf("%w: scope must be 'home' or 'library'", ErrInvalidCatalogRequest)
}
if req.Scope == "library" && req.LibraryID <= 0 {
return fmt.Errorf("%w: library_id is required for library sections", ErrInvalidCatalogRequest)
}
return nil
}
func validateCatalogExactCollectionRequest(req CatalogRequest) error {
switch req.Source {
case CatalogSourceLibraryCollection, CatalogSourceUserCollection:
default:
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if strings.TrimSpace(req.CollectionID) == "" {
return fmt.Errorf("%w: collection_id is required", ErrInvalidCatalogRequest)
}
return nil
}
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
if !IsValidMediaScope(def.MediaScope) {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', 'manga', or 'video'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
}
for _, id := range def.LibraryIDs {
if id <= 0 {
return fmt.Errorf("%w: library_ids must contain positive IDs", ErrInvalidCatalogRequest)
}
}
for i, group := range def.Groups {
if group.Match != "" && group.Match != "all" && group.Match != "any" {
return fmt.Errorf("%w: groups[%d].match must be 'all' or 'any'", ErrInvalidCatalogRequest, i)
}
for j, rule := range group.Rules {
if !ruleFields[rule.Field] {
return fmt.Errorf("%w: groups[%d].rules[%d].field %q is not supported", ErrInvalidCatalogRequest, i, j, rule.Field)
}
def, ok := queryFieldDefs[rule.Field]
if !ok || !def.validOps[rule.Op] {
return fmt.Errorf("%w: groups[%d].rules[%d] is invalid", ErrInvalidCatalogRequest, i, j)
}
}
}
if def.Sort.Field == "" {
return nil
}
if def.Sort.Order != "" && def.Sort.Order != "asc" && def.Sort.Order != "desc" {
return fmt.Errorf("%w: sort.order must be 'asc' or 'desc'", ErrInvalidCatalogRequest)
}
if def.Sort.Field == "relevance" {
if !allowRelevance {
return fmt.Errorf("%w: relevance sort is only supported for query source", ErrInvalidCatalogRequest)
}
if strings.TrimSpace(searchQuery) == "" {
return fmt.Errorf("%w: relevance sort requires q", ErrInvalidCatalogRequest)
}
return nil
}
if !sortFields[def.Sort.Field] {
return fmt.Errorf("%w: sort.field %q is not supported", ErrInvalidCatalogRequest, def.Sort.Field)
}
return nil
}
var catalogQueryRuleFields = map[string]bool{
"type": true,
"genre": true,
"year": true,
"rating_imdb": true,
"studio": true,
"network": true,
"country": true,
"original_language": true,
"content_rating": true,
"added_at": true,
"release_date": true,
"status": true,
"actor": true,
"director": true,
"writer": true,
"producer": true,
"author": true,
"narrator": true,
"series": true,
"watched": true,
"favorited": true,
"in_watchlist": true,
"in_progress": true,
"last_watched": true,
"resolution": true,
"hdr": true,
"dolby_vision": true,
"bitrate": true,
"audio_language": true,
"subtitle_language": true,
}
var catalogPersonalRuleFields = map[string]bool{
"type": true,
"genre": true,
"year": true,
"rating_imdb": true,
"studio": true,
"network": true,
"country": true,
"original_language": true,
"content_rating": true,
"added_at": true,
"release_date": true,
"status": true,
"actor": true,
"director": true,
"writer": true,
"producer": true,
"author": true,
"narrator": true,
"series": true,
"watched": true,
"favorited": true,
"in_watchlist": true,
"in_progress": true,
"last_watched": true,
"resolution": true,
"hdr": true,
"dolby_vision": true,
"bitrate": true,
"audio_language": true,
"subtitle_language": true,
}
func catalogQuerySortFields() map[string]bool {
return QuerySortFieldSet(false)
}
func catalogPersonalSortFields() map[string]bool {
return QuerySortFieldSet(false)
}
func requiresAdvancedQueryExecution(def QueryDefinition) bool {
if def.Sort.Field == "bitrate" {
return true
}
for _, group := range def.Groups {
for _, rule := range group.Rules {
switch rule.Field {
case "actor", "director", "writer", "producer", "author", "narrator", "series", "watched", "favorited", "in_watchlist", "in_progress", "last_watched", "resolution", "hdr", "dolby_vision", "bitrate", "audio_language", "subtitle_language":
return true
}
}
}
return false
}
func useDirectSearchPath(req CatalogRequest) bool {
if req.Source != CatalogSourceQuery {
return false
}
if strings.TrimSpace(req.SearchQuery) == "" || strings.TrimSpace(req.NamePrefix) != "" {
return false
}
if len(req.Query.Groups) > 0 || requiresAdvancedQueryExecution(req.Query) {
return false
}
return req.Query.Sort.Field == "relevance" && req.Query.Sort.Order == "desc"
}
type catalogPageSection struct {
ID string
Scope string
LibraryID *int
SectionType string
Title string
ItemLimit int
Config json.RawMessage
CollectionID string // library collection ID
UserCollectionID string // personal user collection ID
}
type catalogSectionFilters struct {
FilterType string `json:"filter_type"`
FilterLibraryID *int `json:"filter_library_id"`
FilterLibraryIDs []int `json:"filter_library_ids"`
LibraryIDs []int
}
func defaultCatalogLibrarySection(libraryID int, sectionID string) (catalogPageSection, bool) {
sections := []catalogPageSection{
{
ID: "default-continue-watching",
Scope: "library",
LibraryID: &libraryID,
SectionType: "continue_watching",
Title: "Continue Watching",
ItemLimit: 20,
Config: json.RawMessage(`{}`),
},
{
ID: "default-recently-added",
Scope: "library",
LibraryID: &libraryID,
SectionType: "recently_added",
Title: "Recently Added",
ItemLimit: 20,
Config: json.RawMessage(`{}`),
},
{
ID: "default-recently-released",
Scope: "library",
LibraryID: &libraryID,
SectionType: "recently_released",
Title: "Recently Released",
ItemLimit: 20,
Config: json.RawMessage(`{}`),
},
}
for _, section := range sections {
if section.ID == sectionID {
return section, true
}
}
return catalogPageSection{}, false
}
func (r *CatalogResolver) loadCatalogSection(ctx context.Context, req CatalogRequest) (catalogPageSection, error) {
var section catalogPageSection
query := `
SELECT id, scope, library_id, section_type, title, item_limit, config
FROM page_sections
WHERE id = $1 AND scope = $2 AND enabled = true
`
args := []any{req.SectionID, req.Scope}
if req.Scope == "library" {
query += " AND library_id = $3"
args = append(args, req.LibraryID)
} else {
query += " AND library_id IS NULL"
}
err := r.itemRepo.pool.QueryRow(ctx, query, args...).Scan(
&section.ID,
&section.Scope,
&section.LibraryID,
&section.SectionType,
&section.Title,
&section.ItemLimit,
&section.Config,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if req.Scope == "library" {
if fallback, ok := defaultCatalogLibrarySection(req.LibraryID, req.SectionID); ok {
return fallback, nil
}
}
return catalogPageSection{}, ErrCatalogSourceNotFound
}
return catalogPageSection{}, err
}
var collectionCfg struct {
LibraryCollectionID string `json:"library_collection_id"`
UserCollectionID string `json:"user_collection_id"`
}
if len(section.Config) > 0 {
_ = json.Unmarshal(section.Config, &collectionCfg)
}
section.CollectionID = collectionCfg.LibraryCollectionID
section.UserCollectionID = collectionCfg.UserCollectionID
return section, nil
}
func parseCatalogSectionFilters(config json.RawMessage) catalogSectionFilters {
var filters catalogSectionFilters
if len(config) > 0 {
_ = json.Unmarshal(config, &filters)
}
filters.LibraryIDs = normalizeCatalogSectionLibraryIDs(filters.FilterLibraryID, filters.FilterLibraryIDs)
if filters.FilterType == "" && len(filters.LibraryIDs) == 0 {
if def, err := parseCatalogSectionQueryDefinition(config); err == nil {
filters.FilterType = def.MediaScope
filters.LibraryIDs = append([]int(nil), def.LibraryIDs...)
}
}
return filters
}
func normalizeCatalogSectionLibraryIDs(single *int, multiple []int) []int {
seen := map[int]struct{}{}
result := make([]int, 0, len(multiple)+1)
for _, id := range multiple {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
if single != nil && *single > 0 {
if _, ok := seen[*single]; !ok {
result = append(result, *single)
}
}
return result
}
func parseCatalogSectionQueryDefinition(config json.RawMessage) (QueryDefinition, error) {
return parseCatalogCollectionQueryDefinition(config)
}
func parseCatalogCollectionQueryDefinition(config json.RawMessage) (QueryDefinition, error) {
if len(bytesTrimSpace(config)) == 0 || string(bytesTrimSpace(config)) == "{}" || string(bytesTrimSpace(config)) == "null" {
return QueryDefinition{}.Normalize(), nil
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(config, &probe); err != nil {
return QueryDefinition{}, err
}
if _, ok := probe["filter_type"]; ok {
return NormalizeLegacySectionFilter(config)
}
if _, ok := probe["filter_library_id"]; ok {
return NormalizeLegacySectionFilter(config)
}
if _, ok := probe["filter_library_ids"]; ok {
return NormalizeLegacySectionFilter(config)
}
var def QueryDefinition
if err := json.Unmarshal(config, &def); err != nil {
return QueryDefinition{}, err
}
def = def.Normalize()
if err := def.Validate(); err != nil {
return QueryDefinition{}, err
}
return def, nil
}
func bytesTrimSpace(raw []byte) []byte {
return []byte(strings.TrimSpace(string(raw)))
}
func catalogCollectionUsesLiveQuery(raw json.RawMessage) bool {
trimmed := strings.TrimSpace(string(raw))
return trimmed != "" && trimmed != "{}" && trimmed != "null"
}
func intersectCatalogDefinitionLibraries(existing, required []int) []int {
if len(required) == 0 {
return existing
}
if len(existing) == 0 {
return append([]int(nil), required...)
}
return intersectInts(existing, required)
}
func stripCatalogUserScope(access AccessFilter) AccessFilter {
access.UserID = 0
access.ProfileID = ""
return access
}
func (r *CatalogResolver) catalogStoreForAccess(ctx context.Context, access AccessFilter) (userstore.UserStore, error) {
if r.storeProvider == nil || access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.storeProvider.ForUser(ctx, access.UserID)
if err != nil {
return nil, err
}
if store == nil {
return nil, ErrCatalogSourceNotFound
}
return store, nil
}
func catalogProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool {
if collection == nil || strings.TrimSpace(profileID) == "" {
return false
}
for _, allowed := range collection.AllowedProfileIDs {
if allowed == profileID {
return true
}
}
return false
}
func (r *CatalogResolver) loadPersonalSourceIDs(ctx context.Context, store userstore.UserStore, req CatalogRequest, profileID string) ([]string, error) {
switch req.Source {
case CatalogSourceFavorites:
entries, err := store.ListFavorites(ctx, profileID, 10000, 0)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.MediaItemID)
}
return ids, nil
case CatalogSourceWatchlist:
entries, err := store.ListWatchlist(ctx, profileID, 10000, 0)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.MediaItemID)
}
return ids, nil
case CatalogSourceHistory:
entries, err := store.ListHistory(ctx, profileID, 10000, 0)
if err != nil {
return nil, err
}
return ResolveHistoryDisplayIDs(ctx, entries, NewEpisodeRepository(r.itemRepo.pool))
default:
return nil, fmt.Errorf("%w: source %q is not a personal source", ErrInvalidCatalogRequest, req.Source)
}
}
func (r *CatalogResolver) fetchAccessibleItemsByID(ctx context.Context, contentIDs []string, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
if len(contentIDs) == 0 {
return []*models.MediaItem{}, nil
}
filters, earlyEmpty, err := catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
if earlyEmpty {
return []*models.MediaItem{}, nil
}
filters.ContentIDs = contentIDs
items, err := r.fetchAllBrowseCandidatesByFilters(ctx, filters)
if err != nil {
return nil, err
}
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
byID[item.ContentID] = item
}
ordered := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
item, ok := byID[contentID]
if !ok {
continue
}
ordered = append(ordered, item)
}
return ordered, nil
}
func (r *CatalogResolver) fetchAllBrowseCandidates(ctx context.Context, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
filters, earlyEmpty, err := catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
if earlyEmpty {
return []*models.MediaItem{}, nil
}
allItems := make([]*models.MediaItem, 0)
page := filters
page.Limit = 100
page.Offset = 0
page.Sort = "created_at"
page.Order = "desc"
for {
result, err := r.browseRepo.Browse(ctx, page)
if err != nil {
return nil, fmt.Errorf("browsing catalog items: %w", err)
}
allItems = append(allItems, result.Items...)
if len(allItems) >= result.Total || len(result.Items) == 0 {
break
}
page.Offset += page.Limit
}
return allItems, nil
}
func (r *CatalogResolver) fetchAllSearchCandidates(ctx context.Context, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
searchAccess, itemTypes, earlyEmpty := catalogSearchAccess(req, access)
if earlyEmpty {
return []*models.MediaItem{}, nil
}
_, total, err := r.itemRepo.Search(ctx, req.SearchQuery, itemTypes, 1, 0, searchAccess)
if err != nil {
return nil, fmt.Errorf("counting catalog search results: %w", err)
}
if total == 0 {
return []*models.MediaItem{}, nil
}
items := make([]*models.MediaItem, 0, total)
for offset := 0; offset < total; offset += 100 {
limit := min(100, total-offset)
page, _, err := r.itemRepo.Search(ctx, req.SearchQuery, itemTypes, limit, offset, searchAccess)
if err != nil {
return nil, fmt.Errorf("searching catalog items: %w", err)
}
items = append(items, page...)
}
return items, nil
}
func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter, []string, bool) {
allowedLibraryIDs, earlyEmpty := effectiveCatalogLibraryIDs(req.Query.LibraryIDs, access)
if earlyEmpty {
return AccessFilter{}, nil, true
}
searchAccess := AccessFilter{
AllowedLibraryIDs: allowedLibraryIDs,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
}
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
}
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
allowedLibraryIDs, earlyEmpty := effectiveCatalogLibraryIDs(req.Query.LibraryIDs, access)
if earlyEmpty {
return BrowseFilters{}, true, nil
}
filters := BrowseFilters{
// BrowseFilters.Type accepts a comma-separated type list, so group
// scopes like "video" expand here rather than leaking downstream.
Type: strings.Join(MediaScopeItemTypes(req.Query.MediaScope), ","),
NamePrefix: req.NamePrefix,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
}
applyCatalogBrowseOverlayRules(&filters, req.Query)
if len(allowedLibraryIDs) == 1 {
filters.LibraryID = allowedLibraryIDs[0]
} else if len(allowedLibraryIDs) > 1 {
filters.LibraryIDs = allowedLibraryIDs
} else if access.AllowedLibraryIDs != nil && len(req.Query.LibraryIDs) == 0 {
filters.LibraryIDs = []int{}
}
return filters, false, nil
}
func applyCatalogBrowseOverlayRules(filters *BrowseFilters, def QueryDefinition) {
if filters == nil {
return
}
for _, group := range def.Groups {
for _, rule := range group.Rules {
switch rule.Field {
case "genre":
if filters.Genre == "" && rule.Op == "contains" {
if value, ok := catalogStringValue(rule.Value); ok {
filters.Genre = value
}
}
case "status":
if filters.Status == "" && rule.Op == "is" {
if value, ok := catalogStringValue(rule.Value); ok {
filters.Status = value
}
}
case "content_rating":
if rule.Op == "is" {
if value, ok := catalogStringValue(rule.Value); ok && value != "" {
filters.ContentRating = append(filters.ContentRating, value)
}
}
case "year":
switch rule.Op {
case "between":
if values := catalogIntValues(rule.Value); len(values) >= 2 {
if filters.YearMin == 0 || values[0] > filters.YearMin {
filters.YearMin = values[0]
}
if filters.YearMax == 0 || values[1] < filters.YearMax {
filters.YearMax = values[1]
}
}
case "gte", "gt", "is":
if value, ok := catalogIntValue(rule.Value); ok {
if rule.Op == "gt" {
value++
}
if filters.YearMin == 0 || value > filters.YearMin {
filters.YearMin = value
}
if rule.Op == "is" && (filters.YearMax == 0 || value < filters.YearMax) {
filters.YearMax = value
}
}
case "lte", "lt":
if value, ok := catalogIntValue(rule.Value); ok {
if rule.Op == "lt" {
value--
}
if filters.YearMax == 0 || value < filters.YearMax {
filters.YearMax = value
}
}
}
}
}
}
if len(filters.ContentRating) > 1 {
filters.ContentRating = slices.Compact(filters.ContentRating)
}
}
func effectiveCatalogLibraryIDs(requestIDs []int, access AccessFilter) ([]int, bool) {
if len(requestIDs) == 0 {
if access.AllowedLibraryIDs != nil {
ids := append([]int(nil), access.AllowedLibraryIDs...)
ids = removeCatalogLibraryIDs(ids, access.DisabledLibraryIDs)
if len(ids) == 0 {
return nil, true
}
return ids, false
}
return nil, false
}
ids := append([]int(nil), requestIDs...)
if access.AllowedLibraryIDs != nil {
ids = intersectInts(ids, access.AllowedLibraryIDs)
}
ids = removeCatalogLibraryIDs(ids, access.DisabledLibraryIDs)
if len(ids) == 0 {
return nil, true
}
return ids, false
}
func effectiveCatalogDisabledLibraryIDs(requestIDs, disabled []int) []int {
if len(requestIDs) > 0 {
return nil
}
return append([]int(nil), disabled...)
}
func removeCatalogLibraryIDs(ids, remove []int) []int {
if len(ids) == 0 || len(remove) == 0 {
return ids
}
blocked := make(map[int]struct{}, len(remove))
for _, id := range remove {
blocked[id] = struct{}{}
}
filtered := ids[:0]
for _, id := range ids {
if _, ok := blocked[id]; ok {
continue
}
filtered = append(filtered, id)
}
return filtered
}
func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*models.MediaItem {
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
continue
}
if catalogDefinitionMatchesItem(item, def) {
filtered = append(filtered, item)
}
}
return filtered
}
func filterCatalogSearchItems(items []*models.MediaItem, raw string) []*models.MediaItem {
query := strings.TrimSpace(raw)
if query == "" {
return items
}
parsed := parseSearchQuery(query)
needle := normalizeTitleForComparison(firstNonEmptySearchValue(parsed.Text, query))
tokens := strings.Fields(needle)
if len(tokens) == 0 {
return items
}
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
haystack := normalizeTitleForComparison(strings.Join([]string{
item.Title,
item.SortTitle,
item.OriginalTitle,
item.Overview,
}, " "))
matched := true
for _, token := range tokens {
if !strings.Contains(haystack, token) {
matched = false
break
}
}
if matched {
filtered = append(filtered, item)
}
}
return filtered
}
func filterCatalogNamePrefix(items []*models.MediaItem, raw string) []*models.MediaItem {
prefix := strings.ToLower(strings.TrimSpace(raw))
if prefix == "" {
return items
}
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
title := strings.ToLower(strings.TrimSpace(item.Title))
sortTitle := strings.ToLower(strings.TrimSpace(item.SortTitle))
if strings.HasPrefix(title, prefix) || (sortTitle != "" && strings.HasPrefix(sortTitle, prefix)) {
filtered = append(filtered, item)
}
}
return filtered
}
func catalogStringValue(value any) (string, bool) {
str, ok := value.(string)
if !ok {
return "", false
}
str = strings.TrimSpace(str)
return str, str != ""
}
func catalogIntValue(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float32:
return int(v), true
case float64:
return int(v), true
default:
return 0, false
}
}
func catalogIntValues(value any) []int {
values, ok := value.([]any)
if !ok {
return nil
}
result := make([]int, 0, len(values))
for _, entry := range values {
if normalized, ok := catalogIntValue(entry); ok {
result = append(result, normalized)
}
}
return result
}
func catalogDefinitionMatchesItem(item *models.MediaItem, def QueryDefinition) bool {
matchMode := def.Match
if matchMode == "" {
matchMode = "all"
}
matches := 0
for _, group := range def.Groups {
if catalogGroupMatchesItem(item, group) {
matches++
if matchMode == "any" {
return true
}
} else if matchMode == "all" {
return false
}
}
if matchMode == "any" {
return matches > 0
}
return true
}
func catalogGroupMatchesItem(item *models.MediaItem, group QueryGroup) bool {
matchMode := group.Match
if matchMode == "" {
matchMode = "all"
}
matches := 0
for _, rule := range group.Rules {
if catalogRuleMatchesItem(item, rule) {
matches++
if matchMode == "any" {
return true
}
} else if matchMode == "all" {
return false
}
}
if matchMode == "any" {
return matches > 0
}
return true
}
func catalogRuleMatchesItem(item *models.MediaItem, rule QueryRule) bool {
switch rule.Field {
case "type":
return compareCatalogString(item.Type, rule.Op, rule.Value)
case "genre":
return compareCatalogStringSlice(item.Genres, rule.Op, rule.Value)
case "studio":
return compareCatalogStringSlice(item.Studios, rule.Op, rule.Value)
case "network":
return compareCatalogStringSlice(item.Networks, rule.Op, rule.Value)
case "country":
return compareCatalogStringSlice(item.Countries, rule.Op, rule.Value)
case "original_language":
return compareCatalogString(item.OriginalLanguage, rule.Op, rule.Value)
case "content_rating":
return compareCatalogString(item.ContentRating, rule.Op, rule.Value)
case "status":
return compareCatalogString(item.Status, rule.Op, rule.Value)
case "year":
return compareCatalogNumeric(float64(item.Year), rule.Op, rule.Value)
case "rating_imdb":
if item.RatingIMDB == nil {
return false
}
return compareCatalogNumeric(*item.RatingIMDB, rule.Op, rule.Value)
case "added_at":
addedAt := item.CreatedAt
if item.AddedAt != nil {
addedAt = *item.AddedAt
}
return compareCatalogTime(addedAt, rule.Op, rule.Value)
case "release_date":
releaseDate := catalogItemReleaseDate(item)
if releaseDate == "" {
return false
}
return compareCatalogStringDate(releaseDate, rule.Op, rule.Value)
default:
return false
}
}
func compareCatalogString(actual, op string, value any) bool {
expected := strings.TrimSpace(strings.ToLower(fmt.Sprint(value)))
actual = strings.TrimSpace(strings.ToLower(actual))
switch op {
case "is":
return actual == expected
case "is_not":
return actual != expected
default:
return false
}
}
func compareCatalogStringSlice(actual []string, op string, value any) bool {
expected := strings.TrimSpace(strings.ToLower(fmt.Sprint(value)))
found := false
for _, entry := range actual {
if strings.TrimSpace(strings.ToLower(entry)) == expected {
found = true
break
}
}
switch op {
case "contains", "is":
return found
case "is_not":
return !found
default:
return false
}
}
func compareCatalogNumeric(actual float64, op string, value any) bool {
switch op {
case "is":
expected, ok := catalogFloat(value)
return ok && actual == expected
case "is_not":
expected, ok := catalogFloat(value)
return ok && actual != expected
case "gt":
expected, ok := catalogFloat(value)
return ok && actual > expected
case "gte":
expected, ok := catalogFloat(value)
return ok && actual >= expected
case "lt":
expected, ok := catalogFloat(value)
return ok && actual < expected
case "lte":
expected, ok := catalogFloat(value)
return ok && actual <= expected
case "between":
values, ok := catalogFloatRange(value)
return ok && actual >= values[0] && actual <= values[1]
default:
return false
}
}
func compareCatalogStringDate(actual, op string, value any) bool {
actualTime, err := time.Parse("2006-01-02", actual)
if err != nil {
return false
}
switch op {
case "gt", "gte", "lt", "lte", "between", "is", "is_not", "in_last":
default:
return false
}
if op == "in_last" {
duration, ok := catalogStringValue(value)
if !ok {
return false
}
spec, err := parseDurationSpec(duration)
if err != nil {
return false
}
cutoff := catalogDateOnly(spec.cutoffTime(time.Now().UTC()))
return !actualTime.Before(cutoff)
}
if op == "between" {
values, ok := catalogStringRange(value)
if !ok {
return false
}
start, err := time.Parse("2006-01-02", values[0])
if err != nil {
return false
}
end, err := time.Parse("2006-01-02", values[1])
if err != nil {
return false
}
return !actualTime.Before(start) && !actualTime.After(end)
}
expected := strings.TrimSpace(fmt.Sprint(value))
expectedTime, err := time.Parse("2006-01-02", expected)
if err != nil {
return false
}
switch op {
case "is":
return actualTime.Equal(expectedTime)
case "is_not":
return !actualTime.Equal(expectedTime)
case "gt":
return actualTime.After(expectedTime)
case "gte":
return actualTime.After(expectedTime) || actualTime.Equal(expectedTime)
case "lt":
return actualTime.Before(expectedTime)
case "lte":
return actualTime.Before(expectedTime) || actualTime.Equal(expectedTime)
default:
return false
}
}
func compareCatalogTime(actual time.Time, op string, value any) bool {
if actual.IsZero() {
return false
}
switch op {
case "gt", "gte", "lt", "lte", "between", "in_last":
default:
return false
}
if op == "in_last" {
duration, ok := catalogStringValue(value)
if !ok {
return false
}
spec, err := parseDurationSpec(duration)
if err != nil {
return false
}
return !actual.Before(spec.cutoffTime(time.Now()))
}
if op == "between" {
values, ok := catalogStringRange(value)
if !ok {
return false
}
start, ok := catalogTimeValue(values[0])
if !ok {
return false
}
end, ok := catalogTimeValue(values[1])
if !ok {
return false
}
return !actual.Before(start) && !actual.After(end)
}
expected, ok := catalogTimeValue(value)
if !ok {
return false
}
switch op {
case "gt":
return actual.After(expected)
case "gte":
return actual.After(expected) || actual.Equal(expected)
case "lt":
return actual.Before(expected)
case "lte":
return actual.Before(expected) || actual.Equal(expected)
default:
return false
}
}
func catalogTimeValue(value any) (time.Time, bool) {
switch v := value.(type) {
case time.Time:
return v, !v.IsZero()
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return time.Time{}, false
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed, true
}
if parsed, err := time.Parse("2006-01-02", trimmed); err == nil {
return parsed, true
}
return time.Time{}, false
default:
return time.Time{}, false
}
}
func catalogDateOnly(value time.Time) time.Time {
utc := value.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
}
func catalogFloat(value any) (float64, bool) {
switch v := value.(type) {
case int:
return float64(v), true
case int64:
return float64(v), true
case float64:
return v, true
case float32:
return float64(v), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
return parsed, err == nil
default:
return 0, false
}
}
func catalogFloatRange(value any) ([2]float64, bool) {
switch v := value.(type) {
case []any:
if len(v) != 2 {
return [2]float64{}, false
}
start, ok := catalogFloat(v[0])
if !ok {
return [2]float64{}, false
}
end, ok := catalogFloat(v[1])
if !ok {
return [2]float64{}, false
}
return [2]float64{start, end}, true
default:
return [2]float64{}, false
}
}
func catalogStringRange(value any) ([2]string, bool) {
switch v := value.(type) {
case []any:
if len(v) != 2 {
return [2]string{}, false
}
return [2]string{fmt.Sprint(v[0]), fmt.Sprint(v[1])}, true
default:
return [2]string{}, false
}
}
func catalogItemReleaseDate(item *models.MediaItem) string {
if item.ReleaseDate != nil && strings.TrimSpace(*item.ReleaseDate) != "" {
return strings.TrimSpace(*item.ReleaseDate)
}
if item.FirstAirDate != nil {
return strings.TrimSpace(*item.FirstAirDate)
}
return ""
}
func sortCatalogItems(items []*models.MediaItem, sortConfig QuerySort) {
slices.SortStableFunc(items, func(a, b *models.MediaItem) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return 1
}
if b == nil {
return -1
}
direction := 1
if sortConfig.Order == "desc" {
direction = -1
}
switch sortConfig.Field {
case "title":
left := strings.ToLower(firstNonEmptySearchValue(a.SortTitle, a.Title))
right := strings.ToLower(firstNonEmptySearchValue(b.SortTitle, b.Title))
if left == right {
left = strings.ToLower(a.Title)
right = strings.ToLower(b.Title)
}
if cmp := strings.Compare(left, right); cmp != 0 {
return cmp * direction
}
case "year":
if a.Year != b.Year {
if a.Year < b.Year {
return -1 * direction
}
return 1 * direction
}
case "rating_imdb":
left := -1.0
right := -1.0
if a.RatingIMDB != nil {
left = *a.RatingIMDB
}
if b.RatingIMDB != nil {
right = *b.RatingIMDB
}
if left != right {
if left < right {
return -1 * direction
}
return 1 * direction
}
case "release_date":
left := catalogItemReleaseDate(a)
right := catalogItemReleaseDate(b)
if left != right {
return strings.Compare(left, right) * direction
}
case "last_air_date":
var left, right string
if a.LastAirDate != nil {
left = strings.TrimSpace(*a.LastAirDate)
}
if b.LastAirDate != nil {
right = strings.TrimSpace(*b.LastAirDate)
}
if left == "" && right != "" {
return 1
}
if left != "" && right == "" {
return -1
}
if left != right {
return strings.Compare(left, right) * direction
}
case "added_at":
left := a.CreatedAt
right := b.CreatedAt
if a.AddedAt != nil {
left = *a.AddedAt
}
if b.AddedAt != nil {
right = *b.AddedAt
}
if !left.Equal(right) {
if left.Before(right) {
return -1 * direction
}
return 1 * direction
}
}
return strings.Compare(a.ContentID, b.ContentID)
})
}
func paginateCatalogItems(items []*models.MediaItem, offset, limit int) []*models.MediaItem {
if offset < 0 {
offset = 0
}
if offset >= len(items) {
return []*models.MediaItem{}
}
if limit <= 0 {
limit = 20
}
end := min(len(items), offset+limit)
return append([]*models.MediaItem(nil), items[offset:end]...)
}