* 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>
1416 lines
48 KiB
Go
1416 lines
48 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// BrowseFilters represents all supported filter, sort, and pagination parameters
|
|
// for the /items browse endpoint.
|
|
type BrowseFilters struct {
|
|
Type string // single type ("movie") or comma-separated ("movie,series")
|
|
Genre string // single genre filter (GIN index)
|
|
NamePrefix string // case-insensitive prefix filter on sort_title/title
|
|
ContentIDs []string // optional allowlist of exact content IDs
|
|
LibraryID int // filter by specific library
|
|
LibraryIDs []int // accessible library IDs (nil = all)
|
|
DisabledLibraryIDs []int // user-disabled libraries to exclude (only used when LibraryIDs is nil)
|
|
MaxContentRating string // maximum allowed content rating ceiling
|
|
YearMin int // minimum year (inclusive)
|
|
YearMax int // maximum year (inclusive)
|
|
ContentRating []string // comma-separated content ratings (e.g., PG-13, TV-MA)
|
|
Status string // pending, matched, unmatched (optional filter)
|
|
PersonID int64 // filter items by person (joins item_people)
|
|
Sort string // created_at, release_date, rating_imdb, rating_tmdb, year, sort_title, recently_added
|
|
Order string // asc, desc
|
|
Limit int
|
|
MaxLimit int // optional caller-specific cap; zero keeps the default browse cap
|
|
Offset int
|
|
SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp
|
|
RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter)
|
|
}
|
|
|
|
// BrowseResult contains the paginated result of a browse query.
|
|
type BrowseResult struct {
|
|
Items []*models.MediaItem `json:"items"`
|
|
Total int `json:"total"`
|
|
HasMore bool `json:"has_more"`
|
|
}
|
|
|
|
// BrowseRepository provides browse/filter queries on the media_items table.
|
|
type BrowseRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewBrowseRepository creates a new BrowseRepository.
|
|
func NewBrowseRepository(pool *pgxpool.Pool) *BrowseRepository {
|
|
return &BrowseRepository{pool: pool}
|
|
}
|
|
|
|
// Pool returns the underlying pgx pool for ad-hoc queries.
|
|
func (r *BrowseRepository) Pool() *pgxpool.Pool {
|
|
return r.pool
|
|
}
|
|
|
|
// Browse executes a filtered, sorted, paginated query against media_items.
|
|
func (r *BrowseRepository) Browse(ctx context.Context, filters BrowseFilters) (*BrowseResult, error) {
|
|
return r.browse(ctx, filters, true)
|
|
}
|
|
|
|
func (r *BrowseRepository) BrowsePage(ctx context.Context, filters BrowseFilters, includeTotal bool) (*BrowseResult, error) {
|
|
return r.browse(ctx, filters, includeTotal)
|
|
}
|
|
|
|
func (r *BrowseRepository) browse(ctx context.Context, filters BrowseFilters, includeTotal bool) (*BrowseResult, error) {
|
|
plan, earlyEmpty, err := r.buildBrowsePlan(filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if earlyEmpty {
|
|
return &BrowseResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false}, nil
|
|
}
|
|
|
|
dataQuery, queryArgs := plan.pagedSQL(false)
|
|
rows, err := r.pool.Query(ctx, dataQuery, queryArgs...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("browsing media items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var (
|
|
items []*models.MediaItem
|
|
total int
|
|
)
|
|
items, err = scanBrowseItems(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hasMore := false
|
|
hasExtraRow := len(items) > plan.limit
|
|
if hasExtraRow {
|
|
items = items[:plan.limit]
|
|
}
|
|
if includeTotal {
|
|
switch {
|
|
case plan.offset == 0 && len(items) == 0:
|
|
total = 0
|
|
case !hasExtraRow && len(items) > 0:
|
|
total = plan.offset + len(items)
|
|
default:
|
|
countSQL, countArgs := plan.countSQL()
|
|
if err := r.pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
|
return nil, fmt.Errorf("count browse page: %w", err)
|
|
}
|
|
}
|
|
hasMore = total > plan.offset+plan.limit
|
|
} else if hasExtraRow {
|
|
hasMore = true
|
|
}
|
|
|
|
return &BrowseResult{
|
|
Items: items,
|
|
Total: total,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// browseQueryPlan captures the rendered SQL fragments and bound args for a
|
|
// browse data query. It is produced by buildBrowsePlan and consumed both by
|
|
// browse() (to execute) and by tests (to inspect the emitted SQL without a
|
|
// database).
|
|
type browseQueryPlan struct {
|
|
selectClause string
|
|
fromClause string
|
|
whereClause string
|
|
groupByClause string
|
|
orderBy string
|
|
// args holds bound values for the FROM/WHERE/GROUP BY portion of the
|
|
// plan. orderArgs is kept separate so countSQL — which omits ORDER BY —
|
|
// can bind only the args its SQL actually references. Combining the two
|
|
// would make countSQL pass extra bound values past the count query's
|
|
// placeholder set, and pgx/Postgres reject that with "bind message
|
|
// supplies N parameters, but prepared statement requires M". The
|
|
// random+SnapshotAt sort path (buildOrderByPlan) is the canonical case:
|
|
// it bakes a bound timestamp arg into the ORDER BY expression that the
|
|
// count query has no use for.
|
|
args []any
|
|
orderArgs []any
|
|
limit int
|
|
offset int
|
|
limitArgIdx int // 1-based bound-arg index for the LIMIT param
|
|
}
|
|
|
|
// countSQL renders a count-only query that returns the total number of rows
|
|
// matching the plan's FROM/WHERE/GROUP BY, ignoring LIMIT/OFFSET/ORDER BY.
|
|
// Used by exact-total callers so the data SELECT can remain a pure page query.
|
|
// Wraps the inner query in `SELECT COUNT(*) FROM (...) sub` so any GROUP BY in
|
|
// the inner query is preserved.
|
|
//
|
|
// Binds only p.args (FROM/WHERE/GROUP BY values). orderArgs are deliberately
|
|
// excluded — the count SQL has no ORDER BY clause to reference them, and
|
|
// passing extra bound values would fail with "bind message supplies N
|
|
// parameters, but prepared statement requires M".
|
|
func (p browseQueryPlan) countSQL() (string, []any) {
|
|
args := append([]any{}, p.args...)
|
|
sql := fmt.Sprintf(
|
|
"SELECT COUNT(*) FROM (SELECT 1 FROM %s %s %s) sub",
|
|
p.fromClause, p.whereClause, p.groupByClause,
|
|
)
|
|
return sql, args
|
|
}
|
|
|
|
// pagedSQL renders the final paged SELECT and returns it together with the
|
|
// fully-bound arg list. The query always asks for one extra row so callers can
|
|
// detect whether more pages exist without coupling the sorted page fetch to an
|
|
// exact count.
|
|
func (p browseQueryPlan) pagedSQL(_ bool) (string, []any) {
|
|
queryLimit := p.limit
|
|
queryLimit++
|
|
// Bind order: where/from args, then order-by args, then LIMIT, then OFFSET.
|
|
// limitArgIdx is computed by buildBrowsePlan after consuming order args, so
|
|
// it correctly points at LIMIT after the orderArgs are bound below.
|
|
args := append([]any{}, p.args...)
|
|
args = append(args, p.orderArgs...)
|
|
args = append(args, queryLimit, p.offset)
|
|
offsetArgIdx := p.limitArgIdx + 1
|
|
|
|
selectList := p.selectClause
|
|
sql := fmt.Sprintf(
|
|
"SELECT %s FROM %s %s %s %s LIMIT $%d OFFSET $%d",
|
|
selectList, p.fromClause, p.whereClause, p.groupByClause, p.orderBy,
|
|
p.limitArgIdx, offsetArgIdx,
|
|
)
|
|
return sql, args
|
|
}
|
|
|
|
// buildBrowsePlan assembles the WHERE clause, FROM clause, args, ORDER BY, and
|
|
// GROUP BY for a browse query. It performs no I/O. browse() uses it to run the
|
|
// actual query; tests use it to assert the emitted SQL. earlyEmpty == true
|
|
// means the filters are unsatisfiable (e.g. empty library allowlist) and the
|
|
// caller should return an empty result without executing.
|
|
func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPlan, bool, error) {
|
|
// Normalize defaults.
|
|
if filters.Limit <= 0 {
|
|
filters.Limit = 20
|
|
}
|
|
maxLimit := filters.MaxLimit
|
|
if maxLimit <= 0 {
|
|
maxLimit = 100
|
|
}
|
|
if filters.Limit > maxLimit {
|
|
filters.Limit = maxLimit
|
|
}
|
|
if filters.Offset < 0 {
|
|
filters.Offset = 0
|
|
}
|
|
if filters.Order == "" {
|
|
filters.Order = "desc"
|
|
}
|
|
if filters.Sort == "" {
|
|
filters.Sort = "created_at"
|
|
}
|
|
|
|
// Build WHERE clause and args.
|
|
var conditions []string
|
|
var args []any
|
|
argIdx := 1
|
|
|
|
// Type filter (supports comma-separated multi-type, e.g. "movie,series").
|
|
if filters.Type != "" {
|
|
types := splitTypes(filters.Type)
|
|
if len(types) == 1 {
|
|
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
|
|
args = append(args, types[0])
|
|
argIdx++
|
|
} else {
|
|
conditions = append(conditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
|
|
args = append(args, types)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
if filters.ContentIDs != nil {
|
|
if len(filters.ContentIDs) == 0 {
|
|
return browseQueryPlan{}, true, nil
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("mi.content_id = ANY($%d)", argIdx))
|
|
args = append(args, filters.ContentIDs)
|
|
argIdx++
|
|
}
|
|
|
|
// Genre filter (GIN array containment).
|
|
if filters.Genre != "" {
|
|
conditions = append(conditions, fmt.Sprintf("mi.genres @> ARRAY[$%d]::text[]", argIdx))
|
|
args = append(args, filters.Genre)
|
|
argIdx++
|
|
}
|
|
|
|
if prefix := strings.TrimSpace(filters.NamePrefix); prefix != "" {
|
|
// Dual-column OR so titles without a curated sort_title still match.
|
|
// First arm matches the idx_media_items_sort_key expression
|
|
// (LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title))) so the
|
|
// anchored LIKE is sargable; second arm uses idx_media_items_search_exact_title
|
|
// (LOWER(title)). Both arms are equivalent when sort_title is empty,
|
|
// which is harmless — the planner can BitmapOr the two index scans.
|
|
conditions = append(conditions, fmt.Sprintf(
|
|
"(LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) LIKE $%d ESCAPE '\\' OR LOWER(mi.title) LIKE $%d ESCAPE '\\')",
|
|
argIdx, argIdx,
|
|
))
|
|
args = append(args, likePrefixPattern(prefix))
|
|
argIdx++
|
|
}
|
|
|
|
// Year range filters.
|
|
if filters.YearMin > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("mi.year >= $%d", argIdx))
|
|
args = append(args, filters.YearMin)
|
|
argIdx++
|
|
}
|
|
if filters.YearMax > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("mi.year <= $%d", argIdx))
|
|
args = append(args, filters.YearMax)
|
|
argIdx++
|
|
}
|
|
|
|
// Content rating filter (multi-value).
|
|
if len(filters.ContentRating) > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("mi.content_rating = ANY($%d)", argIdx))
|
|
args = append(args, filters.ContentRating)
|
|
argIdx++
|
|
}
|
|
|
|
// Status filter.
|
|
if filters.Status != "" {
|
|
conditions = append(conditions, fmt.Sprintf("mi.status = $%d", argIdx))
|
|
args = append(args, filters.Status)
|
|
argIdx++
|
|
}
|
|
|
|
// Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so
|
|
// random/limited selections only ever pick items that actually have a
|
|
// backdrop — filtering after the LIMIT would wrongly return empty pages.
|
|
if filters.RequireBackdrop {
|
|
conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL")
|
|
}
|
|
|
|
// Library access control: restrict to user's accessible libraries.
|
|
needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added"
|
|
needsPersonJoin := filters.PersonID > 0
|
|
// When filtering by exactly one library and no person join, each
|
|
// content_id matches at most one mil row, so the GROUP BY usually added
|
|
// to dedup the junction join is unnecessary. Skipping it lets ORDER BY
|
|
// mil.first_seen_at exploit idx_item_libraries_folder_seen_content
|
|
// directly — turning a full-library scan + heapsort into a top-N index
|
|
// walk.
|
|
singleLibraryNoDedup := filters.LibraryID > 0 && filters.LibraryIDs == nil && len(filters.DisabledLibraryIDs) == 0 && !needsPersonJoin
|
|
|
|
if filters.PersonID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("ip.person_id = $%d", argIdx))
|
|
args = append(args, filters.PersonID)
|
|
argIdx++
|
|
}
|
|
|
|
if filters.LibraryID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
|
|
args = append(args, filters.LibraryID)
|
|
argIdx++
|
|
}
|
|
|
|
if filters.LibraryIDs != nil {
|
|
if len(filters.LibraryIDs) == 0 {
|
|
// User has empty library access — return no results.
|
|
return browseQueryPlan{}, true, nil
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = ANY($%d)", argIdx))
|
|
args = append(args, filters.LibraryIDs)
|
|
argIdx++
|
|
}
|
|
|
|
if len(filters.DisabledLibraryIDs) > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("NOT (mil.media_folder_id = ANY($%d))", argIdx))
|
|
args = append(args, filters.DisabledLibraryIDs)
|
|
argIdx++
|
|
}
|
|
|
|
applyAccessFilter("mi", AccessFilter{MaxContentRating: filters.MaxContentRating}, &conditions, &args, &argIdx)
|
|
|
|
// Manga chapters (type='ebook' rows linked into a manga series) are internal
|
|
// sub-units and must never surface as standalone catalog items.
|
|
conditions = append(conditions, MangaChapterExclusionWhere("mi"))
|
|
|
|
if filters.SnapshotAt != nil {
|
|
conditions = append(conditions, fmt.Sprintf("mi.created_at <= $%d", argIdx))
|
|
args = append(args, *filters.SnapshotAt)
|
|
argIdx++
|
|
}
|
|
|
|
// Build FROM clause with optional JOIN.
|
|
fromClause := "media_items mi"
|
|
if needsLibJoin {
|
|
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
|
}
|
|
if needsPersonJoin {
|
|
fromClause += " JOIN item_people ip ON ip.content_id = mi.content_id"
|
|
}
|
|
|
|
// Build WHERE string.
|
|
whereClause := ""
|
|
if len(conditions) > 0 {
|
|
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
|
|
// Build ORDER BY clause. orderArgs are kept out of `args` so countSQL can
|
|
// bind only its FROM/WHERE/GROUP BY values; pagedSQL binds them together.
|
|
// argIdx still advances past them so limitArgIdx points at the right
|
|
// LIMIT placeholder once orderArgs are bound positionally before LIMIT.
|
|
orderBy, orderArgs := buildOrderByPlan(filters.Sort, filters.Order, filters.SnapshotAt, argIdx, singleLibraryNoDedup, browseFiltersAreMovieOnly(filters))
|
|
argIdx += len(orderArgs)
|
|
|
|
// Only run the manga count subqueries when the scope can contain manga
|
|
// series; a non-manga type filter rules them out, so substitute NULL
|
|
// placeholders and skip two correlated subqueries per row on the hot path.
|
|
mangaCounts := mangaCountColumns("mi")
|
|
if !browseScopeMayContainManga(filters) {
|
|
mangaCounts = nullMangaCountColumns()
|
|
}
|
|
selectClause := browseItemColumns("mi") + ", " + mangaCounts
|
|
groupByClause := ""
|
|
switch {
|
|
case singleLibraryNoDedup:
|
|
// Single library, no person join: mil row is unique per content_id.
|
|
// Project mil.first_seen_at directly so ORDER BY can use the index.
|
|
selectClause += ", mil.first_seen_at"
|
|
case needsLibJoin:
|
|
// Multi-library or disabled-library filter: dedup with GROUP BY and
|
|
// expose the earliest first_seen_at as the "added at" timestamp.
|
|
selectClause += ", MIN(mil.first_seen_at)"
|
|
groupByClause = "GROUP BY " + browseGroupByColumns("mi")
|
|
case needsPersonJoin:
|
|
// Person join needs GROUP BY to deduplicate, but mil is not available.
|
|
selectClause += ", mi.created_at"
|
|
groupByClause = "GROUP BY " + browseGroupByColumns("mi")
|
|
default:
|
|
// No junction join — use created_at as the added_at fallback.
|
|
selectClause += ", mi.created_at"
|
|
}
|
|
|
|
return browseQueryPlan{
|
|
selectClause: selectClause,
|
|
fromClause: fromClause,
|
|
whereClause: whereClause,
|
|
groupByClause: groupByClause,
|
|
orderBy: orderBy,
|
|
args: args,
|
|
orderArgs: orderArgs,
|
|
limit: filters.Limit,
|
|
offset: filters.Offset,
|
|
limitArgIdx: argIdx,
|
|
}, false, nil
|
|
}
|
|
|
|
// filterWhereClause builds the common WHERE clause and FROM clause used by the
|
|
// ListXxx distinct-value methods. If the returned earlyEmpty flag is true the
|
|
// caller should return an empty result immediately (e.g. when LibraryIDs is an
|
|
// empty slice).
|
|
func filterWhereClause(filters BrowseFilters) (fromClause, whereClause string, args []any, earlyEmpty bool) {
|
|
return filterWhereClauseForSource(filters, "media_items mi", "")
|
|
}
|
|
|
|
func filterWhereClauseForSource(filters BrowseFilters, baseRelation string, mediaScope string) (fromClause, whereClause string, args []any, earlyEmpty bool) {
|
|
var conditions []string
|
|
argIdx := 1
|
|
|
|
if filters.Type != "" {
|
|
types := splitTypes(filters.Type)
|
|
if len(types) == 1 {
|
|
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
|
|
args = append(args, types[0])
|
|
argIdx++
|
|
} else {
|
|
conditions = append(conditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
|
|
args = append(args, types)
|
|
argIdx++
|
|
}
|
|
}
|
|
if prefix := strings.TrimSpace(filters.NamePrefix); prefix != "" {
|
|
// Same dual-column shape as filterWhereClauseForSource's primary
|
|
// browse path — see comment there for index-alignment rationale.
|
|
conditions = append(conditions, fmt.Sprintf(
|
|
"(LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) LIKE $%d ESCAPE '\\' OR LOWER(mi.title) LIKE $%d ESCAPE '\\')",
|
|
argIdx, argIdx,
|
|
))
|
|
args = append(args, likePrefixPattern(prefix))
|
|
argIdx++
|
|
}
|
|
if filters.Status != "" {
|
|
conditions = append(conditions, fmt.Sprintf("mi.status = $%d", argIdx))
|
|
args = append(args, filters.Status)
|
|
argIdx++
|
|
}
|
|
if filters.PersonID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("ip.person_id = $%d", argIdx))
|
|
args = append(args, filters.PersonID)
|
|
argIdx++
|
|
}
|
|
if filters.LibraryID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
|
|
args = append(args, filters.LibraryID)
|
|
argIdx++
|
|
}
|
|
if filters.ContentIDs != nil {
|
|
if len(filters.ContentIDs) == 0 {
|
|
return "", "", nil, true
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("mi.content_id = ANY($%d)", argIdx))
|
|
args = append(args, filters.ContentIDs)
|
|
argIdx++
|
|
}
|
|
if filters.LibraryIDs != nil {
|
|
if len(filters.LibraryIDs) == 0 {
|
|
return "", "", nil, true
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = ANY($%d)", argIdx))
|
|
args = append(args, filters.LibraryIDs)
|
|
argIdx++
|
|
}
|
|
|
|
if len(filters.DisabledLibraryIDs) > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("NOT (mil.media_folder_id = ANY($%d))", argIdx))
|
|
args = append(args, filters.DisabledLibraryIDs)
|
|
argIdx++
|
|
}
|
|
|
|
applyAccessFilter("mi", AccessFilter{MaxContentRating: filters.MaxContentRating}, &conditions, &args, &argIdx)
|
|
|
|
fromClause = baseRelation
|
|
libraryContentExpr := catalogLibraryContentExprForScope(mediaScope, "mi")
|
|
if filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 {
|
|
membershipTable, membershipKey := catalogLibraryMembershipTableAndKeyForScope(mediaScope)
|
|
fromClause += " JOIN " + membershipTable + " mil ON " + libraryContentExpr + " = mil." + membershipKey
|
|
}
|
|
if filters.PersonID > 0 {
|
|
fromClause += " JOIN item_people ip ON ip.content_id = mi.content_id"
|
|
}
|
|
|
|
if len(conditions) > 0 {
|
|
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
return fromClause, whereClause, args, false
|
|
}
|
|
|
|
// listDistinctArrayColumn returns sorted distinct non-empty values from an
|
|
// array column (genres, studios, networks, countries).
|
|
func (r *BrowseRepository) listDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters) ([]string, error) {
|
|
return listDistinctArrayColumnWithSource(ctx, r.pool, column, filters, "media_items mi", "")
|
|
}
|
|
|
|
func listDistinctArrayColumnWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
column string,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if empty {
|
|
return []string{}, nil
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT val
|
|
FROM (
|
|
SELECT UNNEST(mi.%s) AS val
|
|
FROM %s
|
|
%s
|
|
) vals
|
|
WHERE val <> ''
|
|
ORDER BY val ASC
|
|
LIMIT %d
|
|
`, column, fromClause, whereClause, catalogFacetMaxValues)
|
|
|
|
rows, err := pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing %s: %w", column, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
values := make([]string, 0)
|
|
for rows.Next() {
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
return nil, fmt.Errorf("scan %s: %w", column, err)
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
values = append(values, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate %s: %w", column, err)
|
|
}
|
|
|
|
slices.Sort(values)
|
|
return values, nil
|
|
}
|
|
|
|
// listDistinctScalarColumn returns sorted distinct non-empty values from a
|
|
// scalar text column (e.g. content_rating).
|
|
func (r *BrowseRepository) listDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters) ([]string, error) {
|
|
return listDistinctScalarColumnWithSource(ctx, r.pool, column, filters, "media_items mi", "")
|
|
}
|
|
|
|
func listDistinctScalarColumnWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
column string,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if empty {
|
|
return []string{}, nil
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT mi.%s
|
|
FROM %s
|
|
%s
|
|
AND mi.%s <> ''
|
|
ORDER BY mi.%s ASC
|
|
LIMIT %d
|
|
`, column, fromClause, whereClause, column, column, catalogFacetMaxValues)
|
|
|
|
// When there are no WHERE conditions, the extra AND is invalid — prepend WHERE instead.
|
|
if whereClause == "" {
|
|
query = fmt.Sprintf(`
|
|
SELECT DISTINCT mi.%s
|
|
FROM %s
|
|
WHERE mi.%s <> ''
|
|
ORDER BY mi.%s ASC
|
|
LIMIT %d
|
|
`, column, fromClause, column, column, catalogFacetMaxValues)
|
|
}
|
|
|
|
rows, err := pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing %s: %w", column, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
values := make([]string, 0)
|
|
for rows.Next() {
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
return nil, fmt.Errorf("scan %s: %w", column, err)
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
values = append(values, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate %s: %w", column, err)
|
|
}
|
|
|
|
slices.Sort(values)
|
|
return values, nil
|
|
}
|
|
|
|
// ListGenres returns the distinct genres matching the supplied browse filters.
|
|
func (r *BrowseRepository) ListGenres(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctArrayColumn(ctx, "genres", filters)
|
|
}
|
|
|
|
// ListStudios returns the distinct studios matching the supplied browse filters.
|
|
func (r *BrowseRepository) ListStudios(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctArrayColumn(ctx, "studios", filters)
|
|
}
|
|
|
|
// ListNetworks returns the distinct networks matching the supplied browse filters.
|
|
func (r *BrowseRepository) ListNetworks(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctArrayColumn(ctx, "networks", filters)
|
|
}
|
|
|
|
// ListCountries returns the distinct countries matching the supplied browse filters.
|
|
func (r *BrowseRepository) ListCountries(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctArrayColumn(ctx, "countries", filters)
|
|
}
|
|
|
|
// ListContentRatings returns the distinct content ratings matching the supplied browse filters.
|
|
func (r *BrowseRepository) ListContentRatings(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctScalarColumn(ctx, "content_rating", filters)
|
|
}
|
|
|
|
func (r *BrowseRepository) ListOriginalLanguages(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctScalarColumn(ctx, "original_language", filters)
|
|
}
|
|
|
|
func (r *BrowseRepository) ListResolutions(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return listResolutionsWithSource(ctx, r.pool, filters, "media_items mi", "")
|
|
}
|
|
|
|
func listResolutionsWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if earlyEmpty {
|
|
return nil, nil
|
|
}
|
|
mediaFileJoin := catalogMediaFileJoinConditionForScope(mediaScope, "mf", "mi")
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT mf.resolution
|
|
FROM %s
|
|
JOIN media_files mf ON %s
|
|
%s
|
|
AND mf.missing_since IS NULL
|
|
AND mf.resolution IS NOT NULL
|
|
AND BTRIM(mf.resolution) <> ''
|
|
ORDER BY mf.resolution ASC
|
|
`, fromClause, mediaFileJoin, browseFilterPrefix(whereClause))
|
|
return queryDistinctStrings(ctx, pool, query, args)
|
|
}
|
|
|
|
func (r *BrowseRepository) ListAudioLanguages(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return r.listDistinctJSONBLanguageWithFilters(ctx, "audio_tracks", filters)
|
|
}
|
|
|
|
func (r *BrowseRepository) ListSubtitleLanguages(ctx context.Context, filters BrowseFilters) ([]string, error) {
|
|
return listSubtitleLanguagesWithSource(ctx, r.pool, filters, "media_items mi", "")
|
|
}
|
|
|
|
func listSubtitleLanguagesWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if earlyEmpty {
|
|
return nil, nil
|
|
}
|
|
mediaFileJoin := catalogMediaFileJoinConditionForScope(mediaScope, "mf", "mi")
|
|
|
|
// Embedded subtitles use the migration 104 generated text[]
|
|
// `subtitle_language_codes`; external subs still need a JSONB unnest
|
|
// because the generated column only covers subtitle_tracks
|
|
// (audit 2026-05-01 §2.5b).
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT value
|
|
FROM (
|
|
SELECT lang AS value
|
|
FROM %s
|
|
JOIN media_files mf ON %s
|
|
CROSS JOIN LATERAL UNNEST(mf.subtitle_language_codes) AS lang
|
|
%s
|
|
AND mf.missing_since IS NULL
|
|
|
|
UNION
|
|
|
|
SELECT LOWER(COALESCE(track->>'language', '')) AS value
|
|
FROM %s
|
|
JOIN media_files mf ON %s
|
|
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(mf.external_subtitles, '[]'::jsonb)) AS track
|
|
%s
|
|
AND mf.missing_since IS NULL
|
|
) languages
|
|
WHERE value IS NOT NULL AND value <> ''
|
|
ORDER BY value ASC
|
|
`, fromClause, mediaFileJoin, browseFilterPrefix(whereClause), fromClause, mediaFileJoin, browseFilterPrefix(whereClause))
|
|
return queryDistinctStrings(ctx, pool, query, args)
|
|
}
|
|
|
|
func (r *BrowseRepository) listDistinctJSONBLanguageWithFilters(ctx context.Context, column string, filters BrowseFilters) ([]string, error) {
|
|
return listDistinctJSONBLanguageWithSource(ctx, r.pool, column, filters, "media_items mi", "")
|
|
}
|
|
|
|
// listDistinctPeopleByKindWithSource returns distinct people.name values for a
|
|
// PersonKind across the scoped result set. Powers the Authors and Narrators
|
|
// facets — both share item_people, just keyed by `kind`. The from/where
|
|
// clauses from filterWhereClauseForSource gate by library / access / scope,
|
|
// so an audiobook-only library or audiobook media_scope drops video-only
|
|
// content automatically.
|
|
func listDistinctPeopleByKindWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
kind models.PersonKind,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if earlyEmpty {
|
|
return []string{}, nil
|
|
}
|
|
args = append(args, int(kind))
|
|
kindIdx := len(args)
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT p.name
|
|
FROM %s
|
|
JOIN item_people ip ON ip.content_id = mi.content_id AND ip.kind = $%d
|
|
JOIN people p ON p.id = ip.person_id
|
|
%s
|
|
AND p.name IS NOT NULL
|
|
AND BTRIM(p.name) <> ''
|
|
ORDER BY p.name ASC
|
|
LIMIT %d
|
|
`, fromClause, kindIdx, browseFilterPrefix(whereClause), catalogFacetMaxValues)
|
|
return queryDistinctStrings(ctx, pool, query, args)
|
|
}
|
|
|
|
// listDistinctAudiobookSeriesWithSource returns distinct series_name values
|
|
// from the active book series table joined onto the scoped result set. Names are trimmed
|
|
// and case-folded for sort so the picker doesn't show duplicates that differ
|
|
// only by whitespace or casing; the literal trimmed series_name is still
|
|
// returned so existing rules continue to match.
|
|
//
|
|
// The DISTINCT happens in an inline subquery (rather than directly on the
|
|
// outer SELECT) so the outer ORDER BY can apply LOWER(...) without violating
|
|
// Postgres' "ORDER BY expressions must appear in select list" rule for
|
|
// SELECT DISTINCT.
|
|
func listDistinctAudiobookSeriesWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if earlyEmpty {
|
|
return []string{}, nil
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT name FROM (
|
|
SELECT DISTINCT BTRIM(s.series_name) AS name
|
|
FROM %s
|
|
JOIN %s s ON s.content_id = mi.content_id
|
|
%s
|
|
AND s.series_name IS NOT NULL
|
|
AND BTRIM(s.series_name) <> ''
|
|
) names
|
|
ORDER BY LOWER(name) ASC
|
|
LIMIT %d
|
|
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), catalogFacetMaxValues)
|
|
return queryDistinctStrings(ctx, pool, query, args)
|
|
}
|
|
|
|
// searchDistinctArrayColumnWithSource prefix-searches the distinct values
|
|
// of an array column (genres, studios, networks, countries) within the
|
|
// scoped result set. Returns up to limit matches in alphabetical order
|
|
// plus a hasMore flag (true when the underlying result set held more
|
|
// rows than limit).
|
|
func searchDistinctArrayColumnWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
column string,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
prefix string,
|
|
limit int,
|
|
) ([]string, bool, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if limit <= 0 {
|
|
return []string{}, false, nil
|
|
}
|
|
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if empty {
|
|
return []string{}, false, nil
|
|
}
|
|
args = append(args, prefix+"%")
|
|
prefixIdx := len(args)
|
|
// LOWER() in ORDER BY: this query is already wrapped in a subquery
|
|
// (the DISTINCT UNNEST), so the outer ORDER BY can reference any
|
|
// expression freely.
|
|
query := fmt.Sprintf(`
|
|
SELECT name FROM (
|
|
SELECT DISTINCT UNNEST(mi.%s) AS name
|
|
FROM %s
|
|
%s
|
|
) vals
|
|
WHERE name IS NOT NULL
|
|
AND BTRIM(name) <> ''
|
|
AND LOWER(name) LIKE LOWER($%d)
|
|
ORDER BY LOWER(name) ASC
|
|
LIMIT %d
|
|
`, column, fromClause, whereClause, prefixIdx, limit+1)
|
|
return queryFacetSearchResults(ctx, pool, query, args, limit)
|
|
}
|
|
|
|
// searchDistinctScalarColumnWithSource is the scalar (e.g. content_rating)
|
|
// variant of searchDistinctArrayColumnWithSource.
|
|
func searchDistinctScalarColumnWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
column string,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
prefix string,
|
|
limit int,
|
|
) ([]string, bool, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if limit <= 0 {
|
|
return []string{}, false, nil
|
|
}
|
|
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if empty {
|
|
return []string{}, false, nil
|
|
}
|
|
args = append(args, prefix+"%")
|
|
prefixIdx := len(args)
|
|
// DISTINCT in an inline subquery so the outer ORDER BY can apply
|
|
// LOWER() without violating the SELECT DISTINCT rule.
|
|
query := fmt.Sprintf(`
|
|
SELECT name FROM (
|
|
SELECT DISTINCT mi.%s AS name
|
|
FROM %s
|
|
%s
|
|
AND mi.%s IS NOT NULL
|
|
AND BTRIM(mi.%s) <> ''
|
|
AND LOWER(mi.%s) LIKE LOWER($%d)
|
|
) matches
|
|
ORDER BY LOWER(name) ASC
|
|
LIMIT %d
|
|
`, column, fromClause, browseFilterPrefix(whereClause), column, column, column, prefixIdx, limit+1)
|
|
return queryFacetSearchResults(ctx, pool, query, args, limit)
|
|
}
|
|
|
|
// searchDistinctPeopleByKindWithSource is the typeahead equivalent of
|
|
// listDistinctPeopleByKindWithSource; powers /api/v1/catalog/filters/search
|
|
// for facet=author and facet=narrator.
|
|
func searchDistinctPeopleByKindWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
kind models.PersonKind,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
prefix string,
|
|
limit int,
|
|
) ([]string, bool, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if limit <= 0 {
|
|
return []string{}, false, nil
|
|
}
|
|
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if empty {
|
|
return []string{}, false, nil
|
|
}
|
|
args = append(args, int(kind))
|
|
kindIdx := len(args)
|
|
args = append(args, prefix+"%")
|
|
prefixIdx := len(args)
|
|
// DISTINCT in an inline subquery so the outer ORDER BY can apply
|
|
// LOWER() without violating Postgres' "ORDER BY expressions must
|
|
// appear in select list" rule for SELECT DISTINCT.
|
|
query := fmt.Sprintf(`
|
|
SELECT name FROM (
|
|
SELECT DISTINCT p.name AS name
|
|
FROM %s
|
|
JOIN item_people ip ON ip.content_id = mi.content_id AND ip.kind = $%d
|
|
JOIN people p ON p.id = ip.person_id
|
|
%s
|
|
AND p.name IS NOT NULL
|
|
AND BTRIM(p.name) <> ''
|
|
AND LOWER(p.name) LIKE LOWER($%d)
|
|
) matches
|
|
ORDER BY LOWER(name) ASC
|
|
LIMIT %d
|
|
`, fromClause, kindIdx, browseFilterPrefix(whereClause), prefixIdx, limit+1)
|
|
return queryFacetSearchResults(ctx, pool, query, args, limit)
|
|
}
|
|
|
|
// searchDistinctAudiobookSeriesWithSource is the typeahead equivalent of
|
|
// listDistinctAudiobookSeriesWithSource.
|
|
func searchDistinctAudiobookSeriesWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
prefix string,
|
|
limit int,
|
|
) ([]string, bool, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if limit <= 0 {
|
|
return []string{}, false, nil
|
|
}
|
|
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if empty {
|
|
return []string{}, false, nil
|
|
}
|
|
args = append(args, prefix+"%")
|
|
prefixIdx := len(args)
|
|
query := fmt.Sprintf(`
|
|
SELECT name FROM (
|
|
SELECT DISTINCT BTRIM(s.series_name) AS name
|
|
FROM %s
|
|
JOIN %s s ON s.content_id = mi.content_id
|
|
%s
|
|
AND s.series_name IS NOT NULL
|
|
AND BTRIM(s.series_name) <> ''
|
|
AND LOWER(BTRIM(s.series_name)) LIKE LOWER($%d)
|
|
) names
|
|
ORDER BY LOWER(name) ASC
|
|
LIMIT %d
|
|
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), prefixIdx, limit+1)
|
|
return queryFacetSearchResults(ctx, pool, query, args, limit)
|
|
}
|
|
|
|
func bookSeriesTableForMediaScope(mediaScope string) string {
|
|
if mediaScope == "ebook" {
|
|
return "ebook_series"
|
|
}
|
|
return "audiobook_series"
|
|
}
|
|
|
|
// queryFacetSearchResults executes a facet search query that was built
|
|
// with LIMIT N+1 and returns the first N rows plus a hasMore flag
|
|
// indicating whether an additional row was present (i.e. the result set
|
|
// exceeded the requested limit).
|
|
func queryFacetSearchResults(ctx context.Context, pool *pgxpool.Pool, query string, args []any, limit int) ([]string, bool, error) {
|
|
rows, err := pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer rows.Close()
|
|
values := make([]string, 0, limit)
|
|
hasMore := false
|
|
for rows.Next() {
|
|
var value string
|
|
if err := rows.Scan(&value); err != nil {
|
|
return nil, false, err
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if len(values) >= limit {
|
|
hasMore = true
|
|
continue
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
return values, hasMore, nil
|
|
}
|
|
|
|
func listDistinctJSONBLanguageWithSource(
|
|
ctx context.Context,
|
|
pool *pgxpool.Pool,
|
|
column string,
|
|
filters BrowseFilters,
|
|
baseRelation string,
|
|
mediaScope string,
|
|
) ([]string, error) {
|
|
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
|
|
if earlyEmpty {
|
|
return nil, nil
|
|
}
|
|
mediaFileJoin := catalogMediaFileJoinConditionForScope(mediaScope, "mf", "mi")
|
|
|
|
// Migration 104 added STORED text[] generated columns derived from the
|
|
// JSONB tracks. Use them when available so this listing UNNESTs an
|
|
// indexed array instead of parsing JSONB per row
|
|
// (audit 2026-05-01 §2.5b).
|
|
arrayColumn := generatedLanguageColumnFor(column)
|
|
if arrayColumn != "" {
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT lang AS value
|
|
FROM %s
|
|
JOIN media_files mf ON %s
|
|
CROSS JOIN LATERAL UNNEST(mf.%s) AS lang
|
|
%s
|
|
AND mf.missing_since IS NULL
|
|
AND lang IS NOT NULL
|
|
AND lang <> ''
|
|
ORDER BY value ASC
|
|
LIMIT %d
|
|
`, fromClause, mediaFileJoin, arrayColumn, browseFilterPrefix(whereClause), catalogFacetMaxValues)
|
|
return queryDistinctStrings(ctx, pool, query, args)
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT DISTINCT LOWER(COALESCE(track->>'language', '')) AS value
|
|
FROM %s
|
|
JOIN media_files mf ON %s
|
|
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(mf.%s, '[]'::jsonb)) AS track
|
|
%s
|
|
AND mf.missing_since IS NULL
|
|
AND COALESCE(track->>'language', '') <> ''
|
|
ORDER BY value ASC
|
|
LIMIT %d
|
|
`, fromClause, mediaFileJoin, column, browseFilterPrefix(whereClause), catalogFacetMaxValues)
|
|
return queryDistinctStrings(ctx, pool, query, args)
|
|
}
|
|
|
|
// generatedLanguageColumnFor returns the migration-104 STORED text[] column
|
|
// that mirrors the given JSONB tracks column, or "" if none exists.
|
|
func generatedLanguageColumnFor(jsonbColumn string) string {
|
|
switch jsonbColumn {
|
|
case "audio_tracks":
|
|
return "audio_language_codes"
|
|
case "subtitle_tracks":
|
|
return "subtitle_language_codes"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func browseFilterPrefix(whereClause string) string {
|
|
if whereClause == "" {
|
|
return "WHERE TRUE"
|
|
}
|
|
return whereClause
|
|
}
|
|
|
|
func queryDistinctStrings(ctx context.Context, pool *pgxpool.Pool, query string, args []any) ([]string, error) {
|
|
rows, err := pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
values := make([]string, 0)
|
|
for rows.Next() {
|
|
var value string
|
|
if err := rows.Scan(&value); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// browseItemColumns returns the item columns prefixed with the given alias.
|
|
func browseItemColumns(alias string) string {
|
|
cols := []string{
|
|
"content_id", "type", "title", "sort_title", "original_title", "year", "genres",
|
|
"content_rating", "runtime", "overview", "tagline",
|
|
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
|
"imdb_id", "tmdb_id", "tvdb_id",
|
|
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
|
"metadata_s3_path", "metadata_etag", "season_count",
|
|
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date",
|
|
"show_status",
|
|
"matched_at", "episode_metadata_incomplete", "episode_metadata_last_checked_at", "status", "created_at", "updated_at",
|
|
}
|
|
prefixed := make([]string, len(cols))
|
|
for i, col := range cols {
|
|
if col == "last_air_date" {
|
|
prefixed[i] = effectiveLastAirDateExpr(alias)
|
|
continue
|
|
}
|
|
prefixed[i] = alias + "." + col
|
|
}
|
|
return strings.Join(prefixed, ", ")
|
|
}
|
|
|
|
// mangaCountColumns returns two index-backed correlated subqueries feeding the
|
|
// "X Volumes · X Chapters" poster chip: distinct volume tokens (many chapter
|
|
// rows can share one volume) and loose chapter rows without a volume token.
|
|
// They return 0 for non-manga rows (no matching manga_chapters), which the
|
|
// scan path nils out so only manga cards carry the counts. The subqueries are
|
|
// functionally dependent on alias.content_id (the media_items PK, which leads
|
|
// browseGroupByColumns), so they remain valid under the dedup GROUP BY without
|
|
// being listed there.
|
|
func mangaCountColumns(alias string) string {
|
|
return "(SELECT count(*) FROM manga_chapters mc WHERE mc.series_content_id = " + alias + ".content_id AND (mc.volume IS NULL OR mc.volume = '')) AS manga_chapter_count, " +
|
|
"(SELECT count(DISTINCT mc.volume) FROM manga_chapters mc WHERE mc.series_content_id = " + alias + ".content_id AND mc.volume IS NOT NULL AND mc.volume <> '') AS manga_volume_count"
|
|
}
|
|
|
|
// nullMangaCountColumns substitutes NULL placeholders for the manga count
|
|
// subqueries when the browse scope cannot contain manga series. Column names
|
|
// and order match mangaCountColumns so the scan path is unchanged.
|
|
func nullMangaCountColumns() string {
|
|
return "NULL::bigint AS manga_chapter_count, NULL::bigint AS manga_volume_count"
|
|
}
|
|
|
|
// browseScopeMayContainManga reports whether a browse with these filters could
|
|
// return type='manga' rows. An empty type filter (all types) or one that
|
|
// includes "manga" keeps the counts; any other explicit type filter rules
|
|
// manga out, letting the caller skip the count subqueries.
|
|
func browseScopeMayContainManga(filters BrowseFilters) bool {
|
|
if filters.Type == "" {
|
|
return true
|
|
}
|
|
for _, t := range strings.Split(filters.Type, ",") {
|
|
if strings.TrimSpace(t) == "manga" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MangaChapterExclusionWhere returns a WHERE predicate that hides manga CHAPTER
|
|
// items (type='ebook' rows linked into a type='manga' series via the
|
|
// manga_chapters table) from catalog listing surfaces — browse, section
|
|
// resolution, and search. Chapters are internal sub-units of a manga series and
|
|
// must never appear as standalone catalog items; only the series should.
|
|
//
|
|
// It is index-backed: manga_chapters.chapter_content_id is the table's primary
|
|
// key, so the anti-join is a cheap unique-index probe. The predicate is global
|
|
// and harmless for every other row: regular ebooks have no manga_chapters link,
|
|
// and non-ebook types never match either, so they all pass. It is redundant
|
|
// (but harmless) for type='manga' browse scopes, whose series rows are linked
|
|
// via series_content_id, not chapter_content_id.
|
|
//
|
|
// By-id fetch paths that legitimately resolve chapters — the ebook reader,
|
|
// continue-reading (ebook_reader_progress / watch-progress), and the series
|
|
// detail chapter list (mangaChaptersQuery) — use separate queries and must NOT
|
|
// call this.
|
|
func MangaChapterExclusionWhere(alias string) string {
|
|
return "NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = " + alias + ".content_id)"
|
|
}
|
|
|
|
// browseGroupByColumns returns the columns needed for GROUP BY when joining
|
|
// with the junction table.
|
|
func browseGroupByColumns(alias string) string {
|
|
cols := []string{
|
|
"content_id", "type", "title", "sort_title", "original_title", "year", "genres",
|
|
"content_rating", "runtime", "overview", "tagline",
|
|
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
|
"imdb_id", "tmdb_id", "tvdb_id",
|
|
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
|
"metadata_s3_path", "metadata_etag", "season_count",
|
|
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date",
|
|
"show_status",
|
|
"matched_at", "episode_metadata_incomplete", "episode_metadata_last_checked_at", "status", "created_at", "updated_at",
|
|
}
|
|
prefixed := make([]string, len(cols))
|
|
for i, col := range cols {
|
|
prefixed[i] = alias + "." + col
|
|
}
|
|
return strings.Join(prefixed, ", ")
|
|
}
|
|
|
|
// likePrefixPattern returns prefix lowercased and LIKE-escaped (%, _, \) with
|
|
// a trailing % so it forms a prefix-anchored LIKE pattern. Thin wrapper over
|
|
// escapePrefixForLike so any future special-character fix applies to both
|
|
// the browse path and the query_executor preview path.
|
|
func likePrefixPattern(prefix string) string {
|
|
return escapePrefixForLike(prefix) + "%"
|
|
}
|
|
|
|
// scanBrowseItems scans rows returned by the browse query, which include an
|
|
// extra added_at column appended after the standard item columns.
|
|
func scanBrowseItems(rows pgx.Rows) ([]*models.MediaItem, error) {
|
|
var items []*models.MediaItem
|
|
for rows.Next() {
|
|
var item models.MediaItem
|
|
err := rows.Scan(
|
|
&item.ContentID,
|
|
&item.Type,
|
|
&item.Title,
|
|
&item.SortTitle,
|
|
&item.OriginalTitle,
|
|
&item.Year,
|
|
&item.Genres,
|
|
&item.ContentRating,
|
|
&item.Runtime,
|
|
&item.Overview,
|
|
&item.Tagline,
|
|
&item.RatingIMDB,
|
|
&item.RatingTMDB,
|
|
&item.RatingRTCritic,
|
|
&item.RatingRTAudience,
|
|
&item.ImdbID,
|
|
&item.TmdbID,
|
|
&item.TvdbID,
|
|
&item.PosterPath,
|
|
&item.PosterThumbhash,
|
|
&item.BackdropPath,
|
|
&item.BackdropThumbhash,
|
|
&item.LogoPath,
|
|
&item.MetadataS3Path,
|
|
&item.MetadataEtag,
|
|
&item.SeasonCount,
|
|
&item.Studios,
|
|
&item.Networks,
|
|
&item.Countries,
|
|
&item.Keywords,
|
|
&item.OriginalLanguage,
|
|
&item.ReleaseDate,
|
|
&item.FirstAirDate,
|
|
&item.LastAirDate,
|
|
&item.ShowStatus,
|
|
&item.MatchedAt,
|
|
&item.EpisodeMetadataIncomplete,
|
|
&item.EpisodeMetadataLastCheckedAt,
|
|
&item.Status,
|
|
&item.CreatedAt,
|
|
&item.UpdatedAt,
|
|
&item.MangaChapterCount,
|
|
&item.MangaVolumeCount,
|
|
&item.AddedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning browse item row: %w", err)
|
|
}
|
|
// The manga count subqueries return 0 for non-manga rows; drop them so
|
|
// only manga cards carry the counts (movies/series stay clean).
|
|
if item.Type != "manga" {
|
|
item.MangaChapterCount = nil
|
|
item.MangaVolumeCount = nil
|
|
}
|
|
items = append(items, &item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating browse item rows: %w", err)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// buildOrderBy constructs the ORDER BY clause from sort and order parameters.
|
|
func buildOrderBy(sort, order string) string {
|
|
clause, _ := buildOrderByPlan(sort, order, nil, 0, false, false)
|
|
return clause
|
|
}
|
|
|
|
func buildOrderByPlan(sort, order string, snapshot *time.Time, argIdx int, singleLibraryNoDedup bool, movieOnly bool) (string, []any) {
|
|
direction := "DESC"
|
|
if strings.EqualFold(order, "asc") {
|
|
direction = "ASC"
|
|
}
|
|
|
|
nullsClause := ""
|
|
if direction == "DESC" {
|
|
nullsClause = " NULLS LAST"
|
|
}
|
|
|
|
// Every case includes mi.content_id as a final tiebreaker so that
|
|
// OFFSET-based pagination is deterministic when many rows share the
|
|
// same sort value (e.g. same IMDB rating or NULL).
|
|
switch sort {
|
|
case "random":
|
|
if snapshot != nil && argIdx > 0 {
|
|
return fmt.Sprintf("ORDER BY md5(mi.content_id || $%d::text) ASC, mi.content_id ASC", argIdx), []any{snapshot.UTC().Format(time.RFC3339Nano)}
|
|
}
|
|
return "ORDER BY RANDOM()", nil
|
|
case "release_date":
|
|
if movieOnly {
|
|
return fmt.Sprintf("ORDER BY mi.release_date %s%s, mi.content_id ASC", direction, nullsClause), nil
|
|
}
|
|
return fmt.Sprintf(
|
|
"ORDER BY COALESCE(mi.release_date::text, NULLIF(BTRIM(mi.first_air_date), '')) %s%s, mi.content_id ASC",
|
|
direction,
|
|
nullsClause,
|
|
), nil
|
|
case "last_air_date":
|
|
return fmt.Sprintf(
|
|
"ORDER BY %s %s%s, mi.content_id ASC",
|
|
effectiveLastAirDateExpr("mi"),
|
|
direction,
|
|
nullsClause,
|
|
), nil
|
|
case "rating_imdb":
|
|
return fmt.Sprintf("ORDER BY mi.rating_imdb %s%s, mi.content_id ASC", direction, nullsClause), nil
|
|
case "rating_tmdb":
|
|
return fmt.Sprintf("ORDER BY mi.rating_tmdb %s%s, mi.content_id ASC", direction, nullsClause), nil
|
|
case "year":
|
|
return fmt.Sprintf("ORDER BY mi.year %s, mi.content_id ASC", direction), nil
|
|
case "title", "sort_title":
|
|
return fmt.Sprintf(
|
|
"ORDER BY LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) %s, LOWER(mi.title) %s, mi.content_id ASC",
|
|
direction,
|
|
direction,
|
|
), nil
|
|
case "recently_added":
|
|
// Without GROUP BY (single-library filter), reference mil.first_seen_at
|
|
// directly so the planner can drive the order from
|
|
// idx_item_libraries_folder_seen_content instead of materializing
|
|
// every library row to aggregate.
|
|
if singleLibraryNoDedup {
|
|
return fmt.Sprintf("ORDER BY mil.first_seen_at %s, mi.content_id ASC", direction), nil
|
|
}
|
|
return fmt.Sprintf("ORDER BY MIN(mil.first_seen_at) %s, mi.content_id ASC", direction), nil
|
|
case "created_at":
|
|
return fmt.Sprintf("ORDER BY mi.created_at %s, mi.content_id ASC", direction), nil
|
|
default:
|
|
return fmt.Sprintf("ORDER BY mi.created_at %s, mi.content_id ASC", direction), nil
|
|
}
|
|
}
|
|
|
|
func browseFiltersAreMovieOnly(filters BrowseFilters) bool {
|
|
types := splitTypes(filters.Type)
|
|
return len(types) == 1 && types[0] == "movie"
|
|
}
|
|
|
|
// ParseContentRatings splits a comma-separated content rating string into
|
|
// a slice. Empty input returns nil.
|
|
func ParseContentRatings(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
ratings := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
ratings = append(ratings, trimmed)
|
|
}
|
|
}
|
|
if len(ratings) == 0 {
|
|
return nil
|
|
}
|
|
return ratings
|
|
}
|
|
|
|
// ParseIntParam parses a string as int, returning 0 if empty or invalid.
|
|
func ParseIntParam(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
v, _ := strconv.Atoi(s)
|
|
return v
|
|
}
|
|
|
|
// ParseInt64Param parses a string to int64, returning 0 if empty or invalid.
|
|
func ParseInt64Param(s string) int64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
v, _ := strconv.ParseInt(s, 10, 64)
|
|
return v
|
|
}
|
|
|
|
// splitTypes splits a comma-separated type string into trimmed, non-empty parts.
|
|
func splitTypes(s string) []string {
|
|
parts := strings.Split(s, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
result = append(result, t)
|
|
}
|
|
}
|
|
return result
|
|
}
|