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>
This commit is contained in:
committed by
GitHub
co-authored by
Silo Server Developer
Claude Opus 4.8
parent
63d608f6fc
commit
c4cbcddeae
@@ -59,5 +59,11 @@ node_modules/
|
||||
logs/
|
||||
.playwright-mcp/
|
||||
demo/
|
||||
docker-compose.dev.yml
|
||||
# Local-only deployment override (unpublishes bundled redis/postgres host
|
||||
# ports via `ports: !override []`). Kept out of git so a rebase from main
|
||||
# never disturbs it and it never lands in a PR; its absence once exposed
|
||||
# Redis to the internet, so it must persist on the box.
|
||||
docker-compose.override.yml
|
||||
.playwright-cli/
|
||||
output/
|
||||
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/database"
|
||||
"github.com/Silo-Server/silo-server/internal/ebooks"
|
||||
"github.com/Silo-Server/silo-server/internal/manga"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/imagecache"
|
||||
@@ -1058,6 +1059,7 @@ func main() {
|
||||
var episodeRepo *catalog.EpisodeRepository
|
||||
var audiobookEnricher *audiobooks.Enricher
|
||||
var ebookEnricher *ebooks.Enricher
|
||||
var mangaEnricher *manga.Enricher
|
||||
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
|
||||
chainRepo := metadata.NewChainRepository(deps.DB)
|
||||
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
|
||||
@@ -1149,6 +1151,14 @@ func main() {
|
||||
)
|
||||
audiobookEnricher.SetLiteraryWorkLinker(literaryWorkService)
|
||||
ebookEnricher.SetLiteraryWorkLinker(literaryWorkService)
|
||||
mangaEnricher = manga.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
|
||||
// Always wire the image resolver so plugin-prefixed URLs (e.g.
|
||||
// metadb://) can be resolved to presigned HTTP URLs in API responses.
|
||||
@@ -1177,6 +1187,9 @@ func main() {
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
if mangaEnricher != nil {
|
||||
mangaEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
@@ -1784,6 +1797,9 @@ func main() {
|
||||
if ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if mangaEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncMangaMetadataTask(mangaEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.7.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
|
||||
@@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.7.0 h1:VbD7qXjwKYOdajFBYQq1eS2fgHDWyShZBYh0kxJly6U=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.7.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
|
||||
@@ -87,6 +87,37 @@ func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r
|
||||
writeJSON(w, http.StatusOK, detail.Versions)
|
||||
}
|
||||
|
||||
// HandleGetMangaFiles returns the local file listing for a manga series (the
|
||||
// series "View Details" dialog): folder paths plus per-chapter file rows.
|
||||
// Folder and file paths are stripped for viewers without file-path visibility,
|
||||
// matching the item-versions policy; file names and sizes remain.
|
||||
func (h *CatalogResourceHandler) HandleGetMangaFiles(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.items.detailSvc.GetMangaChapterFiles(r.Context(), id, h.items.accessFilter(r))
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get manga files")
|
||||
return
|
||||
}
|
||||
|
||||
if !h.items.requestCanViewFilePaths(r) {
|
||||
files.FolderPaths = nil
|
||||
for i := range files.Files {
|
||||
files.Files[i].FilePath = ""
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, files)
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) {
|
||||
filter := h.items.accessFilter(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
@@ -1046,15 +1046,25 @@ func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookR
|
||||
if s == nil || s.pool == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
// A routine autosave (e.g. reopening a finished book) must not silently drop
|
||||
// a "finished" item below the threshold and un-mark it read; once finished,
|
||||
// progress only moves on an explicit unread (which deletes the row). Below
|
||||
// the threshold, progress tracks freely. Manga chapter ✓ marks ride on this
|
||||
// same row, so the guard protects them too.
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO ebook_reader_progress
|
||||
(user_id, profile_id, content_id, file_id, location, progress, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
|
||||
file_id = EXCLUDED.file_id,
|
||||
location = EXCLUDED.location,
|
||||
progress = EXCLUDED.progress,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
progress = CASE
|
||||
WHEN ebook_reader_progress.progress >= %[1]v AND EXCLUDED.progress < %[1]v
|
||||
THEN ebook_reader_progress.progress
|
||||
ELSE EXCLUDED.progress
|
||||
END,
|
||||
updated_at = EXCLUDED.updated_at`, models.EbookFinishedProgressThreshold)
|
||||
if _, err := s.pool.Exec(ctx, query,
|
||||
progress.UserID,
|
||||
progress.ProfileID,
|
||||
progress.ContentID,
|
||||
|
||||
@@ -223,6 +223,8 @@ type itemListResponse struct {
|
||||
ReleaseDate *string `json:"release_date,omitempty"`
|
||||
LastAirDate *string `json:"last_air_date,omitempty"`
|
||||
AddedAt *time.Time `json:"added_at,omitempty"`
|
||||
MangaChapterCount *int `json:"manga_chapter_count,omitempty"`
|
||||
MangaVolumeCount *int `json:"manga_volume_count,omitempty"`
|
||||
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
|
||||
SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"`
|
||||
UserState *itemUserStateResponse `json:"user_state,omitempty"`
|
||||
@@ -657,6 +659,8 @@ func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *mode
|
||||
}
|
||||
|
||||
resp.AddedAt = item.AddedAt
|
||||
resp.MangaChapterCount = item.MangaChapterCount
|
||||
resp.MangaVolumeCount = item.MangaVolumeCount
|
||||
resp.ReleaseDate = item.ReleaseDate
|
||||
resp.LastAirDate = item.LastAirDate
|
||||
resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card")
|
||||
|
||||
@@ -2102,6 +2102,8 @@ func metadataContentLevelsForLibraryType(libraryType string) []string {
|
||||
return []string{"audiobook"}
|
||||
case "ebooks", "ebook":
|
||||
return []string{"ebook"}
|
||||
case "manga":
|
||||
return []string{"manga"}
|
||||
case "mixed":
|
||||
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
|
||||
default:
|
||||
|
||||
@@ -14,6 +14,7 @@ func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
|
||||
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
|
||||
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
|
||||
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
|
||||
{name: "manga", libraryType: "manga", want: []string{"manga"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -1179,6 +1179,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
|
||||
userStates := h.listSectionItemUserStates(r, allItems)
|
||||
imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems)
|
||||
episodeMeta := h.listSectionEpisodeItemMeta(r.Context(), withItems, requestAccessFilter(r))
|
||||
mangaChapterMeta := h.listSectionMangaChapterItemMeta(r.Context(), allItems)
|
||||
for _, s := range withItems {
|
||||
items := make([]sectionItemResponse, 0, len(s.Items))
|
||||
for _, item := range s.Items {
|
||||
@@ -1193,6 +1194,17 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
|
||||
meta = &value
|
||||
}
|
||||
}
|
||||
// Manga chapters carry their series linkage on top of whatever
|
||||
// meta (e.g. reading progress) the section already resolved, so
|
||||
// continue-reading cards can head to the series.
|
||||
if value, ok := mangaChapterMeta[item.ContentID]; ok {
|
||||
if meta == nil {
|
||||
empty := sections.SectionItemMeta{}
|
||||
meta = &empty
|
||||
}
|
||||
meta.SeriesID = value.SeriesID
|
||||
meta.SeriesTitle = value.SeriesTitle
|
||||
}
|
||||
imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID}
|
||||
items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey]))
|
||||
}
|
||||
@@ -1211,6 +1223,33 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
|
||||
return resp
|
||||
}
|
||||
|
||||
// listSectionMangaChapterItemMeta resolves series linkage for every manga
|
||||
// chapter (type='ebook' linked via manga_chapters) among the section items.
|
||||
// Non-chapter ebooks simply get no entry.
|
||||
func (h *SectionHandler) listSectionMangaChapterItemMeta(ctx context.Context, items []*models.MediaItem) map[string]sections.SectionItemMeta {
|
||||
if h == nil || h.fetcher == nil {
|
||||
return map[string]sections.SectionItemMeta{}
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
if item == nil || item.Type != "ebook" || strings.TrimSpace(item.ContentID) == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.ContentID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
ids = append(ids, item.ContentID)
|
||||
}
|
||||
meta, err := h.fetcher.FetchMangaChapterSeriesMeta(ctx, ids)
|
||||
if err != nil {
|
||||
slog.Warn("loading section manga chapter metadata", "error", err)
|
||||
return map[string]sections.SectionItemMeta{}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withItems []sections.SectionWithItems, filter catalog.AccessFilter) map[string]sections.SectionItemMeta {
|
||||
if h == nil || h.episodeFetcher == nil {
|
||||
return map[string]sections.SectionItemMeta{}
|
||||
|
||||
@@ -1701,6 +1701,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
|
||||
r.Get("/catalog/items/{id}/episodes", catalogResourceHandler.HandleGetItemEpisodes)
|
||||
r.Get("/catalog/items/{id}/versions", catalogResourceHandler.HandleGetItemVersions)
|
||||
r.Get("/catalog/items/{id}/manga-files", catalogResourceHandler.HandleGetMangaFiles)
|
||||
r.Get("/catalog/series/{id}/seasons", catalogResourceHandler.HandleGetSeasons)
|
||||
r.Get("/catalog/series/{id}/seasons/{num}", catalogResourceHandler.HandleGetSeason)
|
||||
r.Get("/catalog/series/{id}/seasons/{num}/episodes", catalogResourceHandler.HandleGetEpisodes)
|
||||
|
||||
@@ -344,6 +344,10 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
|
||||
|
||||
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)
|
||||
@@ -372,7 +376,14 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
|
||||
orderBy, orderArgs := buildOrderByPlan(filters.Sort, filters.Order, filters.SnapshotAt, argIdx, singleLibraryNoDedup, browseFiltersAreMovieOnly(filters))
|
||||
argIdx += len(orderArgs)
|
||||
|
||||
selectClause := browseItemColumns("mi")
|
||||
// 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:
|
||||
@@ -1123,6 +1134,63 @@ func browseItemColumns(alias string) string {
|
||||
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 {
|
||||
@@ -1200,11 +1268,19 @@ func scanBrowseItems(rows pgx.Rows) ([]*models.MediaItem, error) {
|
||||
&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 {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMangaCountColumns pins the browse-card manga count contract: two
|
||||
// index-backed correlated subqueries over manga_chapters, scoped to the series
|
||||
// content ID and aliased so the scan paths can read them positionally. The
|
||||
// card chip reads "X Volumes · X Chapters", so manga_volume_count must count
|
||||
// DISTINCT volume tokens (many chapter rows can share one volume) and
|
||||
// manga_chapter_count must count only loose rows without a volume token.
|
||||
func TestMangaCountColumns(t *testing.T) {
|
||||
cols := mangaCountColumns("mi")
|
||||
|
||||
for _, want := range []string{
|
||||
"FROM manga_chapters mc",
|
||||
"mc.series_content_id = mi.content_id",
|
||||
"count(DISTINCT mc.volume)",
|
||||
"mc.volume IS NOT NULL AND mc.volume <> ''",
|
||||
"AS manga_volume_count",
|
||||
"(mc.volume IS NULL OR mc.volume = '')",
|
||||
"AS manga_chapter_count",
|
||||
} {
|
||||
if !strings.Contains(cols, want) {
|
||||
t.Fatalf("manga count columns missing %q\ngot: %s", want, cols)
|
||||
}
|
||||
}
|
||||
|
||||
// Both counts must be present (two correlated subqueries).
|
||||
if got := strings.Count(cols, "FROM manga_chapters mc"); got != 2 {
|
||||
t.Fatalf("expected 2 manga count subqueries, got %d\ngot: %s", got, cols)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowseScopeMayContainManga pins the gating that lets browse skip the two
|
||||
// manga count subqueries when the scope cannot return manga rows. An empty type
|
||||
// filter (all types) or one that includes "manga" keeps them; any other
|
||||
// explicit type filter rules manga out.
|
||||
func TestBrowseScopeMayContainManga(t *testing.T) {
|
||||
cases := []struct {
|
||||
typeFilter string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"manga", true},
|
||||
{"movie,manga", true},
|
||||
{" manga ", true},
|
||||
{"movie", false},
|
||||
{"movie,series,episode", false},
|
||||
{"ebook", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := browseScopeMayContainManga(BrowseFilters{Type: c.typeFilter}); got != c.want {
|
||||
t.Fatalf("browseScopeMayContainManga(%q) = %v, want %v", c.typeFilter, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nullMangaCountColumns must keep the exact column names and order of
|
||||
// mangaCountColumns so the shared scan path is unchanged when the subqueries
|
||||
// are skipped.
|
||||
func TestNullMangaCountColumnsMatchScanContract(t *testing.T) {
|
||||
null := nullMangaCountColumns()
|
||||
for _, want := range []string{"AS manga_chapter_count", "AS manga_volume_count"} {
|
||||
if !strings.Contains(null, want) {
|
||||
t.Fatalf("null manga count columns missing %q\ngot: %s", want, null)
|
||||
}
|
||||
}
|
||||
if strings.Contains(null, "FROM manga_chapters") {
|
||||
t.Fatalf("null manga count columns must not run subqueries\ngot: %s", null)
|
||||
}
|
||||
if a, b := strings.Index(null, "manga_chapter_count"), strings.Index(null, "manga_volume_count"); a > b {
|
||||
t.Fatalf("column order must match mangaCountColumns (chapter then volume)\ngot: %s", null)
|
||||
}
|
||||
}
|
||||
|
||||
// The library page browses through the catalog query preview path
|
||||
// (previewQuerySource -> QueryExecutor.PreviewPage), not BrowseRepository, so
|
||||
// the preview-page SELECT must carry the same manga count columns or manga
|
||||
// cards in /library/{id}?tab=library render without the Vols/Ch chip.
|
||||
func TestPreviewPageSQLIncludesMangaCounts(t *testing.T) {
|
||||
sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL(
|
||||
QueryDefinition{MediaScope: "manga"},
|
||||
AccessFilter{},
|
||||
20, 0, true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPreviewPageSQL error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"AS manga_chapter_count", "AS manga_volume_count"} {
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("preview-page SQL missing %q\ngot: %s", want, sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,9 @@ func TestParseCatalogMediaScope_AllowsEbook(t *testing.T) {
|
||||
t.Fatalf("expected ebook media scope, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCatalogMediaScope_AllowsManga(t *testing.T) {
|
||||
if got := parseCatalogMediaScope(" manga "); got != "manga" {
|
||||
t.Fatalf("expected manga media scope, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,7 +1090,7 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
|
||||
|
||||
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', or 'video'", ErrInvalidCatalogRequest)
|
||||
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)
|
||||
|
||||
@@ -54,6 +54,21 @@ func TestValidateCatalogQueryRequest_AllowsEbookMediaScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogQueryRequest_AllowsMangaMediaScope(t *testing.T) {
|
||||
req := CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
Query: QueryDefinition{
|
||||
MediaScope: "manga",
|
||||
Match: "all",
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := validateCatalogQueryRequest(req, true); err != nil {
|
||||
t.Fatalf("expected manga media scope to be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) {
|
||||
req := CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
|
||||
+140
-3
@@ -10,6 +10,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
@@ -189,6 +190,9 @@ type ItemDetail struct {
|
||||
|
||||
// Ebook-specific detail. Present only when Type == "ebook".
|
||||
Ebook *EbookDetailExtension `json:"ebook,omitempty"`
|
||||
|
||||
// Manga-specific detail. Present only when Type == "manga".
|
||||
Manga *MangaDetailExtension `json:"manga,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookDetailExtension struct {
|
||||
@@ -240,6 +244,29 @@ type EbookDetailExtension struct {
|
||||
Related AudiobookRelatedContent `json:"related"`
|
||||
}
|
||||
|
||||
// MangaDetailExtension is the manga-series detail payload. A manga series item
|
||||
// (media_items.type='manga') owns a set of readable chapter items
|
||||
// (media_items.type='ebook') linked via the manga_chapters table.
|
||||
type MangaDetailExtension struct {
|
||||
Chapters []MangaChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
// MangaChapter is one chapter of a manga series, ordered by chapter index.
|
||||
type MangaChapter struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
ChapterIndex *float64 `json:"chapter_index,omitempty"`
|
||||
Volume string `json:"volume,omitempty"`
|
||||
// Read is true when the current viewer has finished this chapter, mirroring
|
||||
// ebook read state: ebook_reader_progress.progress >= the finished threshold.
|
||||
Read bool `json:"read"`
|
||||
// Progress is the viewer's reading position as a 0..1 fraction, present
|
||||
// only when a progress row exists. The row progress bar uses it.
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
// PosterURL is the chapter's extracted cover (presigned), for row thumbnails.
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
}
|
||||
|
||||
// ItemUserState is per-profile viewer state included in item detail responses.
|
||||
type ItemUserState struct {
|
||||
Played bool `json:"played"`
|
||||
@@ -992,6 +1019,16 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
}
|
||||
if item.Type == "ebook" {
|
||||
detail.Ebook = s.buildEbookExtension(ctx, item, crewCredits, filter)
|
||||
// A manga chapter is an ebook item linked to its series; exposing the
|
||||
// linkage lets the reader navigate back/next within the series and
|
||||
// continue-reading cards show the series instead of the chapter.
|
||||
if seriesID, seriesTitle, ok := s.lookupMangaSeriesForChapter(ctx, item.ContentID); ok {
|
||||
detail.SeriesID = seriesID
|
||||
detail.SeriesTitle = seriesTitle
|
||||
}
|
||||
}
|
||||
if item.Type == "manga" {
|
||||
detail.Manga = s.buildMangaExtension(ctx, item, filter)
|
||||
}
|
||||
|
||||
// Series folder paths from confirmed claims when available, otherwise from
|
||||
@@ -1157,17 +1194,117 @@ func (s *DetailService) buildEbookExtension(
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
// The three related-content lookups are independent read-only queries; run
|
||||
// them concurrently so detail latency is the slowest one, not their sum
|
||||
// (mirrors buildAudiobookExtension).
|
||||
var (
|
||||
series *AudiobookSeriesGroup
|
||||
alsoByAuthor []AudiobookRelatedItem
|
||||
similar []AudiobookRelatedItem
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); series = s.fetchEbookSeries(ctx, item.ContentID, filter) }()
|
||||
go func() { defer wg.Done(); alsoByAuthor = s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter) }()
|
||||
go func() { defer wg.Done(); similar = s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter) }()
|
||||
wg.Wait()
|
||||
|
||||
return &EbookDetailExtension{
|
||||
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
|
||||
Publisher: firstNonEmptyString(item.Studios),
|
||||
Series: s.fetchEbookSeries(ctx, item.ContentID, filter),
|
||||
Series: series,
|
||||
Related: AudiobookRelatedContent{
|
||||
AlsoByAuthor: s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter),
|
||||
Similar: s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter),
|
||||
AlsoByAuthor: alsoByAuthor,
|
||||
Similar: similar,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildMangaExtension assembles the manga-series detail payload by listing the
|
||||
// series' chapters (ebook items linked via manga_chapters).
|
||||
func (s *DetailService) buildMangaExtension(ctx context.Context, item *models.MediaItem, filter AccessFilter) *MangaDetailExtension {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &MangaDetailExtension{
|
||||
Chapters: s.fetchMangaChapters(ctx, item.ContentID, filter),
|
||||
}
|
||||
}
|
||||
|
||||
// mangaChaptersQuery is the SQL listing a manga series' chapters in reading
|
||||
// order. Chapters with a parsed index sort first (ascending); those without
|
||||
// fall back to sort_title. Kept as a package var so the ordering contract can
|
||||
// be asserted without a database.
|
||||
//
|
||||
// Manga chapters are ebook items, so per-chapter read state mirrors the ebook
|
||||
// surfaces: a chapter is read when the current viewer's ebook_reader_progress
|
||||
// row has progress >= the finished threshold. The LEFT JOIN is scoped by the
|
||||
// viewer's user_id + profile_id ($2/$3) and yields false when no row exists.
|
||||
var mangaChaptersQuery = fmt.Sprintf(`
|
||||
SELECT m.content_id, m.title, mc.chapter_index, mc.volume,
|
||||
COALESCE(erp.progress >= %s, false) AS read,
|
||||
erp.progress::double precision,
|
||||
COALESCE(m.poster_path, '') AS poster_path
|
||||
FROM manga_chapters mc
|
||||
JOIN media_items m ON m.content_id = mc.chapter_content_id
|
||||
LEFT JOIN ebook_reader_progress erp
|
||||
ON erp.content_id = mc.chapter_content_id
|
||||
AND erp.user_id = $2
|
||||
AND erp.profile_id = $3
|
||||
WHERE mc.series_content_id = $1
|
||||
ORDER BY mc.chapter_index NULLS LAST, m.sort_title
|
||||
`, EbookFinishedProgressThresholdSQL)
|
||||
|
||||
// fetchMangaChapters returns the ordered chapters for a manga series. It never
|
||||
// returns nil so the JSON payload always carries a (possibly empty) array. The
|
||||
// access filter supplies the viewer (user_id/profile_id) used to resolve each
|
||||
// chapter's per-viewer read state.
|
||||
func (s *DetailService) fetchMangaChapters(ctx context.Context, seriesContentID string, filter AccessFilter) []MangaChapter {
|
||||
chapters := make([]MangaChapter, 0, 16)
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return chapters
|
||||
}
|
||||
rows, err := s.itemRepo.pool.Query(ctx, mangaChaptersQuery, seriesContentID, filter.UserID, filter.ProfileID)
|
||||
if err != nil {
|
||||
return chapters
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
posterPaths := make([]string, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ch MangaChapter
|
||||
index *float64
|
||||
volume *string
|
||||
progress *float64
|
||||
posterPath string
|
||||
)
|
||||
if err := rows.Scan(&ch.ContentID, &ch.Title, &index, &volume, &ch.Read, &progress, &posterPath); err != nil {
|
||||
return chapters
|
||||
}
|
||||
ch.ChapterIndex = index
|
||||
if volume != nil {
|
||||
ch.Volume = *volume
|
||||
}
|
||||
ch.Progress = progress
|
||||
ch.PosterURL = posterPath // raw path; resolved in one batch below
|
||||
if posterPath != "" {
|
||||
posterPaths = append(posterPaths, posterPath)
|
||||
}
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("manga chapters: row iteration error", "series", seriesContentID, "error", err)
|
||||
}
|
||||
// Presign every chapter poster in one batch rather than per chapter — a
|
||||
// long-running series has hundreds of chapters.
|
||||
resolved := s.PresignImageURLs(ctx, posterPaths, "poster", "")
|
||||
for i := range chapters {
|
||||
chapters[i].PosterURL = resolved[chapters[i].PosterURL]
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
|
||||
out := make([]AudiobookPerson, 0)
|
||||
for _, credit := range crew {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMangaChaptersQueryOrdering pins the manga chapter listing contract: join
|
||||
// manga_chapters to media_items on the chapter content ID, scope to the series,
|
||||
// and order by chapter_index (NULLs last) then sort_title. A wrong ORDER BY
|
||||
// would surface chapters out of reading order in the series detail.
|
||||
func TestMangaChaptersQueryOrdering(t *testing.T) {
|
||||
q := strings.Join(strings.Fields(mangaChaptersQuery), " ")
|
||||
|
||||
for _, want := range []string{
|
||||
"FROM manga_chapters mc",
|
||||
"JOIN media_items m ON m.content_id = mc.chapter_content_id",
|
||||
"WHERE mc.series_content_id = $1",
|
||||
"ORDER BY mc.chapter_index NULLS LAST, m.sort_title",
|
||||
// Per-chapter read state: viewer-scoped LEFT JOIN onto ebook progress.
|
||||
"LEFT JOIN ebook_reader_progress erp",
|
||||
"AND erp.user_id = $2",
|
||||
"AND erp.profile_id = $3",
|
||||
"AS read",
|
||||
} {
|
||||
if !strings.Contains(q, want) {
|
||||
t.Fatalf("manga chapters query missing %q\nquery: %s", want, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMangaChaptersNilSafe asserts the helper never returns nil (the JSON
|
||||
// payload must always carry an array) and tolerates an unconfigured pool.
|
||||
func TestFetchMangaChaptersNilSafe(t *testing.T) {
|
||||
var s *DetailService
|
||||
if got := s.fetchMangaChapters(context.Background(), "series-1", AccessFilter{}); got == nil {
|
||||
t.Fatal("nil receiver should yield an empty slice, not nil")
|
||||
}
|
||||
|
||||
s = &DetailService{}
|
||||
got := s.fetchMangaChapters(context.Background(), "series-1", AccessFilter{})
|
||||
if got == nil {
|
||||
t.Fatal("unconfigured pool should yield an empty slice, not nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected no chapters without a pool, got %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ func buildRatingThresholdQuery(f RatingFilter) (string, []any) {
|
||||
|
||||
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, MangaChapterExclusionWhere("mi"))
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM media_items mi WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
|
||||
qualifiedItemColumns("mi"),
|
||||
@@ -134,6 +136,8 @@ func buildUnplayedHighRatedQuery(f UnplayedFilter) (string, []any) {
|
||||
|
||||
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, MangaChapterExclusionWhere("mi"))
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM media_items mi WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
|
||||
qualifiedItemColumns("mi"),
|
||||
@@ -226,6 +230,8 @@ func buildForgottenFavoritesQuery(f ForgottenFavoritesFilter) (string, []any) {
|
||||
|
||||
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, MangaChapterExclusionWhere("mi"))
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM media_items mi WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
|
||||
qualifiedItemColumns("mi"),
|
||||
|
||||
@@ -233,6 +233,11 @@ func buildBrowseFavoritesPlan(f BrowseFavoritesFilters) (browseFavoritesPlan, er
|
||||
|
||||
applyAccessFilter("mi", AccessFilter{MaxContentRating: f.MaxContentRating, ExcludedMediaTypes: f.ExcludedMediaTypes}, &conditions, &args, &argIdx)
|
||||
|
||||
// Manga chapters (type='ebook' rows linked into a manga series) are internal
|
||||
// sub-units and must never surface as standalone cards, matching the
|
||||
// exclusion applied across browse/search/discovery/sections.
|
||||
conditions = append(conditions, MangaChapterExclusionWhere("mi"))
|
||||
|
||||
orderBy := buildBrowseFavoritesOrderBy(f.SortField, f.SortOrder)
|
||||
|
||||
return browseFavoritesPlan{
|
||||
|
||||
+98
-104
@@ -255,61 +255,67 @@ func scanItem(row pgx.Row) (*models.MediaItem, error) {
|
||||
}
|
||||
|
||||
// scanItems scans multiple rows into a []*models.MediaItem slice.
|
||||
// listItemScanDests returns the scan destinations matching
|
||||
// qualifiedListItemColumns, in column order. Every scan over that select list
|
||||
// must use this so the column list and destinations cannot drift apart.
|
||||
func listItemScanDests(item *models.MediaItem) []any {
|
||||
return []any{
|
||||
&item.ContentID,
|
||||
&item.Type,
|
||||
&item.Title,
|
||||
&item.SortTitle,
|
||||
&item.DefaultMetadataLanguage,
|
||||
&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.PosterSourcePath,
|
||||
&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.AirTime,
|
||||
&item.AirTimezone,
|
||||
&item.ShowStatus,
|
||||
&item.MatchedAt,
|
||||
&item.LastRefreshed,
|
||||
&item.RefreshFailures,
|
||||
&item.EpisodeMetadataIncomplete,
|
||||
&item.EpisodeMetadataLastCheckedAt,
|
||||
&item.LockedFields,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func scanItems(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.DefaultMetadataLanguage,
|
||||
&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.PosterSourcePath,
|
||||
&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.AirTime,
|
||||
&item.AirTimezone,
|
||||
&item.ShowStatus,
|
||||
&item.MatchedAt,
|
||||
&item.LastRefreshed,
|
||||
&item.RefreshFailures,
|
||||
&item.EpisodeMetadataIncomplete,
|
||||
&item.EpisodeMetadataLastCheckedAt,
|
||||
&item.LockedFields,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err := rows.Scan(listItemScanDests(&item)...); err != nil {
|
||||
return nil, fmt.Errorf("scanning media item row: %w", err)
|
||||
}
|
||||
items = append(items, &item)
|
||||
@@ -320,6 +326,30 @@ func scanItems(rows pgx.Rows) ([]*models.MediaItem, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// scanItemsWithMangaCounts scans rows selected with qualifiedListItemColumns
|
||||
// followed by mangaCountColumns. The count subqueries return 0 for non-manga
|
||||
// rows; they are nilled out so only manga cards carry the counts (mirrors
|
||||
// scanBrowseItems).
|
||||
func scanItemsWithMangaCounts(rows pgx.Rows) ([]*models.MediaItem, error) {
|
||||
var items []*models.MediaItem
|
||||
for rows.Next() {
|
||||
var item models.MediaItem
|
||||
dests := append(listItemScanDests(&item), &item.MangaChapterCount, &item.MangaVolumeCount)
|
||||
if err := rows.Scan(dests...); err != nil {
|
||||
return nil, fmt.Errorf("scanning media item row with manga counts: %w", err)
|
||||
}
|
||||
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 media item rows: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// scanItemsWithTotal scans rows that include a trailing total_count column
|
||||
// emitted by COUNT(*) OVER (). The total is identical for every row in the
|
||||
// result set; we read it from the first row (or leave it zero when the result
|
||||
@@ -333,58 +363,8 @@ func scanItemsWithTotal(rows pgx.Rows) ([]*models.MediaItem, int, error) {
|
||||
for rows.Next() {
|
||||
var item models.MediaItem
|
||||
var rowTotal int
|
||||
err := rows.Scan(
|
||||
&item.ContentID,
|
||||
&item.Type,
|
||||
&item.Title,
|
||||
&item.SortTitle,
|
||||
&item.DefaultMetadataLanguage,
|
||||
&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.PosterSourcePath,
|
||||
&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.AirTime,
|
||||
&item.AirTimezone,
|
||||
&item.ShowStatus,
|
||||
&item.MatchedAt,
|
||||
&item.LastRefreshed,
|
||||
&item.RefreshFailures,
|
||||
&item.EpisodeMetadataIncomplete,
|
||||
&item.EpisodeMetadataLastCheckedAt,
|
||||
&item.LockedFields,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&rowTotal,
|
||||
)
|
||||
if err != nil {
|
||||
dests := append(listItemScanDests(&item), &rowTotal)
|
||||
if err := rows.Scan(dests...); err != nil {
|
||||
return nil, 0, fmt.Errorf("scanning media item row with total: %w", err)
|
||||
}
|
||||
items = append(items, &item)
|
||||
@@ -1002,6 +982,10 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
|
||||
}
|
||||
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, &conditions, &args, &argIdx)
|
||||
|
||||
// Manga chapters (type='ebook' rows linked into a manga series) are internal
|
||||
// sub-units and must never surface as standalone search results.
|
||||
conditions = append(conditions, MangaChapterExclusionWhere("mi"))
|
||||
|
||||
whereClause := "WHERE " + strings.Join(conditions, " AND ")
|
||||
|
||||
// Bind ExactTitleHint exactly once. The same arg index is referenced by
|
||||
@@ -1144,7 +1128,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
|
||||
// items that are linked to at least one present file within the given folder
|
||||
// subtree. This intentionally includes ambiguous items so a library scan can
|
||||
// revisit legacy scanner ambiguities after inference heuristics improve.
|
||||
func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error) {
|
||||
func (r *ItemRepository) buildListUnmatchedByFolderAndPathPrefixSQL(folderID int, pathPrefix string, limit int) (string, []any) {
|
||||
query := `
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
@@ -1158,6 +1142,11 @@ func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context,
|
||||
WHERE mil.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND mi.status IN ('unmatched', 'pending', 'ambiguous')
|
||||
-- Manga chapters stay status='pending' by design: provider metadata
|
||||
-- lives on the type='manga' series item, so chapters are never
|
||||
-- matchable and must not feed the matcher's retry loop (mirrors the
|
||||
-- exclusion in the ebook enricher's claim query).
|
||||
AND ` + MangaChapterExclusionWhere("mi") + `
|
||||
AND mf.missing_since IS NULL
|
||||
AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\')
|
||||
GROUP BY mi.content_id
|
||||
@@ -1168,6 +1157,11 @@ func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context,
|
||||
query += ` LIMIT $4`
|
||||
args = append(args, limit)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error) {
|
||||
query, args := r.buildListUnmatchedByFolderAndPathPrefixSQL(folderID, pathPrefix, limit)
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -443,3 +443,31 @@ func TestItemRepo_Search_GroupByHasNoOutputAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemRepo_ListUnmatchedByFolderAndPathPrefix_ExcludesMangaChapters pins
|
||||
// the manga-chapter exclusion in the unmatched-item lister. Manga chapters are
|
||||
// type='ebook' items that stay status='pending' by design (the type='manga'
|
||||
// series item carries all provider metadata), so without a NOT EXISTS guard
|
||||
// against manga_chapters every library scan funnels each chapter through the
|
||||
// matcher's retry loop — one rate-limited ebook-plugin search per chapter
|
||||
// (observed live 2026-06-12: 31,564 chapters x ~1s = 8h46m per scan, 100%
|
||||
// no-match). Mirrors the same exclusion in the ebook enricher's claim query.
|
||||
func TestItemRepo_ListUnmatchedByFolderAndPathPrefix_ExcludesMangaChapters(t *testing.T) {
|
||||
repo := &ItemRepository{}
|
||||
|
||||
sql, args := repo.buildListUnmatchedByFolderAndPathPrefixSQL(10, "/mnt/media/manga", 0)
|
||||
if !strings.Contains(sql, "NOT EXISTS") || !strings.Contains(sql, "manga_chapters") {
|
||||
t.Fatalf("expected manga_chapters NOT EXISTS guard in unmatched lister; got:\n%s", sql)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected 3 args without limit; got %v", args)
|
||||
}
|
||||
|
||||
sql, args = repo.buildListUnmatchedByFolderAndPathPrefixSQL(10, "/mnt/media/manga", 25)
|
||||
if !strings.Contains(sql, "LIMIT $4") {
|
||||
t.Fatalf("expected LIMIT $4 when limit > 0; got:\n%s", sql)
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("expected 4 args with limit; got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +447,10 @@ func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, f
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Manga series items (type='manga') are virtual parents with no media_file of
|
||||
// their own — their membership is keyed to having chapters, not files. Exclude
|
||||
// them here so file-presence reconciliation never sweeps a live series; orphan
|
||||
// series (no remaining chapters) are cleaned up separately by the manga scan.
|
||||
rows, err := tx.Query(ctx, `
|
||||
DELETE FROM media_item_libraries mil
|
||||
WHERE mil.media_folder_id = $1
|
||||
@@ -457,6 +461,12 @@ func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, f
|
||||
AND mf.content_id = mil.content_id
|
||||
AND mf.missing_since IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM media_items mi
|
||||
WHERE mi.content_id = mil.content_id
|
||||
AND mi.type = 'manga'
|
||||
)
|
||||
RETURNING mil.content_id
|
||||
`, folderID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mangaChapterExclusionFor returns the predicate that excludes manga chapter
|
||||
// ebook rows (type='ebook' rows linked via manga_chapters) from a catalog
|
||||
// listing query keyed on the given media_items alias. Manga chapters are
|
||||
// internal sub-units of a type='manga' series and must never surface as
|
||||
// standalone catalog items on browse / section / search surfaces.
|
||||
//
|
||||
// TestMangaChapterExclusionPredicate_AllListingBuilders pins the exact text so
|
||||
// the three independent listing builders (buildBrowsePlan,
|
||||
// QueryExecutor.buildPreviewPagePlan, ItemRepository.buildSearchSQL) all carry
|
||||
// the same index-backed exclusion (manga_chapters.chapter_content_id is the PK).
|
||||
func mangaChapterExclusionPredicate(alias string) string {
|
||||
return "NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = " + alias + ".content_id)"
|
||||
}
|
||||
|
||||
func TestMangaChapterExclusion_BrowsePlan(t *testing.T) {
|
||||
repo := &BrowseRepository{}
|
||||
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "ebook"})
|
||||
if err != nil || earlyEmpty {
|
||||
t.Fatalf("buildBrowsePlan err=%v earlyEmpty=%v", err, earlyEmpty)
|
||||
}
|
||||
if !strings.Contains(plan.whereClause, mangaChapterExclusionPredicate("mi")) {
|
||||
t.Fatalf("browse plan WHERE missing manga-chapter exclusion.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangaChapterExclusion_PreviewPageSQL(t *testing.T) {
|
||||
sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL(
|
||||
QueryDefinition{MediaScope: "ebook"},
|
||||
AccessFilter{},
|
||||
20, 0, true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPreviewPageSQL error: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, mangaChapterExclusionPredicate("mi")) {
|
||||
t.Fatalf("preview-page SQL missing manga-chapter exclusion.\ngot: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangaChapterExclusion_SearchSQL(t *testing.T) {
|
||||
repo := &ItemRepository{}
|
||||
sql, _, _ := repo.buildSearchSQL("naruto", []string{"ebook"}, 20, 0, AccessFilter{})
|
||||
if !strings.Contains(sql, mangaChapterExclusionPredicate("mi")) {
|
||||
t.Fatalf("search SQL missing manga-chapter exclusion.\ngot: %s", sql)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package catalog
|
||||
|
||||
// Manga chapter ↔ series linkage helpers for surfaces beyond the series
|
||||
// detail page: the chapter detail payload (reader back/next navigation),
|
||||
// continue-reading cards (series heading), and the series file-details
|
||||
// dialog.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mangaSeriesForChapterQuery resolves the owning manga series for a chapter
|
||||
// (a type='ebook' item linked via manga_chapters).
|
||||
const mangaSeriesForChapterQuery = `
|
||||
SELECT mc.series_content_id, si.title
|
||||
FROM manga_chapters mc
|
||||
JOIN media_items si ON si.content_id = mc.series_content_id
|
||||
WHERE mc.chapter_content_id = $1
|
||||
`
|
||||
|
||||
// lookupMangaSeriesForChapter returns the series content id and title when the
|
||||
// given item is a manga chapter; ok is false for ordinary ebooks.
|
||||
func (s *DetailService) lookupMangaSeriesForChapter(ctx context.Context, chapterContentID string) (string, string, bool) {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return "", "", false
|
||||
}
|
||||
var seriesID, seriesTitle string
|
||||
err := s.itemRepo.pool.QueryRow(ctx, mangaSeriesForChapterQuery, chapterContentID).
|
||||
Scan(&seriesID, &seriesTitle)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return seriesID, seriesTitle, true
|
||||
}
|
||||
|
||||
// MangaChapterFile is one local file backing a chapter of a manga series, for
|
||||
// the series "View Details" dialog.
|
||||
type MangaChapterFile struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
ChapterIndex *float64 `json:"chapter_index,omitempty"`
|
||||
Volume string `json:"volume,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Container string `json:"container,omitempty"`
|
||||
}
|
||||
|
||||
// MangaSeriesFiles is the series file-details payload: the folder(s) the
|
||||
// chapter files live in plus one row per file in reading order.
|
||||
type MangaSeriesFiles struct {
|
||||
FolderPaths []string `json:"folder_paths,omitempty"`
|
||||
Files []MangaChapterFile `json:"files"`
|
||||
}
|
||||
|
||||
// mangaChapterFilesQuery lists a manga series' chapter files in reading order
|
||||
// (mirrors mangaChaptersQuery ordering).
|
||||
const mangaChapterFilesQuery = `
|
||||
SELECT m.content_id, m.title, mc.chapter_index, mc.volume,
|
||||
f.file_path, COALESCE(f.file_size, 0), COALESCE(f.container, '')
|
||||
FROM manga_chapters mc
|
||||
JOIN media_items m ON m.content_id = mc.chapter_content_id
|
||||
JOIN media_files f ON f.content_id = mc.chapter_content_id
|
||||
WHERE mc.series_content_id = $1
|
||||
ORDER BY mc.chapter_index NULLS LAST, m.sort_title, f.file_path
|
||||
`
|
||||
|
||||
// GetMangaChapterFiles returns the local file listing for an accessible manga
|
||||
// series. File paths are always populated here; the API layer strips them for
|
||||
// viewers without file-path visibility (same policy as item versions).
|
||||
func (s *DetailService) GetMangaChapterFiles(ctx context.Context, seriesContentID string, filter AccessFilter) (*MangaSeriesFiles, error) {
|
||||
if err := s.itemRepo.EnsureAccessible(ctx, seriesContentID, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.itemRepo.pool.Query(ctx, mangaChapterFilesQuery, seriesContentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := &MangaSeriesFiles{Files: make([]MangaChapterFile, 0, 16)}
|
||||
folders := make([]string, 0, 1)
|
||||
seenFolders := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var (
|
||||
file MangaChapterFile
|
||||
index *float64
|
||||
volume *string
|
||||
)
|
||||
if err := rows.Scan(&file.ContentID, &file.Title, &index, &volume, &file.FilePath, &file.FileSize, &file.Container); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.ChapterIndex = index
|
||||
if volume != nil {
|
||||
file.Volume = *volume
|
||||
}
|
||||
file.FileName = filepath.Base(file.FilePath)
|
||||
if dir := filepath.Dir(file.FilePath); dir != "." && dir != "/" && strings.TrimSpace(dir) != "" {
|
||||
if _, ok := seenFolders[dir]; !ok {
|
||||
seenFolders[dir] = struct{}{}
|
||||
folders = append(folders, dir)
|
||||
}
|
||||
}
|
||||
result.Files = append(result.Files, file)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.FolderPaths = folders
|
||||
return result, nil
|
||||
}
|
||||
@@ -18,6 +18,9 @@ func TestMediaScopeItemTypes(t *testing.T) {
|
||||
{"", nil},
|
||||
{"movie", []string{"movie"}},
|
||||
{"audiobook", []string{"audiobook"}},
|
||||
// A manga library browses only its series items; the per-chapter ebook
|
||||
// items are excluded because the manga scope expands to type=manga only.
|
||||
{"manga", []string{"manga"}},
|
||||
{"video", []string{"movie", "series"}},
|
||||
{" Video ", []string{"movie", "series"}},
|
||||
}
|
||||
@@ -39,6 +42,8 @@ func TestMediaScopeMatchesItemType(t *testing.T) {
|
||||
{"video", "series", true},
|
||||
{"video", "audiobook", false},
|
||||
{"audiobook", "audiobook", true},
|
||||
{"manga", "manga", true},
|
||||
{"manga", "ebook", false},
|
||||
{"movie", "series", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -129,10 +129,32 @@ func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, item
|
||||
|
||||
const providerIDColumns = `content_id, item_type, provider, provider_id, created_at, updated_at`
|
||||
|
||||
// excludedProviderIDs lists providers that ReplaceByContentID does NOT manage:
|
||||
// it neither persists nor deletes them. Two kinds live here:
|
||||
// - ephemeral, query-only inputs (metadb, _filepath, oshash) that must never
|
||||
// be written as durable rows; and
|
||||
// - Silo-internal identity anchors (manga_series) stamped directly by the
|
||||
// scanner to keep manga re-scans idempotent. Replace must leave these rows
|
||||
// intact — otherwise the first manga enrichment (which calls
|
||||
// ReplaceByContentID with only the external IDs) would delete the
|
||||
// manga_series anchor, and the next scan would mint a duplicate series and
|
||||
// lose the enriched metadata.
|
||||
var excludedProviderIDs = map[string]struct{}{
|
||||
"metadb": {},
|
||||
"_filepath": {},
|
||||
"oshash": {},
|
||||
"metadb": {},
|
||||
"_filepath": {},
|
||||
"oshash": {},
|
||||
"manga_series": {},
|
||||
}
|
||||
|
||||
// unmanagedProviderIDList returns excludedProviderIDs as a lowercased, sorted
|
||||
// slice for binding into the Replace DELETE so those rows are preserved.
|
||||
func unmanagedProviderIDList() []string {
|
||||
out := make([]string, 0, len(excludedProviderIDs))
|
||||
for p := range excludedProviderIDs {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var preferredProviderIDOrder = map[string]int{
|
||||
@@ -243,7 +265,45 @@ func (r *ProviderIDRepository) GetByContentID(ctx context.Context, contentID str
|
||||
return scanProviderIDs(rows)
|
||||
}
|
||||
|
||||
// ReplaceByContentID replaces all durable provider IDs for a content item.
|
||||
// GetByContentIDs fetches provider IDs for many content items in one query,
|
||||
// grouped by content_id (IDs with no rows are absent). Replaces per-item
|
||||
// GetByContentID loops on the enrichment claim path.
|
||||
func (r *ProviderIDRepository) GetByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaItemProviderID, error) {
|
||||
out := make(map[string][]*models.MediaItemProviderID, len(contentIDs))
|
||||
if len(contentIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+providerIDColumns+`
|
||||
FROM media_item_provider_ids
|
||||
WHERE content_id = ANY($1)
|
||||
ORDER BY content_id,
|
||||
CASE LOWER(provider)
|
||||
WHEN 'tmdb' THEN 0
|
||||
WHEN 'tvdb' THEN 1
|
||||
WHEN 'imdb' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(provider) ASC,
|
||||
provider_id ASC
|
||||
`, contentIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting provider IDs by content_ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
all, err := scanProviderIDs(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, pid := range all {
|
||||
out[pid.ContentID] = append(out[pid.ContentID], pid)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReplaceByContentID replaces the durable provider IDs it manages for a content
|
||||
// item, leaving unmanaged providers (excludedProviderIDs, e.g. the scanner's
|
||||
// manga_series identity anchor) intact.
|
||||
func (r *ProviderIDRepository) ReplaceByContentID(ctx context.Context, contentID string, providerIDs map[string]string) error {
|
||||
if strings.TrimSpace(contentID) == "" {
|
||||
return fmt.Errorf("content_id is required")
|
||||
@@ -290,7 +350,13 @@ func (r *ProviderIDRepository) ReplaceByContentIDTx(
|
||||
}
|
||||
entries := normalizeDurableProviderIDs(providerIDs)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM media_item_provider_ids WHERE content_id = $1`, contentID); err != nil {
|
||||
// Preserve providers Replace does not manage (see excludedProviderIDs):
|
||||
// query-only inputs and the scanner's manga_series identity anchor.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM media_item_provider_ids
|
||||
WHERE content_id = $1
|
||||
AND lower(provider) <> ALL($2::text[])
|
||||
`, contentID, unmanagedProviderIDList()); err != nil {
|
||||
return fmt.Errorf("deleting provider IDs for %s: %w", contentID, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ const MediaScopeVideo = "video"
|
||||
// is an accepted media_scope value. Empty means unscoped and is valid.
|
||||
func IsValidMediaScope(scope string) bool {
|
||||
switch scope {
|
||||
case "", "movie", "series", "episode", "audiobook", "ebook", MediaScopeVideo:
|
||||
case "", "movie", "series", "episode", "audiobook", "ebook", "manga", MediaScopeVideo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -230,7 +230,7 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
}
|
||||
|
||||
if !IsValidMediaScope(normalized.MediaScope) {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', or 'video'")
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', 'manga', or 'video'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
|
||||
@@ -84,6 +84,18 @@ func TestValidate_EbookMediaScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MangaMediaScope(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "manga",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{},
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
}
|
||||
if err := qd.Validate(); err != nil {
|
||||
t.Fatalf("expected manga media scope to be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_EbookMediaScopeRejectsNarratorRule(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
|
||||
@@ -80,7 +80,7 @@ func (e *QueryExecutor) PreviewPage(
|
||||
items []*models.MediaItem
|
||||
total int
|
||||
)
|
||||
items, err = scanItems(rows)
|
||||
items, err = scanItemsWithMangaCounts(rows)
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
@@ -192,7 +192,9 @@ func (p previewPagePlan) pagedSQL(includeTotal bool) (string, []any) {
|
||||
offsetClause = fmt.Sprintf(" OFFSET $%d", offsetArgIdx)
|
||||
args = append(args, p.offset)
|
||||
}
|
||||
selectList := qualifiedListItemColumns("mi")
|
||||
// mangaCountColumns feeds the Vols/Ch poster chip on manga cards; the
|
||||
// library page browses through this preview path, not BrowseRepository.
|
||||
selectList := qualifiedListItemColumns("mi") + ", " + mangaCountColumns("mi")
|
||||
withClause := ""
|
||||
if len(p.ctes) > 0 {
|
||||
withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n"
|
||||
@@ -327,6 +329,10 @@ func (e *QueryExecutor) buildPreviewPagePlan(
|
||||
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 prefix := strings.TrimSpace(access.NamePrefix); prefix != "" {
|
||||
// Dual-column OR matching browse.go and favorites_browse.go: items where
|
||||
// a curated sort_title differs from title (e.g. title="The Office",
|
||||
|
||||
@@ -216,7 +216,7 @@ func (e *Enricher) runBatch(
|
||||
// claimBatchQuery selects unenriched ebooks. Items with fewer prior failures
|
||||
// are claimed first and items at/above enrichFailureCap are skipped entirely,
|
||||
// so a block of permanently failing items cannot occupy every sweep.
|
||||
const claimBatchQuery = `
|
||||
var claimBatchQuery = `
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.title,
|
||||
@@ -238,6 +238,11 @@ const claimBatchQuery = `
|
||||
LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id
|
||||
LEFT JOIN ebook_enrichment_state ees ON ees.content_id = mi.content_id
|
||||
WHERE mi.type = 'ebook'
|
||||
-- Manga chapters are type='ebook' but are parts of a series, not
|
||||
-- standalone books. They are enriched via their type='manga' series (a
|
||||
-- separate path), never individually against book sources — excluding
|
||||
-- them here stops a pointless search storm over Gutenberg/Anna's/etc.
|
||||
AND ` + catalog.MangaChapterExclusionWhere("mi") + `
|
||||
AND (mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
AND mi.last_refreshed IS NULL
|
||||
AND COALESCE(ees.failures, 0) < $2
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
package manga
|
||||
|
||||
// Enricher periodically enriches manga media_items that are missing metadata
|
||||
// by querying the configured metadata-provider chain for each item's library
|
||||
// folder.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
mangaMetadataImageProviderID = "manga-metadata"
|
||||
|
||||
// defaultEnrichBatchSize is sized so a sweep finishes just within the
|
||||
// 5-minute task interval: the plugin serves GetMetadata from its search
|
||||
// cache, so an item costs one AniList request at the plugin's ~28 req/min
|
||||
// budget (AniList's degraded-mode ceiling is 30/min) — 140 items ≈ 295s.
|
||||
// Larger batches are not faster: the task manager drops a trigger while a
|
||||
// sweep is still running, so an overlong sweep idles until the trigger
|
||||
// after next and the effective rate drops below the AniList budget.
|
||||
defaultEnrichBatchSize = 140
|
||||
defaultEnrichWorkers = 4
|
||||
|
||||
// enrichFailureCap is the manga_enrichment_state.failures count at which
|
||||
// a manga stops being claimed for enrichment. Combined with the
|
||||
// failure-count-first claim ordering this prevents a head-of-line block
|
||||
// of permanently failing items from starving newer items and hammering
|
||||
// providers.
|
||||
enrichFailureCap = 5
|
||||
)
|
||||
|
||||
// errEnrichmentSkipped marks an item that could not be attempted at all (no
|
||||
// library folder linked yet, no providers configured). Skipped items are
|
||||
// neither stamped as refreshed nor counted against the failure cap, so they
|
||||
// are retried on every sweep until the missing prerequisite appears.
|
||||
var errEnrichmentSkipped = errors.New("manga enrichment skipped")
|
||||
|
||||
// errEnrichmentNoMatch marks an item every provider answered for without a
|
||||
// confident match. The item was stamped (it will not be re-claimed); the
|
||||
// sentinel only keeps the sweep counters honest — a no-match is neither an
|
||||
// enrichment nor a failure.
|
||||
var errEnrichmentNoMatch = errors.New("manga enrichment: no confident match")
|
||||
|
||||
func mangaContentType() string {
|
||||
return "manga"
|
||||
}
|
||||
|
||||
func mangaEnrichWorkers() int {
|
||||
n := defaultEnrichWorkers
|
||||
if v := os.Getenv("SILO_MANGA_ENRICH_WORKERS"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
if n > mangaEnrichBatchSize() {
|
||||
n = mangaEnrichBatchSize()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func mangaEnrichBatchSize() int {
|
||||
if v := os.Getenv("SILO_MANGA_ENRICH_BATCH"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultEnrichBatchSize
|
||||
}
|
||||
|
||||
type enrichmentItemRow struct {
|
||||
ContentID string
|
||||
Title string
|
||||
Year int
|
||||
FolderID int
|
||||
Language string
|
||||
Author string
|
||||
ProviderIDs map[string]string
|
||||
|
||||
// HasPoster marks an already-enriched item claimed only because a
|
||||
// secondary field (backdrop, status) is missing; the sweep then fetches by
|
||||
// stored provider ID and touches only the missing secondary fields.
|
||||
HasPoster bool
|
||||
// HasBackdrop guards the secondary pass against re-caching an existing
|
||||
// backdrop when the item was claimed for another missing field.
|
||||
HasBackdrop bool
|
||||
}
|
||||
|
||||
// Enricher drives the manga metadata enrichment sweep.
|
||||
type Enricher struct {
|
||||
pool *pgxpool.Pool
|
||||
chainRepo *metadata.ChainRepository
|
||||
resolver *metadata.PluginResolverAdapter
|
||||
itemRepo *catalog.ItemRepository
|
||||
personRepo *catalog.PersonRepository
|
||||
providerIDs *catalog.ProviderIDRepository
|
||||
imageCacher metadata.ImageCacher
|
||||
batchSize int
|
||||
workers int
|
||||
}
|
||||
|
||||
func NewEnricher(
|
||||
pool *pgxpool.Pool,
|
||||
chainRepo *metadata.ChainRepository,
|
||||
resolver *metadata.PluginResolverAdapter,
|
||||
itemRepo *catalog.ItemRepository,
|
||||
personRepo *catalog.PersonRepository,
|
||||
providerIDs *catalog.ProviderIDRepository,
|
||||
) *Enricher {
|
||||
return &Enricher{
|
||||
pool: pool,
|
||||
chainRepo: chainRepo,
|
||||
resolver: resolver,
|
||||
itemRepo: itemRepo,
|
||||
personRepo: personRepo,
|
||||
providerIDs: providerIDs,
|
||||
batchSize: mangaEnrichBatchSize(),
|
||||
workers: mangaEnrichWorkers(),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enricher) SetImageCacher(cacher metadata.ImageCacher) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.imageCacher = cacher
|
||||
}
|
||||
|
||||
func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
if e == nil || e.pool == nil || e.chainRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
items, err := e.claimBatch(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("manga enrichment: claim batch: %w", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
slog.Info("manga enrichment: sweep started",
|
||||
"count", len(items),
|
||||
"workers", e.workers,
|
||||
)
|
||||
|
||||
stats := e.runBatch(ctx, items, e.enrichItem, e.recordEnrichFailure)
|
||||
|
||||
slog.Info("manga enrichment: sweep complete",
|
||||
"attempted", len(items),
|
||||
"enriched", stats.enriched,
|
||||
"no_match", stats.noMatch,
|
||||
"failed", stats.failed,
|
||||
)
|
||||
return int(stats.enriched), nil
|
||||
}
|
||||
|
||||
// sweepStats separates the three terminal outcomes of a sweep so the log and
|
||||
// task result do not overcount: a stamped no-match is not an enrichment.
|
||||
type sweepStats struct {
|
||||
enriched int64
|
||||
noMatch int64
|
||||
failed int64
|
||||
}
|
||||
|
||||
func (e *Enricher) runBatch(
|
||||
ctx context.Context,
|
||||
items []enrichmentItemRow,
|
||||
enrichFn func(context.Context, enrichmentItemRow) error,
|
||||
recordFailure func(context.Context, enrichmentItemRow),
|
||||
) sweepStats {
|
||||
workers := e.workers
|
||||
if workers <= 0 {
|
||||
workers = 1
|
||||
}
|
||||
if workers > len(items) {
|
||||
workers = len(items)
|
||||
}
|
||||
|
||||
ch := make(chan enrichmentItemRow, workers)
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
stats sweepStats
|
||||
)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range ch {
|
||||
if ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
if err := enrichFn(ctx, item); err != nil {
|
||||
if errors.Is(err, errEnrichmentSkipped) {
|
||||
slog.Debug("manga enrichment: item skipped",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"reason", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errEnrichmentNoMatch) {
|
||||
atomic.AddInt64(&stats.noMatch, 1)
|
||||
continue
|
||||
}
|
||||
slog.Warn("manga enrichment: item failed",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"error", err,
|
||||
)
|
||||
// A cancelled sweep says nothing about the item itself,
|
||||
// so it does not count against the failure cap.
|
||||
if recordFailure != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
recordFailure(ctx, item)
|
||||
}
|
||||
atomic.AddInt64(&stats.failed, 1)
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&stats.enriched, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
ch <- item
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
return stats
|
||||
}
|
||||
|
||||
// claimBatchQuery selects manga needing enrichment. Both arms require
|
||||
// last_refreshed IS NULL:
|
||||
// - the common arm is unenriched items (no poster);
|
||||
// - the secondary arm (poster present, backdrop or show_status empty) only
|
||||
// becomes reachable when an operator resets last_refreshed to backfill a
|
||||
// newly-added field across an already-enriched library — exactly how the
|
||||
// banner and publication-status backfills were rolled out. It is an
|
||||
// efficient fast-path for that admin action (fetch by stored provider id,
|
||||
// write only the missing secondary field) and is intentionally NOT an
|
||||
// automatic periodic re-check: a series whose provider simply has no
|
||||
// banner would otherwise be re-fetched every sweep.
|
||||
//
|
||||
// Stamping after the attempt keeps items whose provider has no banner/status
|
||||
// from being re-claimed within the same backfill. Items with fewer prior
|
||||
// failures are claimed first and items at/above enrichFailureCap are skipped
|
||||
// entirely, so a block of permanently failing items cannot occupy every sweep.
|
||||
const claimBatchQuery = `
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.title,
|
||||
mi.year,
|
||||
COALESCE(mil.media_folder_id, 0) AS folder_id,
|
||||
COALESCE(mf.metadata_language, 'en') AS language,
|
||||
COALESCE(
|
||||
(SELECT p.name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = mi.content_id
|
||||
AND ip.kind = 7
|
||||
ORDER BY ip.sort_order, ip.id
|
||||
LIMIT 1),
|
||||
''
|
||||
) AS author,
|
||||
(mi.poster_path IS NOT NULL AND mi.poster_path <> '') AS has_poster,
|
||||
(mi.backdrop_path IS NOT NULL AND mi.backdrop_path <> '') AS has_backdrop
|
||||
FROM media_items mi
|
||||
LEFT JOIN media_item_libraries mil ON mil.content_id = mi.content_id
|
||||
LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id
|
||||
LEFT JOIN manga_enrichment_state ees ON ees.content_id = mi.content_id
|
||||
WHERE mi.type = 'manga'
|
||||
AND ((mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
OR (mi.backdrop_path IS NULL OR mi.backdrop_path = '')
|
||||
OR (mi.show_status IS NULL OR mi.show_status = ''))
|
||||
AND mi.last_refreshed IS NULL
|
||||
AND COALESCE(ees.failures, 0) < $2
|
||||
ORDER BY COALESCE(ees.failures, 0) ASC, mi.created_at ASC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
func (e *Enricher) claimBatch(ctx context.Context) ([]enrichmentItemRow, error) {
|
||||
rows, err := e.pool.Query(ctx, claimBatchQuery, e.batchSize, enrichFailureCap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying unenriched manga: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []enrichmentItemRow
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var item enrichmentItemRow
|
||||
if err := rows.Scan(
|
||||
&item.ContentID,
|
||||
&item.Title,
|
||||
&item.Year,
|
||||
&item.FolderID,
|
||||
&item.Language,
|
||||
&item.Author,
|
||||
&item.HasPoster,
|
||||
&item.HasBackdrop,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning manga enrichment row: %w", err)
|
||||
}
|
||||
if _, dup := seen[item.ContentID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating manga enrichment rows: %w", err)
|
||||
}
|
||||
|
||||
if e.providerIDs != nil && len(items) > 0 {
|
||||
ids := make([]string, len(items))
|
||||
for i := range items {
|
||||
ids[i] = items[i].ContentID
|
||||
}
|
||||
if byID, err := e.providerIDs.GetByContentIDs(ctx, ids); err == nil {
|
||||
for i := range items {
|
||||
items[i].ProviderIDs = providerIDMapFromRows(byID[items[i].ContentID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error {
|
||||
if item.FolderID == 0 {
|
||||
// The scanner inserts the library membership after the item upsert, so
|
||||
// a freshly indexed manga can be claimed inside that window. Skip it:
|
||||
// stamping here would terminally mark the item refreshed before any
|
||||
// provider ever saw it.
|
||||
return fmt.Errorf("%w: item %s has no library folder yet", errEnrichmentSkipped, item.ContentID)
|
||||
}
|
||||
|
||||
providers, err := metadata.ResolveChain(ctx, item.FolderID, mangaContentType(), e.chainRepo, e.resolver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving manga chain for folder %d: %w", item.FolderID, err)
|
||||
}
|
||||
return e.enrichWithProviders(ctx, item, providers)
|
||||
}
|
||||
|
||||
// enrichWithProviders runs the provider chain for one claimed item. Outcomes:
|
||||
// - metadata obtained: persist it and stamp last_refreshed (nil error);
|
||||
// - providers answered but nothing matched: stamp last_refreshed so the
|
||||
// item is not re-claimed every sweep (errEnrichmentNoMatch);
|
||||
// - one or more providers errored and no metadata was obtained: return an
|
||||
// error so the failure cap/backoff engages, without stamping;
|
||||
// - no providers configured: skip (no stamp, no failure) so the item is
|
||||
// retried once a chain exists.
|
||||
func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemRow, providers []metadata.Provider) error {
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("%w: no metadata providers configured for folder %d", errEnrichmentSkipped, item.FolderID)
|
||||
}
|
||||
|
||||
accumulator, accumulatedIDs, providerErrs := collectMangaMetadata(ctx, item, providers)
|
||||
|
||||
if item.HasPoster {
|
||||
return e.enrichSecondaryOnly(ctx, item, accumulator, providerErrs)
|
||||
}
|
||||
|
||||
if !accumulator.HasMetadata && accumulator.PosterPath == "" && accumulator.Overview == "" {
|
||||
if err := ctx.Err(); err != nil {
|
||||
// A cancelled sweep says nothing about the item or the providers.
|
||||
return err
|
||||
}
|
||||
if len(providerErrs) > 0 {
|
||||
// Transient provider trouble must not stamp the item terminally;
|
||||
// surfacing an error engages the failure cap and backoff instead.
|
||||
return fmt.Errorf("no metadata obtained, %d provider error(s): %w",
|
||||
len(providerErrs), errors.Join(providerErrs...))
|
||||
}
|
||||
slog.Info("manga enrichment: no metadata found",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
if err := e.stampLastRefreshed(ctx, item.ContentID); err != nil {
|
||||
return err
|
||||
}
|
||||
return errEnrichmentNoMatch
|
||||
}
|
||||
|
||||
e.cacheRemoteImages(ctx, item.ContentID, accumulator)
|
||||
|
||||
if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil {
|
||||
return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err)
|
||||
}
|
||||
|
||||
slog.Info("manga enrichment: enriched",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"poster", accumulator.PosterPath != "",
|
||||
"backdrop", accumulator.BackdropPath != "",
|
||||
"overview", accumulator.Overview != "",
|
||||
"people", len(filterMangaPeople(accumulator.People)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichSecondaryOnly finishes a secondary-fields claim: an already-enriched
|
||||
// item missing its backdrop and/or publication status. Only the missing
|
||||
// secondary fields are written — the existing poster, overview, people, and
|
||||
// provider IDs stay untouched. Whatever the outcome (fields filled, provider
|
||||
// has neither), the item is stamped so it is not re-claimed every sweep;
|
||||
// provider errors engage the failure cap without stamping, like the full path.
|
||||
func (e *Enricher) enrichSecondaryOnly(ctx context.Context, item enrichmentItemRow, result *metadata.MetadataResult, providerErrs []error) error {
|
||||
upd := &catalog.MetadataUpdate{}
|
||||
if result != nil && result.BackdropPath != "" && !item.HasBackdrop {
|
||||
path, thumbhash := e.cacheRemoteImage(ctx, item.ContentID, result.BackdropPath, metadata.ImageBackdrop)
|
||||
upd.BackdropPath = &path
|
||||
if thumbhash != "" {
|
||||
upd.BackdropThumbhash = &thumbhash
|
||||
}
|
||||
}
|
||||
if result != nil {
|
||||
if status := normalizeMangaStatus(result.ShowStatus); status != "" {
|
||||
upd.ShowStatus = &status
|
||||
}
|
||||
}
|
||||
|
||||
if upd.BackdropPath == nil && upd.ShowStatus == nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(providerErrs) > 0 {
|
||||
return fmt.Errorf("no secondary metadata obtained, %d provider error(s): %w",
|
||||
len(providerErrs), errors.Join(providerErrs...))
|
||||
}
|
||||
slog.Info("manga enrichment: no secondary metadata available",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
if err := e.stampLastRefreshed(ctx, item.ContentID); err != nil {
|
||||
return err
|
||||
}
|
||||
return errEnrichmentNoMatch
|
||||
}
|
||||
|
||||
if err := e.updateMetadataAndTimestamps(ctx, item.ContentID, upd); err != nil {
|
||||
return fmt.Errorf("persisting secondary metadata for %s: %w", item.ContentID, err)
|
||||
}
|
||||
|
||||
slog.Info("manga enrichment: secondary metadata added",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"backdrop", upd.BackdropPath != nil,
|
||||
"status", upd.ShowStatus != nil,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectMangaMetadata queries every provider in the chain and accumulates
|
||||
// IDs and metadata. Individual provider failures are collected (not fatal) so
|
||||
// the caller can distinguish "providers answered, no match" from "providers
|
||||
// were unreachable". The search pass is skipped when the item already carries
|
||||
// provider IDs (a previously matched item only needs the by-ID fetch).
|
||||
func collectMangaMetadata(ctx context.Context, item enrichmentItemRow, providers []metadata.Provider) (*metadata.MetadataResult, map[string]string, []error) {
|
||||
searchQuery, accumulatedIDs := buildMangaSearchQuery(item)
|
||||
var providerErrs []error
|
||||
|
||||
// An item that already carries provider IDs was matched before; the by-ID
|
||||
// fetch below is enough and re-searching would spend a rate-limited
|
||||
// request (and risk re-matching differently).
|
||||
searchProviders := providers
|
||||
if len(accumulatedIDs) > 0 {
|
||||
searchProviders = nil
|
||||
}
|
||||
|
||||
for _, p := range searchProviders {
|
||||
sp, ok := p.(metadata.SearchProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
results, searchErr := sp.Search(ctx, searchQuery)
|
||||
if searchErr != nil {
|
||||
slog.Warn("manga enrichment: search error",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", searchErr,
|
||||
)
|
||||
providerErrs = append(providerErrs, fmt.Errorf("%s search: %w", p.Slug(), searchErr))
|
||||
continue
|
||||
}
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
for k, v := range results[0].ProviderIDs {
|
||||
if v != "" {
|
||||
if _, exists := accumulatedIDs[k]; !exists {
|
||||
accumulatedIDs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("manga enrichment: search result",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"matched_ids", accumulatedIDs,
|
||||
)
|
||||
}
|
||||
|
||||
accumulator := &metadata.MetadataResult{
|
||||
ProviderIDs: accumulatedIDs,
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
mp, ok := p.(metadata.MetadataProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result, getErr := mp.GetMetadata(ctx, buildMangaMetadataRequest(accumulator.ProviderIDs, item.Language))
|
||||
if getErr != nil {
|
||||
slog.Warn("manga enrichment: GetMetadata error",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", getErr,
|
||||
)
|
||||
providerErrs = append(providerErrs, fmt.Errorf("%s metadata: %w", p.Slug(), getErr))
|
||||
continue
|
||||
}
|
||||
if result == nil || !result.HasMetadata {
|
||||
continue
|
||||
}
|
||||
mergeEnrichmentProviderIDs(accumulator, result)
|
||||
metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty)
|
||||
// MergeMetadata does not propagate HasMetadata; without this a confident
|
||||
// match carrying only genres/authors/status/year (no cover, no overview)
|
||||
// would fail the no-match check below and be discarded + stamped.
|
||||
accumulator.HasMetadata = true
|
||||
|
||||
slog.Debug("manga enrichment: metadata received",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"has_poster", result.PosterPath != "",
|
||||
"has_overview", result.Overview != "",
|
||||
)
|
||||
}
|
||||
|
||||
return accumulator, accumulator.ProviderIDs, providerErrs
|
||||
}
|
||||
|
||||
// cacheRemoteImages localizes the remote poster and backdrop URLs on a full
|
||||
// enrichment result, replacing each with the cached path + thumbhash when
|
||||
// caching succeeds (the provider URL is kept as a fallback otherwise).
|
||||
func (e *Enricher) cacheRemoteImages(ctx context.Context, contentID string, result *metadata.MetadataResult) {
|
||||
if e == nil || result == nil {
|
||||
return
|
||||
}
|
||||
if path, thumbhash := e.cacheRemoteImage(ctx, contentID, result.PosterPath, metadata.ImagePoster); path != "" {
|
||||
result.PosterPath = path
|
||||
if thumbhash != "" {
|
||||
result.PosterThumbhash = thumbhash
|
||||
}
|
||||
}
|
||||
if path, thumbhash := e.cacheRemoteImage(ctx, contentID, result.BackdropPath, metadata.ImageBackdrop); path != "" {
|
||||
result.BackdropPath = path
|
||||
if thumbhash != "" {
|
||||
result.BackdropThumbhash = thumbhash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cacheRemoteImage downloads and caches one remote image, returning the
|
||||
// stored path and thumbhash. On any failure it returns the original URL (a
|
||||
// remote URL in the column still renders; the cache is an optimization).
|
||||
func (e *Enricher) cacheRemoteImage(ctx context.Context, contentID, url string, imageType metadata.ImageType) (string, string) {
|
||||
if e == nil || url == "" {
|
||||
return url, ""
|
||||
}
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return url, ""
|
||||
}
|
||||
if isNilImageCacher(e.imageCacher) {
|
||||
return url, ""
|
||||
}
|
||||
|
||||
cached, err := e.imageCacher.CacheImage(ctx, metadata.CacheImageRequest{
|
||||
SourceURL: url,
|
||||
ProviderID: mangaMetadataImageProviderID,
|
||||
ContentType: "manga",
|
||||
ContentID: contentID,
|
||||
ImageType: imageType,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("manga enrichment: image cache failed, keeping provider URL",
|
||||
"content_id", contentID,
|
||||
"url", url,
|
||||
"error", err,
|
||||
)
|
||||
return url, ""
|
||||
}
|
||||
if cached == nil {
|
||||
slog.Warn("manga enrichment: image cache returned no result, keeping provider URL",
|
||||
"content_id", contentID,
|
||||
"url", url,
|
||||
)
|
||||
return url, ""
|
||||
}
|
||||
|
||||
storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext)
|
||||
if storedPath == "" {
|
||||
return url, ""
|
||||
}
|
||||
return storedPath, cached.Thumbhash
|
||||
}
|
||||
|
||||
func isNilImageCacher(cacher metadata.ImageCacher) bool {
|
||||
if cacher == nil {
|
||||
return true
|
||||
}
|
||||
value := reflect.ValueOf(cacher)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
|
||||
return value.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cachedOriginalImagePath(basePath, ext string) string {
|
||||
if basePath == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(basePath, "/original.") {
|
||||
return basePath
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
return strings.TrimRight(basePath, "/") + "/original" + ext
|
||||
}
|
||||
|
||||
func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error {
|
||||
upd := &catalog.MetadataUpdate{}
|
||||
|
||||
if result.PosterPath != "" {
|
||||
upd.PosterPath = &result.PosterPath
|
||||
}
|
||||
if result.PosterThumbhash != "" {
|
||||
upd.PosterThumbhash = &result.PosterThumbhash
|
||||
}
|
||||
if result.BackdropPath != "" {
|
||||
upd.BackdropPath = &result.BackdropPath
|
||||
}
|
||||
if result.BackdropThumbhash != "" {
|
||||
upd.BackdropThumbhash = &result.BackdropThumbhash
|
||||
}
|
||||
if result.LogoPath != "" {
|
||||
upd.LogoPath = &result.LogoPath
|
||||
}
|
||||
if result.Overview != "" {
|
||||
upd.Overview = &result.Overview
|
||||
}
|
||||
if result.Tagline != "" {
|
||||
upd.Tagline = &result.Tagline
|
||||
}
|
||||
if result.ReleaseDate != "" {
|
||||
upd.ReleaseDate = &result.ReleaseDate
|
||||
}
|
||||
if len(result.Genres) > 0 {
|
||||
genres := append([]string(nil), result.Genres...)
|
||||
upd.Genres = &genres
|
||||
}
|
||||
if len(result.Studios) > 0 {
|
||||
studios := append([]string(nil), result.Studios...)
|
||||
upd.Studios = &studios
|
||||
}
|
||||
if result.ContentRating != "" {
|
||||
upd.ContentRating = &result.ContentRating
|
||||
}
|
||||
if result.Runtime > 0 {
|
||||
upd.Runtime = &result.Runtime
|
||||
}
|
||||
if result.Year > 0 {
|
||||
upd.Year = &result.Year
|
||||
}
|
||||
if status := normalizeMangaStatus(result.ShowStatus); status != "" {
|
||||
upd.ShowStatus = &status
|
||||
}
|
||||
|
||||
providerIDs = filterMangaProviderIDs(providerIDs)
|
||||
if e.providerIDs != nil && len(providerIDs) > 0 {
|
||||
if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
|
||||
slog.Warn("manga enrichment: failed to persist provider IDs",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.updateMetadataAndTimestamps(ctx, contentID, upd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authors := filterMangaPeople(result.People)
|
||||
if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil {
|
||||
if err := e.persistPeople(ctx, contentID, authors); err != nil {
|
||||
slog.Warn("manga enrichment: failed to persist people",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Enricher) updateMetadataAndTimestamps(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error {
|
||||
if e.itemRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if err := e.itemRepo.UpdateMetadata(ctx, contentID, upd); err != nil {
|
||||
return fmt.Errorf("UpdateMetadata: %w", err)
|
||||
}
|
||||
return e.stampLastRefreshed(ctx, contentID)
|
||||
}
|
||||
|
||||
func (e *Enricher) stampLastRefreshed(ctx context.Context, contentID string) error {
|
||||
if e.pool == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := e.pool.Exec(ctx, `
|
||||
UPDATE media_items
|
||||
SET last_refreshed = $1,
|
||||
matched_at = COALESCE(matched_at, $1),
|
||||
status = CASE WHEN status = 'pending' THEN 'matched' ELSE status END
|
||||
WHERE content_id = $2
|
||||
`, now, contentID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Success clears the enrichment failure backlog. media_items.refresh_failures
|
||||
// is intentionally left alone: it belongs to the metadata refresh-debt system.
|
||||
_, err := e.pool.Exec(ctx, `
|
||||
DELETE FROM manga_enrichment_state WHERE content_id = $1
|
||||
`, contentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// recordEnrichFailure increments the item's manga_enrichment_state failure
|
||||
// counter so claimBatch deprioritizes it on the next sweep and stops claiming
|
||||
// it at enrichFailureCap. The state is dedicated to manga enrichment;
|
||||
// media_items.refresh_failures is owned by the metadata refresh-debt system
|
||||
// and is never touched here.
|
||||
func (e *Enricher) recordEnrichFailure(ctx context.Context, item enrichmentItemRow) {
|
||||
if e == nil || e.pool == nil {
|
||||
return
|
||||
}
|
||||
if _, err := e.pool.Exec(ctx, `
|
||||
INSERT INTO manga_enrichment_state (content_id, failures, updated_at)
|
||||
VALUES ($1, 1, NOW())
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
failures = manga_enrichment_state.failures + 1,
|
||||
updated_at = NOW()
|
||||
`, item.ContentID); err != nil {
|
||||
slog.Warn("manga enrichment: failed to record enrichment failure",
|
||||
"content_id", item.ContentID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enricher) persistPeople(ctx context.Context, contentID string, people []models.ItemPerson) error {
|
||||
people = filterMangaPeople(people)
|
||||
if len(people) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
persons := make([]models.Person, len(people))
|
||||
for i := range people {
|
||||
persons[i] = people[i].Person
|
||||
}
|
||||
|
||||
personIDs, err := e.personRepo.BatchFindOrCreate(ctx, persons)
|
||||
if err != nil {
|
||||
return fmt.Errorf("BatchFindOrCreate people: %w", err)
|
||||
}
|
||||
|
||||
linked := make([]models.ItemPerson, 0, len(people))
|
||||
for i := range people {
|
||||
if i >= len(personIDs) || personIDs[i] == 0 {
|
||||
continue
|
||||
}
|
||||
ip := people[i]
|
||||
ip.Person.ID = personIDs[i]
|
||||
linked = append(linked, ip)
|
||||
}
|
||||
|
||||
if len(linked) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, err := e.itemRepo.GetPeople(ctx, contentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get existing people: %w", err)
|
||||
}
|
||||
return e.itemRepo.ReplacePeople(ctx, contentID, mergeMangaAuthorCredits(existing, linked))
|
||||
}
|
||||
|
||||
// mergeMangaAuthorCredits mirrors the scanner's mergeEbookPeople semantics:
|
||||
// the provider authors replace existing author (and stale narrator) credits,
|
||||
// while every other curated people kind on the item is preserved.
|
||||
func mergeMangaAuthorCredits(existing []models.ItemPerson, authors []models.ItemPerson) []models.ItemPerson {
|
||||
merged := make([]models.ItemPerson, 0, len(existing)+len(authors))
|
||||
for _, p := range existing {
|
||||
if p.Kind == models.PersonKindAuthor || p.Kind == models.PersonKindNarrator {
|
||||
continue
|
||||
}
|
||||
p.SortOrder = len(merged)
|
||||
merged = append(merged, p)
|
||||
}
|
||||
for _, a := range authors {
|
||||
a.SortOrder = len(merged)
|
||||
merged = append(merged, a)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func filterMangaPeople(people []models.ItemPerson) []models.ItemPerson {
|
||||
authors := make([]models.ItemPerson, 0, len(people))
|
||||
for _, person := range people {
|
||||
if person.Kind != models.PersonKindAuthor {
|
||||
continue
|
||||
}
|
||||
person.SortOrder = len(authors)
|
||||
authors = append(authors, person)
|
||||
}
|
||||
return authors
|
||||
}
|
||||
|
||||
func buildMangaSearchQuery(item enrichmentItemRow) (metadata.SearchQuery, map[string]string) {
|
||||
accumulatedIDs := filterMangaProviderIDs(item.ProviderIDs)
|
||||
if accumulatedIDs == nil {
|
||||
accumulatedIDs = map[string]string{}
|
||||
}
|
||||
return metadata.SearchQuery{
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
ContentType: mangaContentType(),
|
||||
ProviderIDs: accumulatedIDs,
|
||||
Language: item.Language,
|
||||
}, accumulatedIDs
|
||||
}
|
||||
|
||||
func buildMangaMetadataRequest(providerIDs map[string]string, language string) metadata.MetadataRequest {
|
||||
return metadata.MetadataRequest{
|
||||
ProviderIDs: filterMangaProviderIDs(providerIDs),
|
||||
ContentType: mangaContentType(),
|
||||
Language: language,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeEnrichmentProviderIDs(dst *metadata.MetadataResult, src *metadata.MetadataResult) {
|
||||
if src == nil || len(src.ProviderIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.ProviderIDs == nil {
|
||||
dst.ProviderIDs = make(map[string]string, len(src.ProviderIDs))
|
||||
}
|
||||
for k, v := range filterMangaProviderIDs(src.ProviderIDs) {
|
||||
if v != "" {
|
||||
if _, exists := dst.ProviderIDs[k]; !exists {
|
||||
dst.ProviderIDs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterMangaProviderIDs(providerIDs map[string]string) map[string]string {
|
||||
if len(providerIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make(map[string]string, len(providerIDs))
|
||||
for provider, providerID := range providerIDs {
|
||||
provider = strings.TrimSpace(provider)
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if provider == "" || providerID == "" {
|
||||
continue
|
||||
}
|
||||
provider = strings.ToLower(provider)
|
||||
if isMangaASINProvider(provider) || isInternalMangaProvider(provider) {
|
||||
continue
|
||||
}
|
||||
filtered[provider] = providerID
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// normalizeMangaStatus maps the varied publication-status strings returned by
|
||||
// manga metadata providers (AniList: RELEASING/FINISHED/NOT_YET_RELEASED/
|
||||
// CANCELLED/HIATUS, MangaDex: ongoing/completed/hiatus/cancelled, and the SDK's
|
||||
// Continuing/Ended) onto the stable label set the clients render, so the shared
|
||||
// show_status field carries one consistent manga value-domain instead of raw
|
||||
// provider casing. Unknown values pass through trimmed so nothing is lost.
|
||||
func normalizeMangaStatus(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.ReplaceAll(s, " ", "_")) {
|
||||
case "ongoing", "releasing", "current", "publishing", "continuing":
|
||||
return "Ongoing"
|
||||
case "completed", "finished", "ended":
|
||||
return "Completed"
|
||||
case "hiatus", "on_hiatus", "paused":
|
||||
return "Hiatus"
|
||||
case "cancelled", "canceled", "discontinued":
|
||||
return "Cancelled"
|
||||
case "upcoming", "not_yet_released", "unreleased", "announced":
|
||||
return "Upcoming"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func isMangaASINProvider(provider string) bool {
|
||||
normalized := strings.ReplaceAll(strings.ReplaceAll(provider, "_", ""), "-", "")
|
||||
return normalized == "asin" || normalized == "audibleasin"
|
||||
}
|
||||
|
||||
// isInternalMangaProvider filters Silo-internal identity providers out of the
|
||||
// metadata flow. The scanner stamps every manga series with a manga_series
|
||||
// identity row for idempotency; passing it to the plugin made the
|
||||
// search-skip-when-already-matched guard treat every item as matched, so
|
||||
// unmatched items went straight to a by-ID fetch with no usable ID and were
|
||||
// stamped as no-match without a single search.
|
||||
func isInternalMangaProvider(provider string) bool {
|
||||
return strings.ReplaceAll(provider, "-", "_") == "manga_series"
|
||||
}
|
||||
|
||||
func providerIDMapFromRows(rows []*models.MediaItemProviderID) map[string]string {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if r != nil {
|
||||
for provider, providerID := range filterMangaProviderIDs(map[string]string{
|
||||
r.Provider: r.ProviderID,
|
||||
}) {
|
||||
m[provider] = providerID
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaimBatchQueryTargetsManga(t *testing.T) {
|
||||
if !strings.Contains(claimBatchQuery, "mi.type = 'manga'") {
|
||||
t.Fatalf("claimBatchQuery must filter type='manga'")
|
||||
}
|
||||
if strings.Contains(claimBatchQuery, "'ebook'") {
|
||||
t.Fatalf("claimBatchQuery must not reference ebook")
|
||||
}
|
||||
if !strings.Contains(claimBatchQuery, "manga_enrichment_state") {
|
||||
t.Fatalf("claimBatchQuery must join manga_enrichment_state")
|
||||
}
|
||||
// Secondary-fields arm: enriched items missing a backdrop or publication
|
||||
// status are claimed too; has_poster/has_backdrop distinguish them so only
|
||||
// the missing secondary fields are written.
|
||||
if !strings.Contains(claimBatchQuery, "mi.backdrop_path IS NULL OR mi.backdrop_path = ''") {
|
||||
t.Fatalf("claimBatchQuery must claim backdrop-missing items")
|
||||
}
|
||||
if !strings.Contains(claimBatchQuery, "mi.show_status IS NULL OR mi.show_status = ''") {
|
||||
t.Fatalf("claimBatchQuery must claim status-missing items")
|
||||
}
|
||||
if !strings.Contains(claimBatchQuery, "AS has_poster") {
|
||||
t.Fatalf("claimBatchQuery must project has_poster")
|
||||
}
|
||||
if !strings.Contains(claimBatchQuery, "AS has_backdrop") {
|
||||
t.Fatalf("claimBatchQuery must project has_backdrop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypeIsManga(t *testing.T) {
|
||||
if got := mangaContentType(); got != "manga" {
|
||||
t.Fatalf("mangaContentType() = %q, want %q", got, "manga")
|
||||
}
|
||||
}
|
||||
|
||||
// runBatch must keep the three terminal outcomes apart: a stamped no-match is
|
||||
// neither an enrichment (the old behavior overcounted it as one) nor a
|
||||
// failure, and only real failures reach recordFailure.
|
||||
func TestRunBatchSeparatesOutcomes(t *testing.T) {
|
||||
e := &Enricher{workers: 2}
|
||||
items := []enrichmentItemRow{
|
||||
{ContentID: "enriched-1"},
|
||||
{ContentID: "enriched-2"},
|
||||
{ContentID: "no-match"},
|
||||
{ContentID: "skipped"},
|
||||
{ContentID: "failed"},
|
||||
}
|
||||
|
||||
var failures int64
|
||||
stats := e.runBatch(context.Background(), items,
|
||||
func(_ context.Context, item enrichmentItemRow) error {
|
||||
switch item.ContentID {
|
||||
case "no-match":
|
||||
return errEnrichmentNoMatch
|
||||
case "skipped":
|
||||
return errEnrichmentSkipped
|
||||
case "failed":
|
||||
return errors.New("provider exploded")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
},
|
||||
func(context.Context, enrichmentItemRow) {
|
||||
atomic.AddInt64(&failures, 1)
|
||||
},
|
||||
)
|
||||
|
||||
if stats.enriched != 2 {
|
||||
t.Fatalf("enriched = %d, want 2", stats.enriched)
|
||||
}
|
||||
if stats.noMatch != 1 {
|
||||
t.Fatalf("noMatch = %d, want 1", stats.noMatch)
|
||||
}
|
||||
if stats.failed != 1 {
|
||||
t.Fatalf("failed = %d, want 1", stats.failed)
|
||||
}
|
||||
if failures != 1 {
|
||||
t.Fatalf("recordFailure calls = %d, want 1", failures)
|
||||
}
|
||||
}
|
||||
|
||||
// The scanner's manga_series identity rows must never reach the metadata
|
||||
// flow: they made the search-skip guard treat every item as already matched.
|
||||
func TestFilterMangaProviderIDsDropsInternalIdentity(t *testing.T) {
|
||||
filtered := filterMangaProviderIDs(map[string]string{
|
||||
"manga_series": "abc123",
|
||||
"anilist": "42",
|
||||
"asin": "B000",
|
||||
})
|
||||
if _, ok := filtered["manga_series"]; ok {
|
||||
t.Fatalf("manga_series identity must be filtered, got %v", filtered)
|
||||
}
|
||||
if filtered["anilist"] != "42" {
|
||||
t.Fatalf("anilist id must survive, got %v", filtered)
|
||||
}
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("filtered = %v, want only anilist", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMangaStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// AniList enum
|
||||
"RELEASING": "Ongoing",
|
||||
"FINISHED": "Completed",
|
||||
"NOT_YET_RELEASED": "Upcoming",
|
||||
"CANCELLED": "Cancelled",
|
||||
"HIATUS": "Hiatus",
|
||||
// MangaDex / lowercase
|
||||
"ongoing": "Ongoing",
|
||||
"completed": "Completed",
|
||||
"hiatus": "Hiatus",
|
||||
"cancelled": "Cancelled",
|
||||
// SDK Continuing/Ended + spacing/casing variants
|
||||
"Continuing": "Ongoing",
|
||||
"Ended": "Completed",
|
||||
"on hiatus": "Hiatus",
|
||||
" Upcoming ": "Upcoming",
|
||||
// Empty and unknown pass through (trimmed)
|
||||
"": "",
|
||||
" ": "",
|
||||
"Weird-Val": "Weird-Val",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeMangaStatus(in); got != want {
|
||||
t.Fatalf("normalizeMangaStatus(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func MergeMetadata(source, target *MetadataResult, locked []MetadataField, mode
|
||||
mergeInt(&target.SeasonCount, source.SeasonCount, mode)
|
||||
mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode)
|
||||
mergeScalar(&target.LastAirDate, source.LastAirDate, mode)
|
||||
mergeScalar(&target.ShowStatus, source.ShowStatus, mode)
|
||||
if !isLocked(FieldAirSchedule) {
|
||||
mergeScalar(&target.AirTime, source.AirTime, mode)
|
||||
mergeScalar(&target.AirTimezone, source.AirTimezone, mode)
|
||||
@@ -117,6 +118,7 @@ func MergeGlobalMetadata(source, target *MetadataResult, locked []MetadataField,
|
||||
mergeInt(&target.SeasonCount, source.SeasonCount, mode)
|
||||
mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode)
|
||||
mergeScalar(&target.LastAirDate, source.LastAirDate, mode)
|
||||
mergeScalar(&target.ShowStatus, source.ShowStatus, mode)
|
||||
if !isLocked(FieldAirSchedule) {
|
||||
mergeScalar(&target.AirTime, source.AirTime, mode)
|
||||
mergeScalar(&target.AirTimezone, source.AirTimezone, mode)
|
||||
|
||||
@@ -257,6 +257,7 @@ func (p *PluginProvider) GetMetadata(ctx context.Context, req MetadataRequest) (
|
||||
PosterPath: response.GetItem().GetPosterPath(),
|
||||
PosterThumbhash: response.GetItem().GetPosterThumbhash(),
|
||||
BackdropPath: response.GetItem().GetBackdropPath(),
|
||||
ShowStatus: response.GetItem().GetStatus(),
|
||||
BackdropThumbhash: response.GetItem().GetBackdropThumbhash(),
|
||||
LogoPath: response.GetItem().GetLogoPath(),
|
||||
SeasonCount: int(response.GetItem().GetSeasonCount()),
|
||||
|
||||
@@ -180,6 +180,9 @@ type MetadataResult struct {
|
||||
LastAirDate string
|
||||
AirTime string
|
||||
AirTimezone string
|
||||
// ShowStatus is the publication/airing status ("Ongoing", "Completed",
|
||||
// "Continuing", "Ended") when the provider reports one.
|
||||
ShowStatus string
|
||||
}
|
||||
|
||||
// Ratings holds ratings from multiple sources.
|
||||
|
||||
@@ -320,6 +320,8 @@ type MediaItem struct {
|
||||
MetadataS3Path string
|
||||
MetadataEtag string
|
||||
SeasonCount *int // series only
|
||||
MangaChapterCount *int // manga series only: loose manga_chapters rows without a volume token
|
||||
MangaVolumeCount *int // manga series only: distinct non-empty volume tokens in manga_chapters
|
||||
Studios []string
|
||||
Networks []string
|
||||
Countries []string
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// mangaChapterWrite turns a parsed (volume, index, has) into the (index, volume)
|
||||
// values to persist: index is nil when has=false, volume is "" when absent.
|
||||
func mangaChapterWrite(volume string, index float64, has bool) (idx *float64, vol string) {
|
||||
if !has {
|
||||
return nil, ""
|
||||
}
|
||||
i := index
|
||||
return &i, volume
|
||||
}
|
||||
|
||||
// upsertMangaChapter inserts or updates a row in manga_chapters for the given
|
||||
// chapter. A nil index is stored as NULL (chapter number unparseable or absent).
|
||||
func upsertMangaChapter(ctx context.Context, pool *pgxpool.Pool, chapterID, seriesID string, index *float64, volume string) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO manga_chapters (chapter_content_id, series_content_id, chapter_index, volume, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (chapter_content_id) DO UPDATE SET
|
||||
series_content_id = EXCLUDED.series_content_id,
|
||||
chapter_index = EXCLUDED.chapter_index,
|
||||
volume = EXCLUDED.volume,
|
||||
updated_at = NOW()
|
||||
`, chapterID, seriesID, index, volume)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert manga_chapters row: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listMangaChapters returns the chapter_content_id values for all chapters
|
||||
// belonging to the given series, ordered by chapter_index (NULLs last) then
|
||||
// by content ID for a stable secondary sort.
|
||||
func listMangaChapters(ctx context.Context, pool *pgxpool.Pool, seriesID string) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT chapter_content_id
|
||||
FROM manga_chapters
|
||||
WHERE series_content_id = $1
|
||||
ORDER BY chapter_index NULLS LAST, chapter_content_id
|
||||
`, seriesID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list manga_chapters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan manga_chapters row: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate manga_chapters: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package scanner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMangaChapterWrite(t *testing.T) {
|
||||
idx, vol := mangaChapterWrite("v13", 13, true)
|
||||
if idx == nil || *idx != 13 || vol != "v13" {
|
||||
t.Fatalf("has=true: got (%v,%q), want (13,\"v13\")", idx, vol)
|
||||
}
|
||||
idx, vol = mangaChapterWrite("", 178, true)
|
||||
if idx == nil || *idx != 178 || vol != "" {
|
||||
t.Fatalf("has=true no vol: got (%v,%q), want (178,\"\")", idx, vol)
|
||||
}
|
||||
idx, vol = mangaChapterWrite("", 0, false)
|
||||
if idx != nil || vol != "" {
|
||||
t.Fatalf("has=false: got (%v,%q), want (nil,\"\")", idx, vol)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mangaSeriesWhitespace collapses any run of whitespace to a single space so a
|
||||
// series name keys identically regardless of incidental spacing.
|
||||
var mangaSeriesWhitespace = regexp.MustCompile(`\s+`)
|
||||
|
||||
// mangaTrailingParen matches a single trailing parenthetical group, allowing
|
||||
// optional whitespace before it. Applied repeatedly to strip all trailing
|
||||
// groups (year, year-range, "Digital", release-group names, etc.).
|
||||
var mangaTrailingParen = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
|
||||
|
||||
// cleanMangaSeriesName removes all trailing parenthetical groups (scene-release
|
||||
// metadata such as years, "Digital", and release-group tags) from a manga
|
||||
// folder name, then trims any dangling whitespace or trailing " -".
|
||||
//
|
||||
// Parentheticals in the middle of the name are left untouched so titles like
|
||||
// "JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra" are
|
||||
// preserved. The function is pure and idempotent. If stripping would produce
|
||||
// an empty string the original trimmed input is returned unchanged so a series
|
||||
// name is never empty.
|
||||
func cleanMangaSeriesName(name string) string {
|
||||
s := strings.TrimSpace(name)
|
||||
for {
|
||||
stripped := mangaTrailingParen.ReplaceAllString(s, "")
|
||||
if stripped == s {
|
||||
break
|
||||
}
|
||||
s = stripped
|
||||
}
|
||||
// Trim any trailing dash (with optional surrounding spaces) left after
|
||||
// stripping, e.g. "Series Name - (Digital)" → "Series Name -" → "Series Name".
|
||||
s = strings.TrimRight(s, " -")
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mangaSeriesGroupKey is the stable, library-scoped content-group key that all
|
||||
// chapters of one series resolve their series item by. It lowercases, trims,
|
||||
// and collapses internal whitespace so cosmetic variations of the same folder
|
||||
// name yield the same key. Returns "" for an empty name (caller must skip).
|
||||
func mangaSeriesGroupKey(folderID int, name string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
normalized = strings.TrimSpace(mangaSeriesWhitespace.ReplaceAllString(normalized, " "))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("manga:series:%d:%s", folderID, normalized)
|
||||
}
|
||||
|
||||
// mangaVolumeFolder matches directory names that are volume markers, not series.
|
||||
var mangaVolumeFolder = regexp.MustCompile(`(?i)^v(?:ol(?:ume)?\.?)?\s*\d+$`)
|
||||
|
||||
var (
|
||||
mangaVolYearIssue = regexp.MustCompile(`(?i)\b(Vol\.?\s*\d{4})\b.*?#\s*(\d+(?:\.\d+)?)`)
|
||||
mangaVolYearLabel = regexp.MustCompile(`(?i)\bvol\.?\s*\d{4}\b`) // strip a year-style "Vol.YYYY" so it never reads as an index
|
||||
// mangaVolume / mangaChapterC only match the abbreviated forms (v13, vol.4, c128, ch.5).
|
||||
// Full English words ("volume 3", "chapter 5") intentionally fall through to the bare-number path.
|
||||
mangaVolume = regexp.MustCompile(`(?i)\bv(?:ol\.?)?\s*(\d+(?:\.\d+)?)\b`)
|
||||
mangaChapterC = regexp.MustCompile(`(?i)\bc(?:h\.?)?\s*(\d+(?:\.\d+)?)\b`)
|
||||
mangaBareNumber = regexp.MustCompile(`\b(\d+(?:\.\d+)?)\b`)
|
||||
mangaParenNoise = regexp.MustCompile(`\([^)]*\)`) // (year) (Digital) (group) (Month, Year)
|
||||
)
|
||||
|
||||
// mangaSeriesFromPath returns the series name: the nearest ancestor directory of
|
||||
// the file whose name is not a volume marker.
|
||||
func mangaSeriesFromPath(filePath string) string {
|
||||
dir := filepath.Dir(filePath)
|
||||
for dir != "" && dir != "." && dir != string(filepath.Separator) {
|
||||
base := filepath.Base(dir)
|
||||
if !mangaVolumeFolder.MatchString(strings.TrimSpace(base)) {
|
||||
return cleanMangaSeriesName(base)
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mangaIndexForFile parses the volume/chapter index from a manga file's base
|
||||
// name (extension already stripped), first removing the series-name prefix so
|
||||
// numbers inside the series title (e.g. "404 Demons", "365 Days") are not
|
||||
// mistaken for the chapter/volume number. Falls back to the full base name when
|
||||
// the file does not start with the series name.
|
||||
func mangaIndexForFile(base, seriesName string) (volume string, index float64, has bool) {
|
||||
trimmedBase := strings.TrimSpace(base)
|
||||
trimmedSeries := strings.TrimSpace(seriesName)
|
||||
if trimmedSeries != "" && strings.HasPrefix(strings.ToLower(trimmedBase), strings.ToLower(trimmedSeries)) {
|
||||
remainder := trimmedBase[len(trimmedSeries):]
|
||||
return parseMangaIndex(remainder)
|
||||
}
|
||||
return parseMangaIndex(trimmedBase)
|
||||
}
|
||||
|
||||
// parseMangaIndex extracts the ordering index (volume or chapter number) and the
|
||||
// raw volume token from a manga release filename (extension already stripped).
|
||||
// Returns has=false when no number is present (e.g. a one-shot).
|
||||
//
|
||||
// The returned volume is a display token (e.g. "v13" or "Vol.2003") and is not
|
||||
// normalized across forms; callers should treat it as label text, not a key.
|
||||
func parseMangaIndex(name string) (volume string, index float64, has bool) {
|
||||
if m := mangaVolYearIssue.FindStringSubmatch(name); m != nil {
|
||||
if n, err := strconv.ParseFloat(m[2], 64); err == nil {
|
||||
return "v" + strings.TrimSpace(m[2]), n, true
|
||||
}
|
||||
}
|
||||
clean := strings.TrimSpace(mangaParenNoise.ReplaceAllString(name, " "))
|
||||
// A bare "Vol.YYYY" (no "#issue") is a year, not an index — strip it so it
|
||||
// never leaks into the volume/chapter/bare-number scans below.
|
||||
clean = mangaVolYearLabel.ReplaceAllString(clean, " ")
|
||||
if m := mangaVolume.FindStringSubmatch(clean); m != nil {
|
||||
if n, err := strconv.ParseFloat(m[1], 64); err == nil {
|
||||
return "v" + m[1], n, true
|
||||
}
|
||||
}
|
||||
if m := mangaChapterC.FindStringSubmatch(clean); m != nil {
|
||||
if n, err := strconv.ParseFloat(m[1], 64); err == nil {
|
||||
return "", n, true
|
||||
}
|
||||
}
|
||||
if m := mangaBareNumber.FindStringSubmatch(clean); m != nil {
|
||||
if n, err := strconv.ParseFloat(m[1], 64); err == nil {
|
||||
return "", n, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package scanner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMangaSeriesFromPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/m/manga/Official/Kurosagi Corpse Delivery Service/V2006/Kurosagi 10.cbz", "Kurosagi Corpse Delivery Service"},
|
||||
{"/m/manga/One-Punch Man/One-Punch Man 178 (2023) (Digital) (LuCaZ).cbz", "One-Punch Man"},
|
||||
{"/m/manga/Bakuman/v13/Bakuman v13 (2012).cbz", "Bakuman"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
if got := mangaSeriesFromPath(tc.path); got != tc.want {
|
||||
t.Fatalf("mangaSeriesFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangaSeriesGroupKey(t *testing.T) {
|
||||
a := mangaSeriesGroupKey(8, "One-Punch Man")
|
||||
b := mangaSeriesGroupKey(8, " one-punch man ")
|
||||
c := mangaSeriesGroupKey(8, "Bakuman")
|
||||
d := mangaSeriesGroupKey(9, "One-Punch Man")
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("same series must yield same key: %q vs %q", a, b)
|
||||
}
|
||||
if a == c || a == d {
|
||||
t.Fatalf("different series/library must differ: a=%q c=%q d=%q", a, c, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMangaIndex(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
wantVol string
|
||||
wantIdx float64
|
||||
wantHas bool
|
||||
}{
|
||||
{"bare chapter", "One-Punch Man 178 (2023) (Digital) (LuCaZ)", "", 178, true},
|
||||
{"volume", "Bakuman v13 (2012) (Digital) (aKraa)", "v13", 13, true},
|
||||
{"chapter c-prefix", "Dead Mount Death Play c128 (2025) (Digital) (UP!) (Oak)", "", 128, true},
|
||||
{"vol-year issue", "Berserk Vol.2003 #04 (July, 2004)", "v04", 4, true},
|
||||
{"vol-year issue real-world", "10 Things I Want to Do Before I Turn 40 Vol.2025 #01 (May, 2025)", "v01", 1, true},
|
||||
{"vol-year no issue", "Berserk Vol.2003 (2004)", "", 0, false},
|
||||
{"decimal chapter", "Kindergarten WARS 109.1 (2025) (Digital) (Rillant)", "", 109.1, true},
|
||||
{"subtitle then volume", "The Ancient Magus' Bride - Wizard's Blue v04 (2022) (Digital)", "v04", 4, true},
|
||||
{"no number", "Some Oneshot (2020) (Digital) (grp)", "", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
vol, idx, has := parseMangaIndex(tc.file)
|
||||
if vol != tc.wantVol || has != tc.wantHas || idx != tc.wantIdx {
|
||||
t.Fatalf("parseMangaIndex(%q) = (%q,%v,%v), want (%q,%v,%v)", tc.file, vol, idx, has, tc.wantVol, tc.wantIdx, tc.wantHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanMangaSeriesName(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
// Trailing parentheticals stripped.
|
||||
{"404 Demons (Digital) (Oak)", "404 Demons"},
|
||||
{"Arifureta - From Commonplace to World's Strongest (Digital) (1r0n)", "Arifureta - From Commonplace to World's Strongest"},
|
||||
{"Angels of Death Episode.0 (2019-2024) (Digital) (LuCaZ)", "Angels of Death Episode.0"},
|
||||
{"Angel of the Night - Lucian - One-shot (2026) (Digital)", "Angel of the Night - Lucian - One-shot"},
|
||||
{"'Tis Time for 'Torture,' Princess (2019-2026) (Digital) (Antrill-Oak)", "'Tis Time for 'Torture,' Princess"},
|
||||
{"A Certain Scientific Railgun - Astral Buddy (2019)", "A Certain Scientific Railgun - Astral Buddy"},
|
||||
// No junk — must be returned unchanged.
|
||||
{"Amefurashi", "Amefurashi"},
|
||||
// Guardrail: folder that is ONLY parentheticals — return original trimmed input.
|
||||
{"(2025) (Digital)", "(2025) (Digital)"},
|
||||
// Middle parentheticals must be preserved.
|
||||
{"JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra", "JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := cleanMangaSeriesName(tc.input)
|
||||
if got != tc.want {
|
||||
t.Fatalf("cleanMangaSeriesName(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangaIndexForFile(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
seriesName string
|
||||
wantVol string
|
||||
wantIdx float64
|
||||
wantHas bool
|
||||
}{
|
||||
{
|
||||
name: "404 Demons ch01 — series prefix stripped",
|
||||
base: "404 Demons 01 (Digital-Compilation) (Oak)",
|
||||
seriesName: "404 Demons",
|
||||
wantVol: "",
|
||||
wantIdx: 1,
|
||||
wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "404 Demons ch09",
|
||||
base: "404 Demons 09 (Digital-Compilation) (Oak)",
|
||||
seriesName: "404 Demons",
|
||||
wantVol: "",
|
||||
wantIdx: 9,
|
||||
wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "404 Demons v01 — volume token unambiguous",
|
||||
base: "404 Demons v01 (Digital-Compilation) (Oak)",
|
||||
seriesName: "404 Demons",
|
||||
wantVol: "v01",
|
||||
wantIdx: 1,
|
||||
wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "404 Demons v10",
|
||||
base: "404 Demons v10 (Digital-Compilation) (Oak)",
|
||||
seriesName: "404 Demons",
|
||||
wantVol: "v10",
|
||||
wantIdx: 10,
|
||||
wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "One-Punch Man ch178",
|
||||
base: "One-Punch Man 178 (2023) (Digital) (LuCaZ)",
|
||||
seriesName: "One-Punch Man",
|
||||
wantVol: "",
|
||||
wantIdx: 178,
|
||||
wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "365 Days to the Wedding v03 — series number not grabbed",
|
||||
base: "365 Days to the Wedding v03 (2024)",
|
||||
seriesName: "365 Days to the Wedding",
|
||||
wantVol: "v03",
|
||||
wantIdx: 3,
|
||||
wantHas: true,
|
||||
},
|
||||
{
|
||||
name: "404 Demons one-shot — no number after prefix",
|
||||
base: "404 Demons (2025) (Digital)",
|
||||
seriesName: "404 Demons",
|
||||
wantVol: "",
|
||||
wantIdx: 0,
|
||||
wantHas: false,
|
||||
},
|
||||
{
|
||||
name: "fallback — base does not start with series name",
|
||||
base: "Random 12",
|
||||
seriesName: "Different Series",
|
||||
wantVol: "",
|
||||
wantIdx: 12,
|
||||
wantHas: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
vol, idx, has := mangaIndexForFile(tc.base, tc.seriesName)
|
||||
if vol != tc.wantVol || idx != tc.wantIdx || has != tc.wantHas {
|
||||
t.Fatalf("mangaIndexForFile(%q, %q) = (%q, %v, %v), want (%q, %v, %v)",
|
||||
tc.base, tc.seriesName, vol, idx, has, tc.wantVol, tc.wantIdx, tc.wantHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMangaIndexCorpus is a regression test against real-world manga
|
||||
// release filenames following common scanlation naming conventions.
|
||||
// Extensions are already stripped (as parseMangaIndex expects).
|
||||
// At least 95% of names must yield has==true; pure one-shots with no number
|
||||
// are the only legitimate misses.
|
||||
func TestParseMangaIndexCorpus(t *testing.T) {
|
||||
corpus := []string{
|
||||
// bare chapter numbers (most common pattern)
|
||||
"One-Punch Man 178 (2023) (Digital) (LuCaZ)",
|
||||
"One-Punch Man 001 (2012) (Digital) (LuCaZ)",
|
||||
"Attack on Titan 139 (2021) (Digital) (Chromatic)",
|
||||
"Chainsaw Man 097 (2021) (Digital) (LuCaZ)",
|
||||
"Chainsaw Man 001 (2019) (Digital) (LuCaZ)",
|
||||
"Spy x Family 090 (2024) (Digital) (Izar)",
|
||||
"Demon Slayer - Kimetsu no Yaiba 205 (2020) (Digital) (LuCaZ)",
|
||||
"My Hero Academia 430 (2024) (Digital) (LuCaZ)",
|
||||
"Jujutsu Kaisen 271 (2024) (Digital) (LuCaZ)",
|
||||
"Vinland Saga 215 (2024) (Digital) (dAY)",
|
||||
// decimal chapter numbers
|
||||
"Kindergarten WARS 109.1 (2025) (Digital) (Rillant)",
|
||||
"Bleach 686.5 (2016) (Digital) (LuCaZ)",
|
||||
"One Piece 1000.1 (2021) (Digital) (LuCaZ)",
|
||||
"Berserk 364.1 (2022) (Digital) (Oak)",
|
||||
// volume prefix (vNN form)
|
||||
"Bakuman v13 (2012) (Digital) (aKraa)",
|
||||
"Fullmetal Alchemist v27 (2011) (Digital) (Izar)",
|
||||
"Death Note v12 (2006) (Digital) (Chromatic)",
|
||||
"Vinland Saga v26 (2022) (Digital) (dAY)",
|
||||
"The Ancient Magus' Bride - Wizard's Blue v04 (2022) (Digital)",
|
||||
"Blue Period v14 (2023) (Digital) (LuCaZ)",
|
||||
// volume prefix (vol. form)
|
||||
"Dragon Ball Vol.001 (2003) (Digital) (Izar)",
|
||||
"Naruto Vol.072 (2014) (Digital) (Chromatic)",
|
||||
"Bleach Vol.074 (2016) (Digital) (LuCaZ)",
|
||||
// chapter c-prefix
|
||||
"Dead Mount Death Play c128 (2025) (Digital) (UP!) (Oak)",
|
||||
"To Your Eternity c185 (2024) (Digital) (LuCaZ)",
|
||||
"Kaiju No. 8 ch.100 (2024) (Digital) (Izar)",
|
||||
// zero-padded chapter numbers
|
||||
"Berserk 001 (1990) (Digital) (Scans)",
|
||||
"Berserk 364 (2021) (Digital) (Oak)",
|
||||
"Vagabond 327 (2015) (Digital) (LuCaZ)",
|
||||
// series with hyphens and special chars in name
|
||||
"One-Punch Man 001 (2012) (Digital) (LuCaZ)",
|
||||
"Fullmetal Alchemist - Brotherhood 064 (2010) (Digital) (Izar)",
|
||||
"JoJo's Bizarre Adventure - Part 8 - JoJolion 110 (2021) (Digital) (Chromatic)",
|
||||
// high chapter numbers
|
||||
"One Piece 1100 (2023) (Digital) (LuCaZ)",
|
||||
"Fairy Tail 545 (2017) (Digital) (Chromatic)",
|
||||
// two-digit volumes
|
||||
"Berserk v41 (2022) (Digital) (Oak)",
|
||||
"Vagabond v37 (2009) (Digital) (LuCaZ)",
|
||||
}
|
||||
|
||||
misses := 0
|
||||
for _, name := range corpus {
|
||||
if _, _, has := parseMangaIndex(name); !has {
|
||||
misses++
|
||||
t.Logf("no index parsed: %q", name)
|
||||
}
|
||||
}
|
||||
// Allow a small fraction of legitimate one-shots with no number.
|
||||
if float64(misses)/float64(len(corpus)) > 0.05 {
|
||||
t.Fatalf("parser missed %d/%d (>5%%); investigate patterns above", misses, len(corpus))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/idgen"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/titleutil"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ScanMangaFolder scans a manga library. It is a fork of ScanEbookFolder: the
|
||||
// chapter files are kept as readable type='ebook' items exactly as the ebook
|
||||
// pipeline does, while each file additionally find-or-creates a single
|
||||
// type='manga' series item per series folder and links the chapter to it.
|
||||
func (s *Scanner) ScanMangaFolder(ctx context.Context, folder *models.MediaFolder) error {
|
||||
if s == nil || folder == nil {
|
||||
return fmt.Errorf("ScanMangaFolder: nil scanner or folder")
|
||||
}
|
||||
return s.scanMangaPaths(ctx, folder, folder.Paths, true)
|
||||
}
|
||||
|
||||
// scanMangaPaths mirrors scanEbookPaths exactly (root collection, worker pool,
|
||||
// missing-file reconciliation) but dispatches each file to reconcileMangaFile.
|
||||
func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder, roots []string, fullScan bool) error {
|
||||
if s == nil || folder == nil {
|
||||
return fmt.Errorf("scanMangaPaths: nil scanner or folder")
|
||||
}
|
||||
scans, err := collectEbookRootScans(ctx, folder.ID, roots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Every discovered file is indexed, including files under roots whose walk
|
||||
// partially failed: indexing is additive and safe, only the destructive
|
||||
// reconciliation below is restricted to cleanly walked roots.
|
||||
var candidates []string
|
||||
for i := range scans {
|
||||
candidates = append(candidates, scans[i].files...)
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return s.reconcileMangaScan(ctx, folder, scans, nil, fullScan)
|
||||
}
|
||||
|
||||
workers := ebookScanWorkers()
|
||||
slog.Info("manga scan: starting",
|
||||
"folder_id", folder.ID,
|
||||
"candidates", len(candidates),
|
||||
"workers", workers,
|
||||
)
|
||||
reportEbookScanProgress(ctx, folder.ID, len(candidates), 0, 0, 0)
|
||||
|
||||
ch := make(chan string, workers*2)
|
||||
groupLocks := newEbookGroupLocks()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
processed int64
|
||||
failed int64
|
||||
skipped int64
|
||||
failMu sync.Mutex
|
||||
failures []error
|
||||
cancelErr error
|
||||
)
|
||||
start := time.Now()
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for path := range ch {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.reconcileMangaFile(ctx, folder, path, &skipped, groupLocks); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
failMu.Lock()
|
||||
if cancelErr == nil {
|
||||
cancelErr = err
|
||||
}
|
||||
failMu.Unlock()
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&failed, 1)
|
||||
failMu.Lock()
|
||||
failures = append(failures, fmt.Errorf("%s: %w", path, err))
|
||||
failMu.Unlock()
|
||||
slog.Warn("manga scan: file failed",
|
||||
"folder_id", folder.ID,
|
||||
"path", path,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
n := atomic.AddInt64(&processed, 1)
|
||||
if n%500 == 0 || n == int64(len(candidates)) {
|
||||
failedCount := atomic.LoadInt64(&failed)
|
||||
skippedCount := atomic.LoadInt64(&skipped)
|
||||
slog.Info("manga scan: progress",
|
||||
"folder_id", folder.ID,
|
||||
"processed", n,
|
||||
"failed", failedCount,
|
||||
"skipped", skippedCount,
|
||||
"total", len(candidates),
|
||||
"elapsed_sec", int(time.Since(start).Seconds()),
|
||||
)
|
||||
reportEbookScanProgress(ctx, folder.ID, len(candidates), int(n), int(failedCount), int(skippedCount))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, p := range candidates {
|
||||
select {
|
||||
case ch <- p:
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelErr != nil {
|
||||
return cancelErr
|
||||
}
|
||||
|
||||
slog.Info("manga scan: completed",
|
||||
"folder_id", folder.ID,
|
||||
"processed", atomic.LoadInt64(&processed),
|
||||
"failed", atomic.LoadInt64(&failed),
|
||||
"skipped", atomic.LoadInt64(&skipped),
|
||||
"elapsed_sec", int(time.Since(start).Seconds()),
|
||||
)
|
||||
if processedCount := atomic.LoadInt64(&processed); processedCount > 0 {
|
||||
failedCount := atomic.LoadInt64(&failed)
|
||||
skippedCount := atomic.LoadInt64(&skipped)
|
||||
if failedCount > 0 && skippedCount == 0 && failedCount == processedCount {
|
||||
return fmt.Errorf("manga scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...))
|
||||
}
|
||||
}
|
||||
|
||||
seenPaths := make(map[string]bool, len(candidates))
|
||||
for _, p := range candidates {
|
||||
seenPaths[p] = true
|
||||
}
|
||||
return s.reconcileMangaScan(ctx, folder, scans, seenPaths, fullScan)
|
||||
}
|
||||
|
||||
// reconcileMangaScan runs the shared ebook missing-file reconciliation (which
|
||||
// removes vanished chapters) and then deletes any type='manga' series left with
|
||||
// no chapters. Series items are file-less parents reconciled by chapter count,
|
||||
// not file presence — catalog.ReconcileFolderMembership deliberately skips them.
|
||||
func (s *Scanner) reconcileMangaScan(ctx context.Context, folder *models.MediaFolder, scans []ebookRootScan, seenPaths map[string]bool, fullScan bool) error {
|
||||
if err := s.reconcileEbookScan(ctx, folder, scans, seenPaths, fullScan); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteOrphanedMangaSeries(ctx, folder.ID)
|
||||
}
|
||||
|
||||
// deleteOrphanedMangaSeries removes type='manga' series items in the folder that
|
||||
// have no remaining linked chapters (e.g. once every chapter was deleted as
|
||||
// missing). The cascade clears the now-empty library membership.
|
||||
func (s *Scanner) deleteOrphanedMangaSeries(ctx context.Context, folderID int) error {
|
||||
if s == nil || s.fileRepo == nil {
|
||||
return nil
|
||||
}
|
||||
tag, err := s.fileRepo.Pool().Exec(ctx, `
|
||||
DELETE FROM media_items mi
|
||||
WHERE mi.type = 'manga'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manga_chapters mc WHERE mc.series_content_id = mi.content_id
|
||||
)
|
||||
`, folderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting orphaned manga series for folder %d: %w", folderID, err)
|
||||
}
|
||||
if n := tag.RowsAffected(); n > 0 {
|
||||
slog.Info("manga scan: removed orphaned series", "folder_id", folderID, "deleted", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileMangaFile indexes one .cbz/.cbr chapter file: it keeps the file as a
|
||||
// readable type='ebook' chapter item (exactly as reconcileEbookFile does), then
|
||||
// find-or-creates the single type='manga' series item for the chapter's series
|
||||
// folder and links the chapter to it with its parsed index/volume.
|
||||
func (s *Scanner) reconcileMangaFile(ctx context.Context, folder *models.MediaFolder, filePath string, skipped *int64, groupLocks *ebookGroupLocks) error {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat manga file %s: %w", filePath, err)
|
||||
}
|
||||
size := info.Size()
|
||||
modifiedAt := normalizeFileModifiedAt(info.ModTime())
|
||||
|
||||
_, isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt)
|
||||
if skipErr != nil {
|
||||
slog.Warn("manga scan: skip-check failed, falling through",
|
||||
"folder_id", folder.ID,
|
||||
"path", filePath,
|
||||
"error", skipErr,
|
||||
)
|
||||
} else if isUnchanged {
|
||||
atomic.AddInt64(skipped, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := parseEbookFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse manga file %s: %w", filePath, err)
|
||||
}
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = ebookTitleFromPath(filePath)
|
||||
}
|
||||
|
||||
seriesName := mangaSeriesFromPath(filePath)
|
||||
if seriesName == "" {
|
||||
seriesName = ebookTitleFromPath(filePath)
|
||||
}
|
||||
stem := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
vol, idx, has := mangaIndexForFile(stem, seriesName)
|
||||
|
||||
// 1. Keep the file as a readable type='ebook' chapter item, exactly as the
|
||||
// ebook pipeline does (cover + page count + media file + membership).
|
||||
chapterGroupKey := ebookContentGroupKey(&parsed, filePath)
|
||||
chapterID, err := func() (string, error) {
|
||||
unlock := groupLocks.lock(chapterGroupKey)
|
||||
defer unlock()
|
||||
|
||||
contentID, curated, err := s.upsertEbookMediaItem(ctx, folder.ID, filePath, &parsed, chapterGroupKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upsert manga chapter item: %w", err)
|
||||
}
|
||||
if err := s.upsertEbookMediaFile(ctx, folder, contentID, filePath, size, modifiedAt, &parsed, chapterGroupKey); err != nil {
|
||||
return "", fmt.Errorf("upsert manga chapter file: %w", err)
|
||||
}
|
||||
if err := applyEbookLocalCover(ctx, s.itemRepo, s.imageCacher, contentID, filePath, &parsed); err != nil {
|
||||
slog.Warn("manga scan: local cover upload failed",
|
||||
"folder_id", folder.ID,
|
||||
"content_id", contentID,
|
||||
"path", filePath,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if err := s.upsertEbookPeople(ctx, contentID, &parsed, curated); err != nil {
|
||||
return "", fmt.Errorf("upsert manga chapter people: %w", err)
|
||||
}
|
||||
if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), contentID, folder.ID); err != nil {
|
||||
return "", fmt.Errorf("upsert manga chapter library membership: %w", err)
|
||||
}
|
||||
return contentID, nil
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Find-or-create the single type='manga' series item for this folder and
|
||||
// link the chapter to it.
|
||||
seriesID, err := s.findOrCreateMangaSeries(ctx, folder.ID, seriesName, groupLocks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find-or-create manga series: %w", err)
|
||||
}
|
||||
if seriesID != "" {
|
||||
// The series item carries no media file of its own, so it must be given a
|
||||
// library membership explicitly (the chapter path gets this via its file
|
||||
// reconcile). Without it the library-scoped catalog browse, which joins
|
||||
// media_item_libraries, would never surface the series card. The insert is
|
||||
// ON CONFLICT DO NOTHING, so re-scans never duplicate the membership.
|
||||
if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), seriesID, folder.ID); err != nil {
|
||||
return fmt.Errorf("upsert manga series library membership: %w", err)
|
||||
}
|
||||
idxPtr, volOut := mangaChapterWrite(vol, idx, has)
|
||||
if err := upsertMangaChapter(ctx, s.fileRepo.Pool(), chapterID, seriesID, idxPtr, volOut); err != nil {
|
||||
return fmt.Errorf("link manga chapter to series: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("manga scan: indexed",
|
||||
"folder_id", folder.ID,
|
||||
"chapter_id", chapterID,
|
||||
"series_id", seriesID,
|
||||
"series", seriesName,
|
||||
"path", filePath,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mangaSeriesProvider is the provider namespace under which a manga series
|
||||
// item's content-group key is recorded in media_item_provider_ids. The table's
|
||||
// UNIQUE (provider, provider_id, item_type) constraint guarantees exactly one
|
||||
// type='manga' series item per group key, which is what makes re-scans
|
||||
// idempotent across processes.
|
||||
const mangaSeriesProvider = "manga_series"
|
||||
|
||||
// findOrCreateMangaSeries resolves the single type='manga' series item for the
|
||||
// given series name in the folder, creating it on first sight. It is idempotent:
|
||||
// re-scanning any chapter of the same series resolves to the same series
|
||||
// content_id. The group-key lock serializes creation across this process's
|
||||
// worker goroutines; the SELECT-after-conflicting-INSERT recovers the winner's
|
||||
// content_id if another process raced us.
|
||||
func (s *Scanner) findOrCreateMangaSeries(ctx context.Context, folderID int, seriesName string, groupLocks *ebookGroupLocks) (string, error) {
|
||||
if s.itemRepo == nil {
|
||||
return "", fmt.Errorf("itemRepo not configured on Scanner")
|
||||
}
|
||||
if s.fileRepo == nil {
|
||||
return "", fmt.Errorf("fileRepo not configured on Scanner")
|
||||
}
|
||||
groupKey := mangaSeriesGroupKey(folderID, seriesName)
|
||||
if groupKey == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
unlock := groupLocks.lock(groupKey)
|
||||
defer unlock()
|
||||
|
||||
if existing, err := s.lookupMangaSeries(ctx, groupKey); err != nil {
|
||||
return "", err
|
||||
} else if existing != "" {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
id, err := idgen.NextID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate manga series content_id: %w", err)
|
||||
}
|
||||
title := strings.TrimSpace(seriesName)
|
||||
item := &models.MediaItem{
|
||||
ContentID: id,
|
||||
Type: "manga",
|
||||
// Explicit "pending" mirrors the ebook chapter path: enrichment promotes
|
||||
// it to "matched". This is the "needs metadata" status.
|
||||
Status: "pending",
|
||||
Title: title,
|
||||
SortTitle: titleutil.DeriveDefaultSortTitle(title),
|
||||
}
|
||||
if err := s.itemRepo.Upsert(ctx, item); err != nil {
|
||||
return "", fmt.Errorf("create manga series item: %w", err)
|
||||
}
|
||||
|
||||
tag, err := s.fileRepo.Pool().Exec(ctx, `
|
||||
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
|
||||
VALUES ($1, $2, $3, 'manga')
|
||||
ON CONFLICT (provider, provider_id, item_type) DO NOTHING
|
||||
`, id, mangaSeriesProvider, groupKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("record manga series key: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Another process created the series first; our freshly minted item is a
|
||||
// dangling orphan. Delete it and adopt the winner so no duplicate series
|
||||
// survives.
|
||||
winner, lookupErr := s.lookupMangaSeries(ctx, groupKey)
|
||||
if lookupErr != nil {
|
||||
return "", lookupErr
|
||||
}
|
||||
if winner != "" && winner != id {
|
||||
if _, delErr := s.fileRepo.Pool().Exec(ctx,
|
||||
`DELETE FROM media_items WHERE content_id = $1`, id); delErr != nil {
|
||||
slog.Warn("manga scan: failed to delete duplicate series item",
|
||||
"folder_id", folderID,
|
||||
"content_id", id,
|
||||
"error", delErr,
|
||||
)
|
||||
}
|
||||
return winner, nil
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// lookupMangaSeries returns the content_id of the type='manga' series item
|
||||
// already recorded for the group key, or "" if none exists.
|
||||
func (s *Scanner) lookupMangaSeries(ctx context.Context, groupKey string) (string, error) {
|
||||
var id string
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT content_id
|
||||
FROM media_item_provider_ids
|
||||
WHERE provider = $1 AND provider_id = $2 AND item_type = 'manga'
|
||||
LIMIT 1
|
||||
`, mangaSeriesProvider, groupKey).Scan(&id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("lookup manga series by key: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -14,6 +14,14 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool {
|
||||
if file == nil {
|
||||
return true
|
||||
}
|
||||
// Ebook/comic files (epub, pdf, cbz, cbr — including manga chapters, which
|
||||
// are BaseType "ebook") are read directly by the reader and never go through
|
||||
// the transcode/playback probe pipeline. ffprobe yields nothing useful for
|
||||
// them, so requiring probe metadata re-ran ffprobe on every detail/watch
|
||||
// load and never converged.
|
||||
if file.BaseType == "ebook" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(file.ProbeSource) == "" || file.ProbeUpdatedAt == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// Ebook/comic files (epub, pdf, cbz, cbr — including manga chapters, which are
|
||||
// BaseType "ebook") are read directly and never carry ffprobe playback
|
||||
// metadata. Treating them as needing repair re-ran ffprobe on every detail
|
||||
// load and never converged.
|
||||
func TestNeedsCriticalProbeRepair_EbookFileNeverNeedsRepair(t *testing.T) {
|
||||
f := &models.MediaFile{
|
||||
BaseType: "ebook",
|
||||
Container: "epub",
|
||||
// no ProbeUpdatedAt, no audio/video — the unprobed state ebooks ship in
|
||||
}
|
||||
if NeedsCriticalProbeRepair(f) {
|
||||
t.Fatal("an ebook/comic file must not need probe repair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsCriticalProbeRepair_UnprobedNonEbookFileRepairs(t *testing.T) {
|
||||
if !NeedsCriticalProbeRepair(&models.MediaFile{}) {
|
||||
t.Fatal("an unprobed non-ebook file must need probe repair")
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,13 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isMangaLibraryType(folder.Type) {
|
||||
if err := s.ScanMangaFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if err := s.ScanEbookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
@@ -299,6 +306,12 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
if isMangaLibraryType(folder.Type) {
|
||||
if err := s.scanMangaPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if err := s.scanEbookPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil {
|
||||
return nil, err
|
||||
@@ -360,6 +373,15 @@ func isEbookLibraryType(libraryType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isMangaLibraryType(t string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "manga":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// walkMode tells walkLogicalTree which file extensions to surface and
|
||||
// which library-specific filename heuristics (sample/extra skipping)
|
||||
// to apply.
|
||||
@@ -386,6 +408,9 @@ func walkModeFor(folderType string) walkMode {
|
||||
return walkModePodcast
|
||||
case isEbookLibraryType(folderType):
|
||||
return walkModeEbook
|
||||
case isMangaLibraryType(folderType):
|
||||
// Manga chapters are .cbz/.cbr archives, surfaced by the ebook walk.
|
||||
return walkModeEbook
|
||||
default:
|
||||
return walkModeVideo
|
||||
}
|
||||
|
||||
@@ -221,6 +221,25 @@ func TestIsEbookLibraryType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMangaLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"manga", true},
|
||||
{"Manga", true},
|
||||
{" MANGA ", true},
|
||||
{"ebooks", false},
|
||||
{"movies", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isMangaLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isMangaLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeForEbookLibraryTypes(t *testing.T) {
|
||||
for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} {
|
||||
if got := walkModeFor(libraryType); got != walkModeEbook {
|
||||
|
||||
@@ -92,13 +92,24 @@ func generatedHomeLibraryRecentID(section *PageSection, libraryID int) string {
|
||||
}
|
||||
|
||||
func generatedHomeLibraryRecentDefaults(libraryID int, libraryName, libraryType string) []*PageSection {
|
||||
// addedConfig/releasedConfig default to the library-scoped (no media_scope)
|
||||
// generated config. A manga library mixes type='manga' series with
|
||||
// type='ebook' chapters, so we scope its generated home rows to the series
|
||||
// only — otherwise the chapter junk filenames leak into the home page.
|
||||
addedConfig := GeneratedHomeLibraryRecentConfig(libraryID)
|
||||
releasedConfig := GeneratedHomeLibraryRecentConfig(libraryID)
|
||||
if libraryType == "manga" {
|
||||
addedConfig = GeneratedHomeLibraryRecentConfigScoped(libraryID, "manga")
|
||||
releasedConfig = GeneratedHomeLibraryRecentConfigScoped(libraryID, "manga")
|
||||
}
|
||||
|
||||
sections := []*PageSection{
|
||||
{
|
||||
Scope: "home",
|
||||
SectionType: SectionRecentlyAdded,
|
||||
Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyAdded, libraryName),
|
||||
ItemLimit: 20,
|
||||
Config: GeneratedHomeLibraryRecentConfig(libraryID),
|
||||
Config: addedConfig,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
@@ -119,7 +130,7 @@ func generatedHomeLibraryRecentDefaults(libraryID int, libraryName, libraryType
|
||||
SectionType: SectionRecentlyReleased,
|
||||
Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyReleased, libraryName),
|
||||
ItemLimit: 20,
|
||||
Config: GeneratedHomeLibraryRecentConfig(libraryID),
|
||||
Config: releasedConfig,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
@@ -207,6 +218,16 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-ebooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("ebook"), Enabled: true},
|
||||
}
|
||||
case "manga":
|
||||
// Manga libraries browse the series items (media_items.type='manga');
|
||||
// the per-chapter ebook items are scoped out by the "manga" media scope.
|
||||
return []*PageSection{
|
||||
{ID: "default-continue-reading", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Reading", ItemLimit: 20, Config: ContinueTypeConfig(ContinueTypeReading), Enabled: true},
|
||||
{ID: "default-recently-added-manga", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Manga", ItemLimit: 20, Config: defaultMediaScopeConfig("manga"), Enabled: true},
|
||||
{ID: "default-recently-released-manga", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Manga", ItemLimit: 20, Config: defaultMediaScopeConfig("manga"), Enabled: true},
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-manga", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("manga"), Enabled: true},
|
||||
}
|
||||
default:
|
||||
return DefaultLibrarySections(libraryID)
|
||||
}
|
||||
|
||||
@@ -386,6 +386,60 @@ func TestDefaultLibrarySectionsForTypeEbooks(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeManga(t *testing.T) {
|
||||
libraryID := 13
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "manga")
|
||||
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("expected 5 manga default sections, got %d", len(got))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
id string
|
||||
sectionType SectionType
|
||||
title string
|
||||
position int
|
||||
}{
|
||||
{index: 0, id: "default-continue-reading", sectionType: SectionContinueWatching, title: "Continue Reading", position: 0},
|
||||
{index: 1, id: "default-recently-added-manga", sectionType: SectionRecentlyAdded, title: "Recently Added Manga", position: 1},
|
||||
{index: 2, id: "default-recently-released-manga", sectionType: SectionRecentlyReleased, title: "Recently Released Manga", position: 2},
|
||||
{index: 3, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 3},
|
||||
{index: 4, id: "default-random-manga", sectionType: SectionRandom, title: "Random Picks", position: 4},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
section := got[tt.index]
|
||||
if section.ID != tt.id {
|
||||
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
|
||||
}
|
||||
if section.SectionType != tt.sectionType {
|
||||
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
|
||||
}
|
||||
if section.Title != tt.title {
|
||||
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
|
||||
}
|
||||
if section.Position != tt.position {
|
||||
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
|
||||
}
|
||||
}
|
||||
|
||||
// The manga library browses only its series items: every query section is
|
||||
// scoped to media_items.type='manga', so the per-chapter ebook items are
|
||||
// excluded from the library feed.
|
||||
assertContinueType(t, got[0].Config, ContinueTypeReading)
|
||||
mangaScope := catalog.QueryDefinition{
|
||||
MediaScope: "manga",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
}
|
||||
assertQueryDefinition(t, got[1].Config, mangaScope)
|
||||
assertQueryDefinition(t, got[2].Config, mangaScope)
|
||||
assertEmptyJSON(t, got[3].Config)
|
||||
assertQueryDefinition(t, got[4].Config, mangaScope)
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) {
|
||||
libraryID := 99
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "mixed")
|
||||
@@ -501,3 +555,54 @@ func TestHomeDefaultsIncludeRecipeRichSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedHomeLibraryRecentDefaultsMangaScope(t *testing.T) {
|
||||
got := generatedHomeLibraryRecentDefaults(7, "Manga", "manga")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 generated manga home sections, got %d", len(got))
|
||||
}
|
||||
|
||||
wantTitles := map[SectionType]string{
|
||||
SectionRecentlyAdded: "Recently Added in Manga",
|
||||
SectionRecentlyReleased: "Recently Released in Manga",
|
||||
}
|
||||
for _, sec := range got {
|
||||
wantTitle, ok := wantTitles[sec.SectionType]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected section type %s", sec.SectionType)
|
||||
}
|
||||
if sec.Title != wantTitle {
|
||||
t.Fatalf("section %s title = %q, want %q", sec.SectionType, sec.Title, wantTitle)
|
||||
}
|
||||
|
||||
def, err := ParseQueryDefinition(sec.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseQueryDefinition(%s) error = %v", sec.SectionType, err)
|
||||
}
|
||||
if def.MediaScope != "manga" {
|
||||
t.Fatalf("section %s media_scope = %q, want manga", sec.SectionType, def.MediaScope)
|
||||
}
|
||||
if len(def.LibraryIDs) != 1 || def.LibraryIDs[0] != 7 {
|
||||
t.Fatalf("section %s library_ids = %v, want [7]", sec.SectionType, def.LibraryIDs)
|
||||
}
|
||||
if id, ok := ParseGeneratedHomeLibraryRecentConfig(sec.Config); !ok || id != 7 {
|
||||
t.Fatalf("section %s generated config id = %d ok = %v, want 7 true", sec.SectionType, id, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedHomeLibraryRecentDefaultsNonMangaNoScope(t *testing.T) {
|
||||
got := generatedHomeLibraryRecentDefaults(7, "Movies", "movies")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 generated movies home sections, got %d", len(got))
|
||||
}
|
||||
for _, sec := range got {
|
||||
def, err := ParseQueryDefinition(sec.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseQueryDefinition(%s) error = %v", sec.SectionType, err)
|
||||
}
|
||||
if def.MediaScope == "manga" {
|
||||
t.Fatalf("section %s unexpectedly carries manga media_scope", sec.SectionType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +397,14 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res
|
||||
if err != nil {
|
||||
return SectionWithItems{}, err
|
||||
}
|
||||
// Manga chapters are ebook items linked to a series; collapse multiple
|
||||
// in-progress chapters of the same manga to a single card (keeping the
|
||||
// most recently read), mirroring the episode→series collapse. Resolve
|
||||
// the linkage into itemMeta so the shared collapse can group by series.
|
||||
if len(orderedItems) > 1 {
|
||||
f.applyMangaChapterSeriesMeta(ctx, orderedItems, itemMeta)
|
||||
orderedItems = collapseContinueWatchingSeriesCandidates(orderedItems, itemMeta)
|
||||
}
|
||||
return SectionWithItems{
|
||||
ResolvedSection: resolved,
|
||||
Items: orderedItems,
|
||||
@@ -1580,6 +1588,8 @@ func (f *Fetcher) fetchFormatShowcase(ctx context.Context, s ResolvedSection, li
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
whereClause := "WHERE " + strings.Join(conditions, " AND ")
|
||||
|
||||
limit := s.ItemLimit
|
||||
@@ -2008,6 +2018,8 @@ func buildRecentlyAddedQuery(s ResolvedSection, libraryID *int, libraryIDs []int
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||||
@@ -2015,7 +2027,7 @@ func buildRecentlyAddedQuery(s ResolvedSection, libraryID *int, libraryIDs []int
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %s FROM %s %s ORDER BY mi.created_at DESC, mi.content_id ASC LIMIT $%d`,
|
||||
itemColumns("mi"), fromClause, whereClause, argIdx,
|
||||
itemColumnsLatestMangaPoster("mi"), fromClause, whereClause, argIdx,
|
||||
)
|
||||
args = append(args, s.ItemLimit)
|
||||
return query, args
|
||||
@@ -2071,16 +2083,18 @@ func buildRecentlyAddedSingleLibraryQuery(s ResolvedSection, cfgFilters SectionC
|
||||
applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx)
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
whereClause := "WHERE " + strings.Join(conditions, " AND ")
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %s FROM media_item_libraries mil JOIN media_items mi ON mi.content_id = mil.content_id %s ORDER BY mil.first_seen_at DESC, mil.content_id ASC LIMIT $%d`,
|
||||
itemColumns("mi"), whereClause, argIdx,
|
||||
itemColumnsLatestMangaPoster("mi"), whereClause, argIdx,
|
||||
)
|
||||
args = append(args, s.ItemLimit)
|
||||
return sectionQuery{sql: query, args: args}, true
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
|
||||
func buildRecentlyReleasedQuery(s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (string, []any) {
|
||||
cfgFilters := ParseConfigFilters(s.Config)
|
||||
|
||||
var conditions []string
|
||||
@@ -2095,6 +2109,8 @@ func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection,
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||||
@@ -2102,9 +2118,14 @@ func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection,
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %s FROM %s %s ORDER BY mi.year DESC, mi.created_at DESC LIMIT $%d`,
|
||||
itemColumns("mi"), fromClause, whereClause, argIdx,
|
||||
itemColumnsLatestMangaPoster("mi"), fromClause, whereClause, argIdx,
|
||||
)
|
||||
args = append(args, s.ItemLimit)
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
|
||||
query, args := buildRecentlyReleasedQuery(s, libraryID, libraryIDs, filter)
|
||||
|
||||
rows, err := f.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -2152,7 +2173,7 @@ func (f *Fetcher) fetchFiltered(ctx context.Context, s ResolvedSection, libraryI
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
|
||||
func buildRandomQuery(s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (string, []any, int) {
|
||||
cfgFilters := ParseConfigFilters(s.Config)
|
||||
|
||||
var conditions []string
|
||||
@@ -2167,6 +2188,8 @@ func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||||
@@ -2190,6 +2213,11 @@ func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID
|
||||
fromClause, whereClause, argIdx,
|
||||
)
|
||||
args = append(args, queryLimit)
|
||||
return query, args, limit
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
|
||||
query, args, limit := buildRandomQuery(s, libraryID, libraryIDs, filter)
|
||||
|
||||
rows, err := f.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -2421,9 +2449,10 @@ func utcDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// itemColumns returns the SELECT column list matching scanMediaItems.
|
||||
// Mirrors catalog.browseItemColumns.
|
||||
func itemColumns(alias string) string {
|
||||
// itemColumnsList returns the alias-prefixed SELECT columns matching
|
||||
// scanMediaItems, in scan order. Mirrors catalog.browseItemColumns. The shared
|
||||
// source of truth for itemColumns and its manga-poster-override variant.
|
||||
func itemColumnsList(alias string) []string {
|
||||
cols := []string{
|
||||
"content_id", "type", "title", "sort_title", "original_title", "year", "genres",
|
||||
"content_rating", "runtime", "overview", "tagline",
|
||||
@@ -2439,7 +2468,53 @@ func itemColumns(alias string) string {
|
||||
for i, c := range cols {
|
||||
prefixed[i] = alias + "." + c
|
||||
}
|
||||
return strings.Join(prefixed, ", ")
|
||||
return prefixed
|
||||
}
|
||||
|
||||
// itemColumns returns the SELECT column list matching scanMediaItems.
|
||||
// Mirrors catalog.browseItemColumns.
|
||||
func itemColumns(alias string) string {
|
||||
return strings.Join(itemColumnsList(alias), ", ")
|
||||
}
|
||||
|
||||
// itemColumnsLatestMangaPoster returns the same SELECT column list as
|
||||
// itemColumns (identical order and aliases, so scanMediaItems is unchanged) but
|
||||
// overrides poster_path/poster_thumbhash for type='manga' SERIES rows: a manga
|
||||
// series card shows the cover of its latest-added volume/chapter (the linked
|
||||
// manga_chapters row with the greatest created_at) instead of the AniList series
|
||||
// cover, falling back to the series' own poster when no chapter cover exists.
|
||||
//
|
||||
// Strictly gated on mi.type = 'manga' so movies/TV/audiobooks/ebooks keep their
|
||||
// own poster exactly. Used only by the recently-added / recently-released
|
||||
// section builders; all other queries keep itemColumns.
|
||||
func itemColumnsLatestMangaPoster(alias string) string {
|
||||
cols := itemColumnsList(alias)
|
||||
for i, c := range cols {
|
||||
switch c {
|
||||
case alias + ".poster_path":
|
||||
cols[i] = mangaLatestVolumePosterExpr(alias, "poster_path")
|
||||
case alias + ".poster_thumbhash":
|
||||
cols[i] = mangaLatestVolumePosterExpr(alias, "poster_thumbhash")
|
||||
}
|
||||
}
|
||||
return strings.Join(cols, ", ")
|
||||
}
|
||||
|
||||
// mangaLatestVolumePosterExpr emits the manga-gated CASE override for a single
|
||||
// poster column, aliased back to the original column name so the scan order and
|
||||
// column set are unchanged.
|
||||
func mangaLatestVolumePosterExpr(alias, col string) string {
|
||||
// NULLIF(...,'') on each operand: poster columns default to '' (empty
|
||||
// string), not NULL, so a plain COALESCE would surface a cover-less latest
|
||||
// chapter's empty poster instead of falling back to the series' own cover.
|
||||
// Mirrors the episode poster expressions above; trailing '' keeps the THEN
|
||||
// branch non-NULL.
|
||||
return "CASE WHEN " + alias + ".type = 'manga' THEN COALESCE(NULLIF((" +
|
||||
"SELECT c." + col + " FROM media_items c " +
|
||||
"JOIN manga_chapters mc ON mc.chapter_content_id = c.content_id " +
|
||||
"WHERE mc.series_content_id = " + alias + ".content_id " +
|
||||
"ORDER BY c.created_at DESC, c.content_id DESC LIMIT 1), ''), " +
|
||||
"NULLIF(" + alias + "." + col + ", ''), '') ELSE " + alias + "." + col + " END AS " + col
|
||||
}
|
||||
|
||||
// scanMediaItems scans rows into MediaItem slices. Must match itemColumns order.
|
||||
@@ -2611,6 +2686,8 @@ func (f *Fetcher) fetchTrending(ctx context.Context, s ResolvedSection, libraryI
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
conditions = append(conditions, fmt.Sprintf("uwh.watched_at > NOW() - $%d::interval", argIdx))
|
||||
args = append(args, interval)
|
||||
argIdx++
|
||||
@@ -2738,6 +2815,8 @@ func (f *Fetcher) fetchNewToLibrary(ctx context.Context, s ResolvedSection, libr
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
conditions = append(conditions, fmt.Sprintf("mi.created_at > NOW() - ($%d || ' days')::interval", argIdx))
|
||||
args = append(args, days)
|
||||
argIdx++
|
||||
@@ -2787,6 +2866,8 @@ func (f *Fetcher) fetchMostWatched(ctx context.Context, s ResolvedSection, libra
|
||||
argIdx = newArgIdx
|
||||
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
|
||||
|
||||
conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi"))
|
||||
|
||||
conditions = append(conditions, fmt.Sprintf("uwh.watched_at > NOW() - $%d::interval", argIdx))
|
||||
args = append(args, interval)
|
||||
argIdx++
|
||||
@@ -3110,3 +3191,64 @@ func (f *Fetcher) fetchMoodCollection(ctx context.Context, s ResolvedSection, li
|
||||
}
|
||||
return items, len(items), nil
|
||||
}
|
||||
|
||||
// mangaChapterSeriesMetaQuery resolves the owning manga series for chapter
|
||||
// items (type='ebook' rows linked via manga_chapters) appearing on section
|
||||
// cards, so continue-reading surfaces can show the series instead of the
|
||||
// chapter's raw file title.
|
||||
const mangaChapterSeriesMetaQuery = `
|
||||
SELECT mc.chapter_content_id, mc.series_content_id, si.title
|
||||
FROM manga_chapters mc
|
||||
JOIN media_items si ON si.content_id = mc.series_content_id
|
||||
WHERE mc.chapter_content_id = ANY($1)
|
||||
`
|
||||
|
||||
// FetchMangaChapterSeriesMeta returns series linkage keyed by chapter content
|
||||
// id. IDs that are not manga chapters simply have no entry.
|
||||
func (f *Fetcher) FetchMangaChapterSeriesMeta(ctx context.Context, ids []string) (map[string]SectionItemMeta, error) {
|
||||
meta := make(map[string]SectionItemMeta, len(ids))
|
||||
if f == nil || f.pool == nil || len(ids) == 0 {
|
||||
return meta, nil
|
||||
}
|
||||
rows, err := f.pool.Query(ctx, mangaChapterSeriesMetaQuery, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var chapterID, seriesID, seriesTitle string
|
||||
if err := rows.Scan(&chapterID, &seriesID, &seriesTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := seriesID
|
||||
meta[chapterID] = SectionItemMeta{SeriesID: &id, SeriesTitle: seriesTitle}
|
||||
}
|
||||
return meta, rows.Err()
|
||||
}
|
||||
|
||||
// applyMangaChapterSeriesMeta resolves the owning manga series for any chapter
|
||||
// (ebook) items in the set and merges SeriesID/SeriesTitle into the existing
|
||||
// itemMeta, preserving the progress fields already populated there. Lets the
|
||||
// shared series-collapse group multiple in-progress chapters of one manga.
|
||||
func (f *Fetcher) applyMangaChapterSeriesMeta(ctx context.Context, items []*models.MediaItem, itemMeta map[string]SectionItemMeta) {
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil && item.Type == "ebook" && strings.TrimSpace(item.ContentID) != "" {
|
||||
ids = append(ids, item.ContentID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
seriesMeta, err := f.FetchMangaChapterSeriesMeta(ctx, ids)
|
||||
if err != nil {
|
||||
slog.Warn("continue-reading: manga series linkage lookup failed", "error", err)
|
||||
return
|
||||
}
|
||||
for chapterID, sm := range seriesMeta {
|
||||
m := itemMeta[chapterID]
|
||||
m.SeriesID = sm.SeriesID
|
||||
m.SeriesTitle = sm.SeriesTitle
|
||||
itemMeta[chapterID] = m
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,3 +230,37 @@ func contentIDs(items []*models.MediaItem) []string {
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestCollapseContinueWatchingSeriesCandidatesCollapsesMangaChapters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
seriesID := "manga-7"
|
||||
older := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)
|
||||
newer := time.Date(2025, 3, 5, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Two in-progress chapters (ebook items) of one manga series, plus an
|
||||
// unrelated ebook with no series — mirrors applyMangaChapterSeriesMeta's
|
||||
// output feeding the shared collapse.
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ch-12", Type: "ebook", Title: "Series v12"},
|
||||
{ContentID: "ch-09", Type: "ebook", Title: "Series v09"},
|
||||
{ContentID: "book-x", Type: "ebook", Title: "A standalone ebook"},
|
||||
}
|
||||
meta := map[string]SectionItemMeta{
|
||||
"ch-12": {SeriesID: &seriesID, ItemSource: "in_progress", SortTimestamp: newer},
|
||||
"ch-09": {SeriesID: &seriesID, ItemSource: "in_progress", SortTimestamp: older},
|
||||
}
|
||||
|
||||
collapsed := collapseContinueWatchingSeriesCandidates(items, meta)
|
||||
|
||||
gotIDs := contentIDs(collapsed)
|
||||
wantIDs := []string{"ch-12", "book-x"} // most-recent chapter kept; standalone untouched
|
||||
if len(gotIDs) != len(wantIDs) {
|
||||
t.Fatalf("collapsed IDs = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if gotIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("collapsed IDs = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,38 @@ func GeneratedHomeLibraryRecentConfig(libraryID int) json.RawMessage {
|
||||
return config
|
||||
}
|
||||
|
||||
// GeneratedHomeLibraryRecentConfigScoped builds the generated home "recent"
|
||||
// config for a library while constraining results to a single media scope.
|
||||
// This is required for mixed-type libraries (e.g. manga, which contains both
|
||||
// type='manga' series and type='ebook' chapters) so the auto-generated home
|
||||
// rows only surface the series and not the junk chapter filenames. It mirrors
|
||||
// the modern QueryDefinition shape used by GeneratedHomeLibraryRecentEpisodesConfig
|
||||
// (library_ids + media_scope) — note we intentionally avoid filter_library_id
|
||||
// here, since that flat key routes the config through the legacy parser which
|
||||
// drops media_scope. Library targeting comes from both library_ids and the
|
||||
// generated_library_id metadata read by parseGeneratedHomeLibraryRecentConfig.
|
||||
func GeneratedHomeLibraryRecentConfigScoped(libraryID int, mediaScope string) json.RawMessage {
|
||||
config, err := json.Marshal(struct {
|
||||
catalog.QueryDefinition
|
||||
GeneratedLibraryID int `json:"generated_library_id"`
|
||||
GeneratedSource string `json:"generated_source"`
|
||||
}{
|
||||
QueryDefinition: catalog.QueryDefinition{
|
||||
LibraryIDs: []int{libraryID},
|
||||
MediaScope: mediaScope,
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
}.Normalize(),
|
||||
GeneratedLibraryID: libraryID,
|
||||
GeneratedSource: GeneratedHomeLibraryRecentSource,
|
||||
})
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func GeneratedHomeLibraryRecentEpisodesConfig(libraryID int) json.RawMessage {
|
||||
config, err := json.Marshal(struct {
|
||||
catalog.QueryDefinition
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package sections
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
// mangaChapterExclusionSQL is the predicate that library-listing section
|
||||
// builders must carry so manga CHAPTER rows (type='ebook' linked into a manga
|
||||
// series) never surface as standalone cards.
|
||||
const mangaChapterExclusionSQL = "NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)"
|
||||
|
||||
func TestRecentlyAddedQueriesExcludeMangaChapters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generic multi-library path.
|
||||
generic, _ := buildRecentlyAddedQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{"filter_library_ids":[1,2],"filter_type":"movie"}`),
|
||||
}, nil, nil, catalog.AccessFilter{})
|
||||
if !strings.Contains(generic, mangaChapterExclusionSQL) {
|
||||
t.Fatalf("recently-added generic query missing manga-chapter exclusion:\n%s", generic)
|
||||
}
|
||||
|
||||
// Single-library fast path.
|
||||
single, _ := buildRecentlyAddedQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{"filter_library_id":1,"filter_type":"movie"}`),
|
||||
}, nil, []int{1, 2}, catalog.AccessFilter{})
|
||||
if !strings.Contains(single, mangaChapterExclusionSQL) {
|
||||
t.Fatalf("recently-added single-library query missing manga-chapter exclusion:\n%s", single)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyReleasedQueryExcludesMangaChapters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query, _ := buildRecentlyReleasedQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{}`),
|
||||
}, nil, nil, catalog.AccessFilter{})
|
||||
if !strings.Contains(query, mangaChapterExclusionSQL) {
|
||||
t.Fatalf("recently-released query missing manga-chapter exclusion:\n%s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomQueryExcludesMangaChapters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query, _, _ := buildRandomQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{}`),
|
||||
}, nil, nil, catalog.AccessFilter{})
|
||||
if !strings.Contains(query, mangaChapterExclusionSQL) {
|
||||
t.Fatalf("random query missing manga-chapter exclusion:\n%s", query)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package sections
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
// The recently-added/released section cards for a manga SERIES must show the
|
||||
// cover of the latest-added volume/chapter (greatest created_at) instead of
|
||||
// the AniList series cover. The override is implemented as a manga-gated
|
||||
// (type='manga') CASE that pulls the newest linked chapter's poster, falling
|
||||
// back to the series' own poster. Non-manga rows must keep mi.poster_path
|
||||
// exactly.
|
||||
func assertMangaPosterOverride(t *testing.T, label, query string) {
|
||||
t.Helper()
|
||||
for _, frag := range []string{
|
||||
"CASE WHEN mi.type = 'manga'",
|
||||
"manga_chapters mc ON mc.chapter_content_id = c.content_id",
|
||||
"mc.series_content_id = mi.content_id",
|
||||
"ORDER BY c.created_at DESC",
|
||||
"AS poster_path",
|
||||
"AS poster_thumbhash",
|
||||
// Poster columns default to '' (not NULL), so the override must NULLIF
|
||||
// each operand or a cover-less latest chapter blanks the series card.
|
||||
"COALESCE(NULLIF(",
|
||||
"NULLIF(mi.poster_path, '')",
|
||||
"NULLIF(mi.poster_thumbhash, '')",
|
||||
} {
|
||||
if !strings.Contains(query, frag) {
|
||||
t.Fatalf("%s query missing manga poster-override fragment %q:\n%s", label, frag, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyAddedQueriesUseLatestMangaVolumePoster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
generic, _ := buildRecentlyAddedQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{"filter_library_ids":[1,2]}`),
|
||||
}, nil, nil, catalog.AccessFilter{})
|
||||
assertMangaPosterOverride(t, "recently-added generic", generic)
|
||||
|
||||
single, _ := buildRecentlyAddedQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{"filter_library_id":1}`),
|
||||
}, nil, []int{1, 2}, catalog.AccessFilter{})
|
||||
assertMangaPosterOverride(t, "recently-added single-library", single)
|
||||
}
|
||||
|
||||
func TestRecentlyReleasedQueryUsesLatestMangaVolumePoster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query, _ := buildRecentlyReleasedQuery(ResolvedSection{
|
||||
ItemLimit: 12,
|
||||
Config: json.RawMessage(`{}`),
|
||||
}, nil, nil, catalog.AccessFilter{})
|
||||
assertMangaPosterOverride(t, "recently-released", query)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
type mangaMetadataEnricher interface {
|
||||
Run(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// SyncMangaMetadataTask runs the periodic manga enrichment sweep.
|
||||
// It calls manga.Enricher.Run() which selects unenriched manga media_items,
|
||||
// resolves the per-folder metadata-provider chain at content_level='manga',
|
||||
// and writes results back to the database.
|
||||
type SyncMangaMetadataTask struct {
|
||||
enricher mangaMetadataEnricher
|
||||
}
|
||||
|
||||
// NewSyncMangaMetadataTask constructs the task.
|
||||
func NewSyncMangaMetadataTask(enricher mangaMetadataEnricher) *SyncMangaMetadataTask {
|
||||
return &SyncMangaMetadataTask{enricher: enricher}
|
||||
}
|
||||
|
||||
func (t *SyncMangaMetadataTask) Key() string { return "sync_manga_metadata" }
|
||||
func (t *SyncMangaMetadataTask) Name() string { return "Sync Manga Metadata" }
|
||||
func (t *SyncMangaMetadataTask) Description() string {
|
||||
return "Fetches metadata (cover art, overview, authors) for manga that have not yet been enriched"
|
||||
}
|
||||
func (t *SyncMangaMetadataTask) Category() taskmanager.TaskCategory {
|
||||
return taskmanager.TaskCategoryMetadata
|
||||
}
|
||||
func (t *SyncMangaMetadataTask) IsHidden() bool { return false }
|
||||
|
||||
func (t *SyncMangaMetadataTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
||||
return []taskmanager.TriggerConfig{
|
||||
{Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SyncMangaMetadataTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
|
||||
progress.Report(0, "Scanning for unenriched manga")
|
||||
|
||||
enriched, err := t.enricher.Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manga metadata sync: %w", err)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]int{"items_enriched": enriched})
|
||||
progress.SetResultData(result)
|
||||
progress.Report(100, fmt.Sprintf("Manga metadata sync complete (%d items enriched)", enriched))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE manga_chapters (
|
||||
chapter_content_id TEXT PRIMARY KEY REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
series_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
chapter_index NUMERIC,
|
||||
volume TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX manga_chapters_series ON manga_chapters (series_content_id, chapter_index NULLS LAST);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS manga_chapters;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
-- Dedicated failure accounting for the manga enrichment sweep. Mirrors
|
||||
-- ebook_enrichment_state: tracks per-item failure counts independently from
|
||||
-- media_items.refresh_failures so the enrichment sweep and the metadata
|
||||
-- refresh-debt system do not fight over a shared counter.
|
||||
CREATE TABLE manga_enrichment_state (
|
||||
content_id text PRIMARY KEY REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
failures integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE manga_enrichment_state;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- The browse manga count-chip subqueries filter manga_chapters by
|
||||
-- series_content_id and read/aggregate `volume` (chapter_count = volume IS NULL,
|
||||
-- volume_count = count(DISTINCT volume)). The existing
|
||||
-- manga_chapters_series (series_content_id, chapter_index) index doesn't include
|
||||
-- volume, so count(DISTINCT volume) does a heap fetch per chapter row. Add a
|
||||
-- covering (series_content_id, volume) index so both count subqueries are
|
||||
-- index-only.
|
||||
CREATE INDEX IF NOT EXISTS idx_manga_chapters_series_volume
|
||||
ON public.manga_chapters USING btree (series_content_id, volume);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS idx_manga_chapters_series_volume;
|
||||
-- +goose StatementEnd
|
||||
+58
-4
@@ -662,6 +662,48 @@ export interface EbookDetailExtension {
|
||||
};
|
||||
}
|
||||
|
||||
// MangaChapter mirrors the host catalog.MangaChapter struct. Each chapter is a
|
||||
// readable type='ebook' item; the manga reader links to the ebook reader by
|
||||
// content_id alone (file_id is optional and resolved server-side).
|
||||
export interface MangaChapter {
|
||||
content_id: string;
|
||||
title: string;
|
||||
chapter_index?: number;
|
||||
volume?: string;
|
||||
// True when the current viewer has finished this chapter (ebook read state).
|
||||
// Seeds the row's mark-read toggle on load.
|
||||
read?: boolean;
|
||||
// Viewer reading position as a 0..1 fraction (absent when never opened).
|
||||
progress?: number;
|
||||
// Presigned cover thumbnail extracted from the chapter file.
|
||||
poster_url?: string;
|
||||
}
|
||||
|
||||
// MangaChapterFile is one local file backing a chapter, for the series
|
||||
// "View Details" dialog. file_path/folder paths are stripped server-side for
|
||||
// viewers without file-path visibility.
|
||||
export interface MangaChapterFile {
|
||||
content_id: string;
|
||||
title: string;
|
||||
chapter_index?: number;
|
||||
volume?: string;
|
||||
file_path?: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
container?: string;
|
||||
}
|
||||
|
||||
export interface MangaSeriesFiles {
|
||||
folder_paths?: string[];
|
||||
files: MangaChapterFile[];
|
||||
}
|
||||
|
||||
// MangaDetailExtension mirrors the host catalog.MangaDetailExtension struct;
|
||||
// present only when ItemDetail.type === "manga".
|
||||
export interface MangaDetailExtension {
|
||||
chapters: MangaChapter[];
|
||||
}
|
||||
|
||||
// Seasons / Watched State
|
||||
export interface LeafItemUserData {
|
||||
played: boolean;
|
||||
@@ -745,7 +787,7 @@ export interface BrowseItemSortMetrics {
|
||||
|
||||
export interface BrowseItem {
|
||||
content_id: string;
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook" | "ebook";
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook" | "ebook" | "manga";
|
||||
title: string;
|
||||
series_title?: string;
|
||||
season_number?: number | null;
|
||||
@@ -774,6 +816,12 @@ export interface BrowseItem {
|
||||
overlay_summary?: OverlaySummary | null;
|
||||
sort_metrics?: BrowseItemSortMetrics | null;
|
||||
user_state?: MediaItemUserState;
|
||||
// Manga-only count chips. The host populates these only for type='manga'
|
||||
// browse items; they are absent (undefined) for every other media type.
|
||||
// chapter count = loose chapters without a volume token; volume count =
|
||||
// distinct volumes ("12 Volumes · 3 Chapters").
|
||||
manga_chapter_count?: number;
|
||||
manga_volume_count?: number;
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
@@ -1031,7 +1079,7 @@ export type SetMarkersRequest = Partial<Record<MarkerKind, MarkerSegmentInput |
|
||||
|
||||
export interface ItemDetail {
|
||||
content_id: string;
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook" | "ebook" | "podcast";
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook" | "ebook" | "manga" | "podcast";
|
||||
status?: "pending" | "matched" | "unmatched" | "ambiguous";
|
||||
|
||||
// Metadata (served inline from Postgres).
|
||||
@@ -1065,6 +1113,8 @@ export interface ItemDetail {
|
||||
locked_fields?: number[];
|
||||
release_date: string | null;
|
||||
first_air_date: string | null;
|
||||
// Publication/airing status ("Ongoing", "Completed", "Continuing", "Ended").
|
||||
show_status?: string;
|
||||
last_air_date: string | null;
|
||||
air_time?: string | null;
|
||||
air_timezone?: string | null;
|
||||
@@ -1112,6 +1162,7 @@ export interface ItemDetail {
|
||||
effective_version_edition_key?: string;
|
||||
audiobook?: AudiobookDetailExtension;
|
||||
ebook?: EbookDetailExtension;
|
||||
manga?: MangaDetailExtension;
|
||||
}
|
||||
|
||||
export interface WatchDetail {
|
||||
@@ -1291,7 +1342,7 @@ export interface QuerySort {
|
||||
|
||||
export interface QueryDefinition {
|
||||
library_ids: number[];
|
||||
media_scope?: "movie" | "series" | "episode" | "audiobook" | "ebook" | "video";
|
||||
media_scope?: "movie" | "series" | "episode" | "audiobook" | "ebook" | "manga" | "video";
|
||||
match: "all" | "any";
|
||||
groups: QueryGroup[];
|
||||
sort: QuerySort;
|
||||
@@ -3490,6 +3541,7 @@ export function normalizeQueryDefinition(value?: QueryDefinitionInput | null): Q
|
||||
value?.media_scope === "episode" ||
|
||||
value?.media_scope === "audiobook" ||
|
||||
value?.media_scope === "ebook" ||
|
||||
value?.media_scope === "manga" ||
|
||||
value?.media_scope === "video"
|
||||
? value.media_scope
|
||||
: undefined,
|
||||
@@ -3554,7 +3606,9 @@ export function queryDefinitionFromSectionConfig(
|
||||
? "audiobook"
|
||||
: config.media_scope === "ebook" || config.filter_type === "ebook"
|
||||
? "ebook"
|
||||
: undefined;
|
||||
: config.media_scope === "manga" || config.filter_type === "manga"
|
||||
? "manga"
|
||||
: undefined;
|
||||
|
||||
const legacySortField = typeof config.sort === "string" ? config.sort : undefined;
|
||||
const legacySortOrder = typeof config.order === "string" ? config.order : undefined;
|
||||
|
||||
@@ -936,6 +936,16 @@
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05);
|
||||
}
|
||||
|
||||
/* Less-transparent glass for small chips that sit over busy cover art
|
||||
(manga count/status pills) so the label stays legible. */
|
||||
.glass-chip {
|
||||
background: color-mix(in srgb, var(--surface) 78%, transparent);
|
||||
backdrop-filter: blur(12px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.1);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
/* ── Terminal Surface ──────────────────────────────────────── */
|
||||
/* Scoped terminal aesthetic for ffmpeg console and similar.
|
||||
Usage: <div class="terminal-surface">...</div> */
|
||||
|
||||
@@ -108,7 +108,11 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
card.durationSeconds > 0 ? (card.positionSeconds / card.durationSeconds) * 100 : 0;
|
||||
const hasPartialProgress = progressPercent > 0 && progressPercent < 100;
|
||||
const hasEpisodeMeta = card.seasonNumber != null && card.episodeNumber != null;
|
||||
const headingIsSeries = hasEpisodeMeta && !!card.seriesTitle;
|
||||
// A manga chapter is an ebook item that carries its owning series; the card
|
||||
// presents the series (heading, links) since the chapter's own item detail
|
||||
// is an internal page that loops back into the reader.
|
||||
const isMangaChapter = card.type === "ebook" && !!card.seriesId && !!card.seriesTitle;
|
||||
const headingIsSeries = (hasEpisodeMeta && !!card.seriesTitle) || isMangaChapter;
|
||||
const heading = headingIsSeries ? card.seriesTitle : card.title;
|
||||
// The heading shows the series title for episodes, so it should navigate to
|
||||
// the series page; everything else heads to the item's own page.
|
||||
@@ -116,6 +120,9 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
headingIsSeries && card.seriesId
|
||||
? buildItemHref({ contentId: card.seriesId, libraryId: props.libraryId })
|
||||
: card.itemHref;
|
||||
// Detail-page link target for the card's image and meta lines: manga
|
||||
// chapters head to the series page like the heading does.
|
||||
const detailHref = isMangaChapter ? headingHref : card.itemHref;
|
||||
const episodeLabel = hasEpisodeMeta
|
||||
? `Season ${card.seasonNumber} Episode ${card.episodeNumber}`
|
||||
: null;
|
||||
@@ -123,7 +130,9 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
? card.seriesTitle && card.title
|
||||
? `${episodeLabel} • ${card.title}`
|
||||
: episodeLabel
|
||||
: null;
|
||||
: isMangaChapter
|
||||
? card.title
|
||||
: null;
|
||||
const premiereBadge =
|
||||
"sectionItem" in props && props.sectionItem
|
||||
? props.sectionItem.badges?.find((badge) => badge === "season_premiere")
|
||||
@@ -210,7 +219,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
return (
|
||||
<div className={`group/card ${containerWidth}`}>
|
||||
<div className="group/media relative">
|
||||
<ViewTransitionLink to={card.itemHref} className="block">
|
||||
<ViewTransitionLink to={detailHref} className="block">
|
||||
<div className={`media-card-image relative ${imageAspect} overflow-hidden rounded-xl`}>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
@@ -291,7 +300,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
</ViewTransitionLink>
|
||||
{episodeMeta && (
|
||||
<ViewTransitionLink
|
||||
to={card.itemHref}
|
||||
to={isMangaChapter ? card.watchHref : card.itemHref}
|
||||
className="text-muted-foreground block truncate text-xs hover:underline"
|
||||
>
|
||||
{episodeMeta}
|
||||
@@ -313,7 +322,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
<div className="text-muted-foreground text-xs">{timeLeftLabel}</div>
|
||||
) : (
|
||||
<ViewTransitionLink
|
||||
to={card.itemHref}
|
||||
to={isMangaChapter ? card.watchHref : card.itemHref}
|
||||
className="text-muted-foreground block w-fit text-xs hover:underline"
|
||||
>
|
||||
{timeLeftLabel}
|
||||
|
||||
@@ -28,7 +28,8 @@ type FilterRuleMediaScope =
|
||||
| "series"
|
||||
| "episode"
|
||||
| "audiobook"
|
||||
| "ebook";
|
||||
| "ebook"
|
||||
| "manga";
|
||||
|
||||
interface FilterRuleEditorProps {
|
||||
value: FilterConfig;
|
||||
@@ -46,7 +47,8 @@ export function getFilterRuleFieldOptions(
|
||||
return COLLECTION_FIELD_OPTIONS.filter(
|
||||
(option) => allowPersonalizedFilters || !option.personalized,
|
||||
).map((option) => {
|
||||
if (mediaScope !== "ebook") {
|
||||
// Ebook and manga are read rather than watched, so relabel "watched".
|
||||
if (mediaScope !== "ebook" && mediaScope !== "manga") {
|
||||
return option;
|
||||
}
|
||||
switch (option.value) {
|
||||
|
||||
@@ -34,6 +34,8 @@ function typeLabel(type: BrowseItem["type"]): string {
|
||||
return "Ebook";
|
||||
case "audiobook":
|
||||
return "Audiobook";
|
||||
case "manga":
|
||||
return "Manga";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@@ -189,6 +189,132 @@ describe("ItemCard SortMeta", () => {
|
||||
expect(markup).toContain("S01E03");
|
||||
});
|
||||
|
||||
it("renders a volumes-only manga count chip", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "manga-1",
|
||||
type: "manga",
|
||||
title: "Railgun",
|
||||
manga_chapter_count: 0,
|
||||
manga_volume_count: 12,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).toContain("12 Vol");
|
||||
expect(markup).not.toContain("Ch");
|
||||
});
|
||||
|
||||
it("renders a chapters-only manga count chip", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "manga-2",
|
||||
type: "manga",
|
||||
title: "One Piece",
|
||||
manga_chapter_count: 100,
|
||||
manga_volume_count: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).toContain("100 Ch");
|
||||
expect(markup).not.toContain("Vol");
|
||||
});
|
||||
|
||||
it("renders both counts when a series has volumes and loose chapters", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "manga-4",
|
||||
type: "manga",
|
||||
title: "Mixed Manga",
|
||||
manga_chapter_count: 3,
|
||||
manga_volume_count: 12,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).toContain("12 Vol · 3 Ch");
|
||||
});
|
||||
|
||||
it("uses singular labels for single counts", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "manga-5",
|
||||
type: "manga",
|
||||
title: "One Shot",
|
||||
manga_chapter_count: 1,
|
||||
manga_volume_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).toContain("1 Vol · 1 Ch");
|
||||
});
|
||||
|
||||
it("renders a color-coded publication status chip on manga cards", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "manga-st",
|
||||
type: "manga",
|
||||
title: "Ongoing Manga",
|
||||
manga_volume_count: 5,
|
||||
show_status: "Ongoing",
|
||||
},
|
||||
});
|
||||
expect(markup).toContain("Ongoing");
|
||||
expect(markup).toContain("text-emerald-200");
|
||||
});
|
||||
|
||||
it("does not render a status chip on non-manga cards or when status is absent", () => {
|
||||
const noStatus = renderCard({
|
||||
item: { ...baseItem, content_id: "manga-ns", type: "manga", title: "No Status" },
|
||||
});
|
||||
expect(noStatus).not.toContain("Ongoing");
|
||||
const ebook = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "eb",
|
||||
type: "ebook",
|
||||
title: "Book",
|
||||
show_status: "Completed",
|
||||
},
|
||||
});
|
||||
expect(ebook).not.toContain("Completed");
|
||||
});
|
||||
|
||||
it("does not render a manga count chip on non-manga cards", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "ebook-9",
|
||||
type: "ebook",
|
||||
title: "Not Manga",
|
||||
// Even if these stray fields were present, gating is on type.
|
||||
manga_chapter_count: 99,
|
||||
manga_volume_count: 99,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).not.toContain("Volume");
|
||||
expect(markup).not.toContain("Chapter");
|
||||
});
|
||||
|
||||
it("does not render a manga count chip when both counts are missing or zero", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
...baseItem,
|
||||
content_id: "manga-3",
|
||||
type: "manga",
|
||||
title: "Empty Manga",
|
||||
manga_chapter_count: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).not.toContain("Volume");
|
||||
expect(markup).not.toContain("Chapter");
|
||||
});
|
||||
|
||||
it("renders episode cards with series context when available", () => {
|
||||
const markup = renderCard({
|
||||
item: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, Layers } from "lucide-react";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
import type { BrowseItem } from "@/api/types";
|
||||
import { decodeThumbhash } from "@/lib/thumbhash";
|
||||
@@ -57,6 +57,51 @@ function formatProgress(ratio?: number | null) {
|
||||
return `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`;
|
||||
}
|
||||
|
||||
// mangaCountChipLabel returns the top-right poster chip label for a manga
|
||||
// browse item, or null when the item is not manga or has no counts. The server
|
||||
// sends distinct volumes (manga_volume_count) and loose un-volumed chapters
|
||||
// (manga_chapter_count) separately. Labels are abbreviated ("12 Vol · 3 Ch")
|
||||
// so the chip fits narrow cards without occluding the cover; the detail page
|
||||
// carries the spelled-out counts. Strictly manga-gated so no other card type
|
||||
// renders it.
|
||||
function mangaCountChipLabel(item: BrowseItem): string | null {
|
||||
if (item.type !== "manga") {
|
||||
return null;
|
||||
}
|
||||
const volumes = item.manga_volume_count ?? 0;
|
||||
const chapters = item.manga_chapter_count ?? 0;
|
||||
const parts: string[] = [];
|
||||
if (volumes > 0) {
|
||||
parts.push(`${volumes} Vol`);
|
||||
}
|
||||
if (chapters > 0) {
|
||||
parts.push(`${chapters} Ch`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
// mangaStatusChip returns the top-left publication-status pill for a manga
|
||||
// browse card (color-coded), or null when the item is not manga or has no
|
||||
// status. Strictly manga-gated so no other card type renders it.
|
||||
function mangaStatusChip(item: BrowseItem): { label: string; tone: string } | null {
|
||||
if (item.type !== "manga") {
|
||||
return null;
|
||||
}
|
||||
const status = item.show_status?.trim();
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
const tone =
|
||||
{
|
||||
Ongoing: "text-emerald-200 border-emerald-400/30",
|
||||
Completed: "text-sky-200 border-sky-400/30",
|
||||
Hiatus: "text-amber-200 border-amber-400/30",
|
||||
Cancelled: "text-red-300 border-red-400/30",
|
||||
Upcoming: "text-violet-200 border-violet-400/30",
|
||||
}[status] ?? "text-foreground border-white/15";
|
||||
return { label: status, tone };
|
||||
}
|
||||
|
||||
function SortMeta({ item, sortField }: { item: BrowseItem; sortField?: string }) {
|
||||
const episodeLabels = buildEpisodeCardLabels(item);
|
||||
const defaultLabel = [item.year || "", item.type === "series" ? "Series" : ""]
|
||||
@@ -152,6 +197,8 @@ export default function ItemCard({
|
||||
}`;
|
||||
const episodeLabels = buildEpisodeCardLabels(item);
|
||||
const displayTitle = episodeLabels ? episodeLabels.seriesTitle : item.title;
|
||||
const mangaCountLabel = mangaCountChipLabel(item);
|
||||
const mangaStatus = mangaStatusChip(item);
|
||||
|
||||
return (
|
||||
<div className="media-card group/card">
|
||||
@@ -206,6 +253,19 @@ export default function ItemCard({
|
||||
{item.status === "matched" && overlayPrefs && (
|
||||
<CardOverlays data={overlayDataFromBrowseItem(item)} prefs={overlayPrefs} />
|
||||
)}
|
||||
{mangaStatus && (
|
||||
<span
|
||||
className={`glass-chip absolute top-2.5 left-2.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] uppercase ${mangaStatus.tone}`}
|
||||
>
|
||||
{mangaStatus.label}
|
||||
</span>
|
||||
)}
|
||||
{mangaCountLabel && (
|
||||
<span className="glass-chip text-foreground absolute top-2.5 right-2.5 inline-flex items-center gap-1 rounded-full border border-white/15 px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] uppercase">
|
||||
<Layers className="size-3" />
|
||||
{mangaCountLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ViewTransitionLink>
|
||||
{selectionMode && onToggleSelect && (
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Folder, Loader2 } from "lucide-react";
|
||||
import type { MangaChapterFile } from "@/api/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useMangaSeriesFiles } from "@/hooks/queries/catalogRead";
|
||||
import { prettifyVolumeLabel } from "@/lib/mangaChapters";
|
||||
import { formatFileSize } from "@/pages/ItemDetail/components/versionFormatUtils";
|
||||
|
||||
// fileRowLabel describes the chapter a file backs: its volume token when
|
||||
// present, otherwise a chapter form mirroring the series list labels.
|
||||
function fileRowLabel(file: MangaChapterFile): string {
|
||||
if (file.volume?.trim()) {
|
||||
return prettifyVolumeLabel(file.volume);
|
||||
}
|
||||
if (typeof file.chapter_index === "number") {
|
||||
return `Chapter ${file.chapter_index}`;
|
||||
}
|
||||
return file.title?.trim() || "Chapter";
|
||||
}
|
||||
|
||||
// MangaFilesDialog shows the local files backing a manga series: the folder(s)
|
||||
// the chapters live in and one row per file. Paths are server-stripped for
|
||||
// viewers without file-path visibility, so those users see names and sizes.
|
||||
export default function MangaFilesDialog({
|
||||
contentId,
|
||||
title,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
contentId: string;
|
||||
title?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { data, isLoading, error } = useMangaSeriesFiles(contentId, open);
|
||||
const files = data?.files ?? [];
|
||||
const totalBytes = files.reduce((sum, file) => sum + (file.file_size || 0), 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] gap-4 sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate pr-6">
|
||||
{title ? `${title} — Files` : "Files"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{files.length > 0
|
||||
? `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatFileSize(totalBytes)}`
|
||||
: "Local files backing this series."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-destructive py-6 text-sm">
|
||||
Couldn't load file details. Try again later.
|
||||
</p>
|
||||
) : (
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto">
|
||||
{(data?.folder_paths?.length ?? 0) > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{data?.folder_paths?.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="text-muted-foreground flex items-start gap-2 font-mono text-xs break-all"
|
||||
>
|
||||
<Folder className="mt-0.5 size-3.5 flex-shrink-0" />
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{files.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">No files found.</p>
|
||||
) : (
|
||||
<ul className="divide-border/40 border-border/40 divide-y rounded-md border">
|
||||
{files.map((file) => (
|
||||
<li
|
||||
key={`${file.content_id}-${file.file_name}`}
|
||||
className="flex items-baseline gap-3 px-3 py-2"
|
||||
>
|
||||
<span className="text-foreground/90 w-28 flex-shrink-0 truncate text-xs font-medium">
|
||||
{fileRowLabel(file)}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs"
|
||||
title={file.file_path || file.file_name}
|
||||
>
|
||||
{file.file_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground flex-shrink-0 text-xs tabular-nums">
|
||||
{file.file_size > 0 ? formatFileSize(file.file_size) : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { type DismissHomeItemVariables, useDismissHomeItem } from "@/hooks/queri
|
||||
import { useToggleFavorite } from "@/hooks/queries/favorites";
|
||||
import { useToggleWatchlist } from "@/hooks/queries/watchlist";
|
||||
import { getWatchedActionLabel } from "@/pages/ItemDetail/watchedState";
|
||||
import MangaFilesDialog from "@/components/MangaFilesDialog";
|
||||
import RefreshMetadataDialog from "@/components/RefreshMetadataDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,6 +33,7 @@ type MediaItemMenuEntry =
|
||||
| "toggleFavorite"
|
||||
| "toggleWatchlist"
|
||||
| "dismissFromHome"
|
||||
| "viewDetails"
|
||||
| "viewPlayHistory"
|
||||
| "refreshMetadata";
|
||||
label: string;
|
||||
@@ -104,6 +106,11 @@ export function buildMediaItemMenuModel({
|
||||
}
|
||||
}
|
||||
|
||||
// Manga series get a local file inspector (folder path, per-volume files).
|
||||
if (mediaType === "manga") {
|
||||
entries.push({ kind: "action", key: "viewDetails", label: "View Details" });
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
if (entries.length > 0) {
|
||||
entries.push({ kind: "separator" });
|
||||
@@ -157,6 +164,7 @@ export default function MediaItemMenu({
|
||||
const isAdmin = useIsActingAdmin();
|
||||
const [currentUserState, setCurrentUserState] = useState(userState);
|
||||
const [refreshDialogOpen, setRefreshDialogOpen] = useState(false);
|
||||
const [filesDialogOpen, setFilesDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUserState(userState);
|
||||
@@ -243,6 +251,10 @@ export default function MediaItemMenu({
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "viewDetails": {
|
||||
setFilesDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
case "viewPlayHistory": {
|
||||
navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`);
|
||||
return;
|
||||
@@ -316,6 +328,13 @@ export default function MediaItemMenu({
|
||||
onConfirm={handleRefreshConfirm}
|
||||
isPending={refreshMetadataMutation.isPending}
|
||||
/>
|
||||
{mediaType === "manga" && (
|
||||
<MangaFilesDialog
|
||||
contentId={contentId}
|
||||
open={filesDialogOpen}
|
||||
onOpenChange={setFilesDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookHeadphones, BookOpen, Film, Layers, Podcast, Tv } from "lucide-react";
|
||||
import { BookHeadphones, BookMarked, BookOpen, Film, Layers, Podcast, Tv } from "lucide-react";
|
||||
|
||||
export const LIBRARY_TYPES = [
|
||||
{ value: "movies", label: "Movies", icon: Film },
|
||||
@@ -6,6 +6,7 @@ export const LIBRARY_TYPES = [
|
||||
{ value: "mixed", label: "Mixed", icon: Layers },
|
||||
{ value: "audiobooks", label: "Audiobooks", icon: BookHeadphones },
|
||||
{ value: "ebooks", label: "Ebooks", icon: BookOpen },
|
||||
{ value: "manga", label: "Manga", icon: BookMarked },
|
||||
{ value: "podcasts", label: "Podcasts", icon: Podcast },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export function contentLevelsForType(libraryType: string): string[] {
|
||||
case "ebooks":
|
||||
case "ebook":
|
||||
return ["ebook"];
|
||||
case "manga":
|
||||
return ["manga"];
|
||||
case "podcasts":
|
||||
return ["podcast", "podcast_episode"];
|
||||
default:
|
||||
|
||||
@@ -38,6 +38,7 @@ export const CATALOG_MEDIA_SCOPE_OPTIONS = [
|
||||
{ value: "episode", label: "Episodes" },
|
||||
{ value: "audiobook", label: "Audiobooks" },
|
||||
{ value: "ebook", label: "Ebooks" },
|
||||
{ value: "manga", label: "Manga" },
|
||||
] as const;
|
||||
|
||||
export default function CatalogFilterBar({
|
||||
@@ -64,8 +65,13 @@ export default function CatalogFilterBar({
|
||||
value={state.mediaScope}
|
||||
onValueChange={(v) => {
|
||||
// "video" spans movie+series, so sorts valid for "all" stay valid.
|
||||
const nextRelevanceScope =
|
||||
v === "all" || v === "video" ? "all" : (v as QuerySortRelevanceScope);
|
||||
// Manga reuses the ebook sort universe (no dedicated sort scope).
|
||||
const nextRelevanceScope: QuerySortRelevanceScope =
|
||||
v === "all" || v === "video"
|
||||
? "all"
|
||||
: v === "manga"
|
||||
? "ebook"
|
||||
: (v as QuerySortRelevanceScope);
|
||||
const nextSort = normalizeQuerySortForScope(
|
||||
{ field: state.sortField, order: state.sortOrder },
|
||||
{
|
||||
@@ -110,7 +116,11 @@ export default function CatalogFilterBar({
|
||||
? null
|
||||
: state.mediaScope === "video"
|
||||
? ["movie", "series"]
|
||||
: [state.mediaScope];
|
||||
: // Manga has no dedicated sort scope; it reuses the ebook
|
||||
// sort universe (its chapters are ebook items).
|
||||
state.mediaScope === "manga"
|
||||
? ["ebook"]
|
||||
: [state.mediaScope];
|
||||
const currentApplicable =
|
||||
!scopeTypes ||
|
||||
scopeTypes.some((scope) => sortOption.applicableMediaScopes.includes(scope));
|
||||
|
||||
@@ -40,7 +40,7 @@ const DECADE_OPTIONS = Array.from({ length: 15 }, (_, index) => 2030 - index * 1
|
||||
|
||||
/** Flat form state that maps 1-to-1 with friendly form fields. */
|
||||
export interface GuidedFormState {
|
||||
mediaScope: "all" | "video" | "movie" | "series" | "episode" | "audiobook" | "ebook";
|
||||
mediaScope: "all" | "video" | "movie" | "series" | "episode" | "audiobook" | "ebook" | "manga";
|
||||
libraryIds: number[];
|
||||
genres: string[];
|
||||
decade: string;
|
||||
@@ -422,8 +422,13 @@ export default function CollectionGuidedRulesEditor({
|
||||
// use singular media scopes; accept both.
|
||||
const isAudiobookLibrary =
|
||||
libraryType === "audiobook" || libraryType === "audiobooks" || state.mediaScope === "audiobook";
|
||||
// Manga is read like ebooks, so it shares the ebook "Read Status" labels.
|
||||
const isEbookLibrary =
|
||||
libraryType === "ebook" || libraryType === "ebooks" || state.mediaScope === "ebook";
|
||||
libraryType === "ebook" ||
|
||||
libraryType === "ebooks" ||
|
||||
libraryType === "manga" ||
|
||||
state.mediaScope === "ebook" ||
|
||||
state.mediaScope === "manga";
|
||||
const isBookLibrary = isAudiobookLibrary || isEbookLibrary;
|
||||
const progressStatusLabel = isEbookLibrary
|
||||
? "Read Status"
|
||||
@@ -482,8 +487,14 @@ export default function CollectionGuidedRulesEditor({
|
||||
<Select
|
||||
value={state.mediaScope}
|
||||
onValueChange={(v) => {
|
||||
const nextRelevanceScope =
|
||||
v === "all" || v === "video" ? "all" : (v as QuerySortRelevanceScope);
|
||||
const nextRelevanceScope: QuerySortRelevanceScope =
|
||||
v === "all" || v === "video"
|
||||
? "all"
|
||||
: // Manga has no dedicated sort scope; it reuses the ebook
|
||||
// sort universe (its chapters are ebook items).
|
||||
v === "manga"
|
||||
? "ebook"
|
||||
: (v as QuerySortRelevanceScope);
|
||||
const nextSort = normalizeQuerySortForScope(
|
||||
{ field: state.sortField, order: state.sortOrder },
|
||||
{
|
||||
@@ -510,6 +521,7 @@ export default function CollectionGuidedRulesEditor({
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
<SelectItem value="ebook">Ebooks</SelectItem>
|
||||
<SelectItem value="manga">Manga</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
EpisodesResponse,
|
||||
FileVersion,
|
||||
ItemDetail,
|
||||
MangaSeriesFiles,
|
||||
SeasonDetailResponse,
|
||||
SeasonsResponse,
|
||||
} from "@/api/types";
|
||||
@@ -96,6 +97,23 @@ export function useCatalogItemVersions(id: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMangaSeriesFiles(
|
||||
id: string,
|
||||
options?: RequestInit,
|
||||
): Promise<MangaSeriesFiles> {
|
||||
return api<MangaSeriesFiles>(`/catalog/items/${catalogPathID(id)}/manga-files`, options);
|
||||
}
|
||||
|
||||
// useMangaSeriesFiles backs the series "View Details" dialog; enabled defers
|
||||
// the fetch until the dialog actually opens.
|
||||
export function useMangaSeriesFiles(id: string | undefined, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [...catalogKeys.itemDetail(id!), "manga-files"],
|
||||
queryFn: () => fetchMangaSeriesFiles(id!),
|
||||
enabled: !!id && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCatalogItemEpisodes(id: string | undefined, libraryId?: number) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.itemEpisodes(id!, libraryId),
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MangaChapter } from "@/api/types";
|
||||
import { buildMangaList, prettifyVolumeLabel } from "./mangaChapters";
|
||||
|
||||
function chapter(partial: Partial<MangaChapter>): MangaChapter {
|
||||
return {
|
||||
content_id: partial.content_id ?? "c",
|
||||
title: partial.title ?? "Chapter",
|
||||
chapter_index: partial.chapter_index,
|
||||
volume: partial.volume,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMangaList", () => {
|
||||
it("renders a pure-volume series as flat volume units (no nested chapter)", () => {
|
||||
const entries = buildMangaList([
|
||||
chapter({ content_id: "v1", chapter_index: 1, volume: "v01" }),
|
||||
chapter({ content_id: "v2", chapter_index: 2, volume: "v02" }),
|
||||
]);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "volume", chapter: expect.objectContaining({ content_id: "v1" }), label: "Volume 1" },
|
||||
{ kind: "volume", chapter: expect.objectContaining({ content_id: "v2" }), label: "Volume 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a pure-chapter series as flat loose chapters ordered by index", () => {
|
||||
const entries = buildMangaList([
|
||||
chapter({ content_id: "c178", chapter_index: 178, volume: "" }),
|
||||
chapter({ content_id: "c179", chapter_index: 179 }),
|
||||
]);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "chapter",
|
||||
chapter: expect.objectContaining({ content_id: "c178" }),
|
||||
label: "Chapter 178",
|
||||
},
|
||||
{
|
||||
kind: "chapter",
|
||||
chapter: expect.objectContaining({ content_id: "c179" }),
|
||||
label: "Chapter 179",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("nests only when a single volume holds multiple chapters", () => {
|
||||
const entries = buildMangaList([
|
||||
chapter({ content_id: "v1-c2", chapter_index: 2, volume: "v01" }),
|
||||
chapter({ content_id: "v1-c1", chapter_index: 1, volume: "v01" }),
|
||||
]);
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0];
|
||||
expect(entry?.kind).toBe("section");
|
||||
if (entry?.kind === "section") {
|
||||
expect(entry.label).toBe("Volume 1");
|
||||
expect(entry.chapters.map((c) => c.content_id)).toEqual(["v1-c1", "v1-c2"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders all top-level entries by representative index, loose chapters not forced last", () => {
|
||||
const entries = buildMangaList([
|
||||
chapter({ content_id: "loose-5", chapter_index: 5 }),
|
||||
chapter({ content_id: "v-c10", chapter_index: 10, volume: "v02" }),
|
||||
chapter({ content_id: "v-c1", chapter_index: 1, volume: "v01" }),
|
||||
chapter({ content_id: "loose-3", chapter_index: 3, volume: "" }),
|
||||
]);
|
||||
|
||||
expect(entries.map((e) => e.label)).toEqual(["Volume 1", "Chapter 3", "Chapter 5", "Volume 2"]);
|
||||
});
|
||||
|
||||
it("orders a section by its minimum chapter index relative to other entries", () => {
|
||||
const entries = buildMangaList([
|
||||
chapter({ content_id: "v2-c9", chapter_index: 9, volume: "v02" }),
|
||||
chapter({ content_id: "v2-c10", chapter_index: 10, volume: "v02" }),
|
||||
chapter({ content_id: "v1", chapter_index: 1, volume: "v01" }),
|
||||
]);
|
||||
|
||||
expect(entries.map((e) => e.label)).toEqual(["Volume 1", "Volume 2"]);
|
||||
expect(entries[1]?.kind).toBe("section");
|
||||
});
|
||||
|
||||
it("labels a loose chapter without an index by its trimmed title", () => {
|
||||
const entries = buildMangaList([chapter({ content_id: "bonus", title: " Bonus " })]);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "chapter",
|
||||
chapter: expect.objectContaining({ content_id: "bonus" }),
|
||||
label: "Bonus",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("places chapters with a null index last within a section", () => {
|
||||
const entries = buildMangaList([
|
||||
chapter({ content_id: "v1-cNull", volume: "v01" }),
|
||||
chapter({ content_id: "v1-c1", chapter_index: 1, volume: "v01" }),
|
||||
chapter({ content_id: "v1-c2", chapter_index: 2, volume: "v01" }),
|
||||
]);
|
||||
|
||||
expect(entries[0]?.kind).toBe("section");
|
||||
if (entries[0]?.kind === "section") {
|
||||
expect(entries[0].chapters.map((c) => c.content_id)).toEqual(["v1-c1", "v1-c2", "v1-cNull"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty array for no chapters", () => {
|
||||
expect(buildMangaList([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prettifyVolumeLabel", () => {
|
||||
it("expands a v-prefixed token to a Volume label", () => {
|
||||
expect(prettifyVolumeLabel("v13")).toBe("Volume 13");
|
||||
expect(prettifyVolumeLabel("V2")).toBe("Volume 2");
|
||||
});
|
||||
|
||||
it("expands a bare numeric token to a Volume label", () => {
|
||||
expect(prettifyVolumeLabel("7")).toBe("Volume 7");
|
||||
});
|
||||
|
||||
it("passes through non-numeric tokens unchanged", () => {
|
||||
expect(prettifyVolumeLabel("Omnibus")).toBe("Omnibus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("volume token normalization", () => {
|
||||
it("buckets 'v01' and '1' into the same volume", () => {
|
||||
const entries = buildMangaList([
|
||||
{ content_id: "a", title: "Series v01", chapter_index: 1, volume: "v01" },
|
||||
{ content_id: "b", title: "Series 1 extras", chapter_index: 2, volume: "1" },
|
||||
]);
|
||||
|
||||
// One section labeled "Volume 1" holding both chapters — not two
|
||||
// duplicate top-level entries.
|
||||
expect(entries).toHaveLength(1);
|
||||
const [entry] = entries;
|
||||
if (!entry || entry.kind !== "section") {
|
||||
throw new Error(`expected a section entry, got ${JSON.stringify(entry)}`);
|
||||
}
|
||||
expect(entry.label).toBe("Volume 1");
|
||||
expect(entry.chapters.map((c) => c.content_id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("keeps non-numeric tokens distinct", () => {
|
||||
const entries = buildMangaList([
|
||||
{ content_id: "a", title: "Omnibus", chapter_index: 1, volume: "Omnibus" },
|
||||
{ content_id: "b", title: "v2", chapter_index: 2, volume: "v2" },
|
||||
]);
|
||||
expect(entries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { MangaChapter } from "@/api/types";
|
||||
|
||||
// A MangaListEntry is one row in the manga detail list. Most manga releases are
|
||||
// one file per volume, so the common cases are flat: a `volume` unit (a single
|
||||
// cbz that is a whole volume) or a loose `chapter` (a single cbz with no volume
|
||||
// token). Nesting via a `section` only happens when one volume genuinely holds
|
||||
// multiple chapters.
|
||||
export type MangaListEntry =
|
||||
| { kind: "volume"; chapter: MangaChapter; label: string }
|
||||
| { kind: "chapter"; chapter: MangaChapter; label: string }
|
||||
| { kind: "section"; label: string; chapters: MangaChapter[] };
|
||||
|
||||
const VOLUME_TOKEN_PATTERN = /^v?(\d+)$/i;
|
||||
|
||||
// prettifyVolumeLabel turns a raw volume token into a display label. "v13" and
|
||||
// "13" both become "Volume 13"; non-numeric tokens (e.g. "Omnibus") pass
|
||||
// through unchanged so unusual volume schemes still render sensibly.
|
||||
export function prettifyVolumeLabel(volume: string): string {
|
||||
const match = volume.trim().match(VOLUME_TOKEN_PATTERN);
|
||||
return match ? `Volume ${Number(match[1])}` : volume.trim();
|
||||
}
|
||||
|
||||
// chapterLabel prefers a "Chapter <n>" form derived from the index, falling
|
||||
// back to the chapter's own trimmed title when no index is available.
|
||||
export function chapterLabel(chapter: MangaChapter): string {
|
||||
if (typeof chapter.chapter_index === "number") {
|
||||
return `Chapter ${chapter.chapter_index}`;
|
||||
}
|
||||
return chapter.title?.trim() || "Chapter";
|
||||
}
|
||||
|
||||
// chapterSortKey returns a comparable index where missing indices sort last.
|
||||
function chapterSortKey(chapter: MangaChapter): number {
|
||||
return typeof chapter.chapter_index === "number"
|
||||
? chapter.chapter_index
|
||||
: Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function byChapterIndex(a: MangaChapter, b: MangaChapter): number {
|
||||
const ka = chapterSortKey(a);
|
||||
const kb = chapterSortKey(b);
|
||||
// Both missing → both POSITIVE_INFINITY; the subtraction would be NaN (which
|
||||
// Array.sort treats as 0, leaving order undefined). Compare explicitly so
|
||||
// un-indexed chapters keep a stable order.
|
||||
if (ka === kb) return 0;
|
||||
return ka < kb ? -1 : 1;
|
||||
}
|
||||
|
||||
// buildMangaList turns a flat chapter list into ordered display entries.
|
||||
//
|
||||
// Grouping rules:
|
||||
// 1. Bucket chapters by trimmed volume token (empty/absent → no-volume).
|
||||
// 2. No-volume chapters each become their own loose `chapter` entry.
|
||||
// 3. A volume bucket with exactly one chapter becomes a `volume` unit;
|
||||
// with two or more it becomes a `section` (chapters ordered by index).
|
||||
// 4. All top-level entries order by a representative index: a unit/loose by
|
||||
// its own index (nulls last), a section by its minimum chapter index.
|
||||
// volumeBucketKey canonicalizes a volume token for grouping: "v01", "01" and
|
||||
// "1" all describe Volume 1 and must land in one bucket (mixed release naming
|
||||
// otherwise yields duplicate "Volume 1" entries). Non-numeric tokens group by
|
||||
// their trimmed text.
|
||||
function volumeBucketKey(token: string): string {
|
||||
const match = token.match(VOLUME_TOKEN_PATTERN);
|
||||
return match ? String(Number(match[1])) : token;
|
||||
}
|
||||
|
||||
export function buildMangaList(chapters: MangaChapter[]): MangaListEntry[] {
|
||||
const volumeBuckets = new Map<string, MangaChapter[]>();
|
||||
const loose: MangaChapter[] = [];
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const token = chapter.volume?.trim();
|
||||
if (token) {
|
||||
const key = volumeBucketKey(token);
|
||||
const bucket = volumeBuckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(chapter);
|
||||
} else {
|
||||
volumeBuckets.set(key, [chapter]);
|
||||
}
|
||||
} else {
|
||||
loose.push(chapter);
|
||||
}
|
||||
}
|
||||
|
||||
const ranked: { sortKey: number; entry: MangaListEntry }[] = [];
|
||||
|
||||
for (const chapter of loose) {
|
||||
ranked.push({
|
||||
sortKey: chapterSortKey(chapter),
|
||||
entry: { kind: "chapter", chapter, label: chapterLabel(chapter) },
|
||||
});
|
||||
}
|
||||
|
||||
for (const [token, bucket] of volumeBuckets) {
|
||||
const ordered = [...bucket].sort(byChapterIndex);
|
||||
const label = prettifyVolumeLabel(token);
|
||||
const [first] = ordered;
|
||||
if (ordered.length === 1 && first) {
|
||||
ranked.push({
|
||||
sortKey: chapterSortKey(first),
|
||||
entry: { kind: "volume", chapter: first, label },
|
||||
});
|
||||
} else {
|
||||
const minIndex = ordered.reduce(
|
||||
(min, chapter) => Math.min(min, chapterSortKey(chapter)),
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
ranked.push({
|
||||
sortKey: minIndex,
|
||||
entry: { kind: "section", label, chapters: ordered },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ranked.sort((a, b) => a.sortKey - b.sortKey).map((r) => r.entry);
|
||||
}
|
||||
|
||||
// A FlatMangaChapter is one readable unit in series order, with a label that
|
||||
// stays meaningful out of context ("Volume 3 · Chapter 12" for a chapter
|
||||
// nested in a volume section). Used by the series Continue CTA and the
|
||||
// reader's next-chapter navigation.
|
||||
export interface FlatMangaChapter {
|
||||
chapter: MangaChapter;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// flattenMangaList unrolls display entries into the flat reading order.
|
||||
export function flattenMangaList(entries: MangaListEntry[]): FlatMangaChapter[] {
|
||||
const flat: FlatMangaChapter[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === "section") {
|
||||
for (const chapter of entry.chapters) {
|
||||
flat.push({ chapter, label: `${entry.label} · ${chapterLabel(chapter)}` });
|
||||
}
|
||||
} else {
|
||||
flat.push({ chapter: entry.chapter, label: entry.label });
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
// firstUnreadChapter returns the resume target: the first chapter in reading
|
||||
// order the viewer has not finished, or null when everything is read (or the
|
||||
// list is empty).
|
||||
export function firstUnreadChapter(entries: MangaListEntry[]): FlatMangaChapter | null {
|
||||
for (const flat of flattenMangaList(entries)) {
|
||||
if (flat.chapter.read !== true) {
|
||||
return flat;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ type PlayableMediaType =
|
||||
| "episode"
|
||||
| "audiobook"
|
||||
| "ebook"
|
||||
| "manga"
|
||||
| "podcast";
|
||||
|
||||
interface MediaHrefInput {
|
||||
@@ -12,6 +13,10 @@ interface MediaHrefInput {
|
||||
type: PlayableMediaType;
|
||||
libraryId?: number;
|
||||
restart?: boolean;
|
||||
// In-app path to return to after the reader (manga chapters pass their series
|
||||
// page to break the chapter→reader→chapter loop). Routed through the query
|
||||
// helper so it is always a proper query param, even when libraryId is absent.
|
||||
backTo?: string;
|
||||
}
|
||||
|
||||
function appendQuery(base: string, params: Record<string, string | number | boolean | undefined>) {
|
||||
@@ -33,7 +38,13 @@ export function buildItemHref({
|
||||
return appendQuery(`/item/${encodeURIComponent(contentId)}`, { libraryId });
|
||||
}
|
||||
|
||||
export function buildMediaPlayHref({ contentId, type, libraryId, restart }: MediaHrefInput) {
|
||||
export function buildMediaPlayHref({
|
||||
contentId,
|
||||
type,
|
||||
libraryId,
|
||||
restart,
|
||||
backTo,
|
||||
}: MediaHrefInput) {
|
||||
if (type === "movie" || type === "episode") {
|
||||
return appendQuery(`/watch/${encodeURIComponent(contentId)}`, { libraryId, restart });
|
||||
}
|
||||
@@ -45,8 +56,11 @@ export function buildMediaPlayHref({ contentId, type, libraryId, restart }: Medi
|
||||
});
|
||||
}
|
||||
if (type === "ebook") {
|
||||
return appendQuery(`/reader/ebook/${encodeURIComponent(contentId)}`, { libraryId });
|
||||
return appendQuery(`/reader/ebook/${encodeURIComponent(contentId)}`, { libraryId, backTo });
|
||||
}
|
||||
// Manga series (and series/season) are not directly playable: you open the
|
||||
// detail page and read an individual chapter (itself an ebook item) from
|
||||
// there. Fall through to the item href.
|
||||
return buildItemHref({ contentId, libraryId });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type QuerySortRelevanceScope =
|
||||
| "episode"
|
||||
| "audiobook"
|
||||
| "ebook"
|
||||
| "manga"
|
||||
| "all";
|
||||
export type QuerySortField =
|
||||
| "title"
|
||||
@@ -64,6 +65,9 @@ interface QuerySortLike {
|
||||
// applicableMediaScopes to be eligible for book-only libraries.
|
||||
const ALL_VIDEO_SCOPES: ApplicableMediaScope[] = ["movie", "series", "episode"];
|
||||
const ALL_MEDIA_SCOPES: ApplicableMediaScope[] = [...ALL_VIDEO_SCOPES, "audiobook", "ebook"];
|
||||
// Manga series rows are file-less containers: technical sorts (Duration,
|
||||
// Bitrate) are meaningless there, so manga is opt-in per sort field instead
|
||||
// of being part of ALL_MEDIA_SCOPES.
|
||||
|
||||
export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
{
|
||||
@@ -71,21 +75,21 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
label: "Title",
|
||||
defaultOrder: "asc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
{
|
||||
value: "added_at",
|
||||
label: "Date Added",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
{
|
||||
value: "release_date",
|
||||
label: "Release Date",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
{
|
||||
value: "last_air_date",
|
||||
@@ -100,7 +104,7 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
label: "Year",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
{
|
||||
value: "content_rating",
|
||||
@@ -163,21 +167,21 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
label: "Progress",
|
||||
defaultOrder: "desc",
|
||||
personalized: true,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
{
|
||||
value: "date_viewed",
|
||||
label: "Date Viewed",
|
||||
defaultOrder: "desc",
|
||||
personalized: true,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
{
|
||||
value: "plays",
|
||||
label: "Plays",
|
||||
defaultOrder: "desc",
|
||||
personalized: true,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: [...ALL_MEDIA_SCOPES, "manga"],
|
||||
},
|
||||
// Book-native sorts. Author is shared by audiobooks and ebooks; narrator
|
||||
// remains audiobook-only. Series is shared by the audiobook_series and
|
||||
@@ -187,7 +191,7 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
label: "Author",
|
||||
defaultOrder: "asc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ["audiobook", "ebook"],
|
||||
applicableMediaScopes: ["audiobook", "ebook", "manga"],
|
||||
},
|
||||
{
|
||||
value: "narrator",
|
||||
@@ -246,7 +250,10 @@ export function getQuerySortOptions(input: QuerySortOptionsInput = false): Query
|
||||
(includePersonalized || !option.personalized) &&
|
||||
optionMatchesRelevanceScope(option, relevanceScope),
|
||||
).map((option) => {
|
||||
const ebookLabel = relevanceScope === "ebook" ? EBOOK_SORT_LABELS[option.value] : undefined;
|
||||
const ebookLabel =
|
||||
relevanceScope === "ebook" || relevanceScope === "manga"
|
||||
? EBOOK_SORT_LABELS[option.value]
|
||||
: undefined;
|
||||
return ebookLabel ? { ...option, label: ebookLabel } : option;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -320,6 +320,23 @@ describe("EbookReader", () => {
|
||||
expect(container.innerHTML).toContain('href="/item/ebook-1?libraryId=12"');
|
||||
});
|
||||
|
||||
it("sends the reader back action to an explicit backTo target (manga series)", async () => {
|
||||
const backTo = encodeURIComponent("/item/manga-series-1?libraryId=7");
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[`/reader/ebook/ebook-1?libraryId=7&backTo=${backTo}`]}>
|
||||
<Routes>
|
||||
<Route path="/reader/ebook/:contentId" element={<EbookReader />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
// backTo wins over the default chapter-detail target, breaking the loop.
|
||||
expect(container.innerHTML).toContain('href="/item/manga-series-1?libraryId=7"');
|
||||
expect(container.innerHTML).not.toContain('href="/item/ebook-1?libraryId=7"');
|
||||
});
|
||||
|
||||
it("switches between multiple ebook files from the reader header", async () => {
|
||||
mocks.useCatalogItemDetail.mockReturnValue({
|
||||
data: makeEbookItem({
|
||||
|
||||
+290
-170
@@ -42,6 +42,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { useScreenWakeLock } from "@/hooks/useScreenWakeLock";
|
||||
import { useTTS } from "@/hooks/useTTS";
|
||||
import { useCatalogItemDetail } from "@/hooks/queries/catalogRead";
|
||||
import { buildItemHref, buildMediaPlayHref } from "@/lib/mediaNavigation";
|
||||
import { buildMangaList, flattenMangaList } from "@/lib/mangaChapters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TOCItem } from "@/reader/readest/libs/document";
|
||||
import FoliateBookReader, {
|
||||
@@ -197,7 +199,29 @@ export default function EbookReader() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedFileID = Number(searchParams.get("file_id") || "");
|
||||
const libraryIdParam = searchParams.get("libraryId");
|
||||
// Manga chapter rows pass an explicit backTo target (the manga series detail)
|
||||
// so the reader's back action returns to the series instead of the chapter's
|
||||
// own junk item detail — which would loop straight back into the reader.
|
||||
// Absent for normal ebooks, so their back behavior is unchanged.
|
||||
const backToParam = searchParams.get("backTo");
|
||||
const { data: item, isLoading, error } = useCatalogItemDetail(contentId || undefined);
|
||||
// Manga chapters carry their owning series id; fetching the series detail
|
||||
// (usually already cached from the series page) gives the ordered chapter
|
||||
// list, which powers next-chapter navigation and the default back target.
|
||||
const mangaSeriesId = item?.type === "ebook" ? item.series_id : undefined;
|
||||
const { data: mangaSeries } = useCatalogItemDetail(mangaSeriesId || undefined);
|
||||
const nextChapter = useMemo(() => {
|
||||
const seriesChapters = mangaSeries?.manga?.chapters;
|
||||
if (!seriesChapters || seriesChapters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const flat = flattenMangaList(buildMangaList(seriesChapters));
|
||||
const index = flat.findIndex((entry) => entry.chapter.content_id === contentId);
|
||||
if (index < 0 || index + 1 >= flat.length) {
|
||||
return null;
|
||||
}
|
||||
return flat[index + 1];
|
||||
}, [contentId, mangaSeries?.manga?.chapters]);
|
||||
const selectedFile = useMemo(
|
||||
() =>
|
||||
chooseReaderFile(
|
||||
@@ -211,11 +235,22 @@ export default function EbookReader() {
|
||||
[item?.versions],
|
||||
);
|
||||
const format = readerFileFormat(selectedFile);
|
||||
// Comic archives are image books: prose chrome (TTS, typography, reading
|
||||
// ruler) is meaningless and the side panel steals width the pages need, so
|
||||
// it starts closed (the toggle still opens it).
|
||||
const isComicFormat = format === "cbz" || format === "cbr";
|
||||
const readerRef = useRef<FoliateBookReaderHandle>(null);
|
||||
const [loadedFile, setLoadedFile] = useState<ReaderLoadState | null>(null);
|
||||
const [readerProgress, setReaderProgress] = useState<number | null>(null);
|
||||
const [toc, setToc] = useState<TOCItem[]>([]);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const comicPanelInitRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isComicFormat && !comicPanelInitRef.current) {
|
||||
comicPanelInitRef.current = true;
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}, [isComicFormat]);
|
||||
const [panel, setPanel] = useState<ReaderPanel>("toc");
|
||||
const [readerSettings, setReaderSettings] = useState<ReaderSettings>(() =>
|
||||
loadStoredReaderSettings(),
|
||||
@@ -512,9 +547,40 @@ export default function EbookReader() {
|
||||
);
|
||||
}
|
||||
|
||||
const backHref = `/item/${encodeURIComponent(item.content_id)}${
|
||||
libraryIdParam ? `?libraryId=${encodeURIComponent(libraryIdParam)}` : ""
|
||||
}`;
|
||||
// backToParam comes from the URL, so it must be validated before use as an
|
||||
// href: only accept a single-leading-slash in-app relative path. This rejects
|
||||
// absolute URLs, protocol-relative (`//host`), backslash tricks, and
|
||||
// `javascript:`/`data:` schemes (open-redirect / XSS).
|
||||
const safeBackTo =
|
||||
backToParam && backToParam.startsWith("/") && !/^\/[/\\]/.test(backToParam)
|
||||
? backToParam
|
||||
: null;
|
||||
const libraryIdNumber = libraryIdParam ? Number(libraryIdParam) : undefined;
|
||||
// Manga chapters default their back target to the owning series, so entry
|
||||
// points that cannot pass backTo (continue-reading cards, deep links) still
|
||||
// escape the chapter's own junk item detail.
|
||||
const mangaSeriesHref = mangaSeriesId
|
||||
? buildItemHref({
|
||||
contentId: mangaSeriesId,
|
||||
libraryId: Number.isFinite(libraryIdNumber) ? libraryIdNumber : undefined,
|
||||
})
|
||||
: null;
|
||||
const backHref =
|
||||
safeBackTo ||
|
||||
mangaSeriesHref ||
|
||||
`/item/${encodeURIComponent(item.content_id)}${
|
||||
libraryIdParam ? `?libraryId=${encodeURIComponent(libraryIdParam)}` : ""
|
||||
}`;
|
||||
const nextChapterHref =
|
||||
nextChapter && mangaSeriesHref
|
||||
? buildMediaPlayHref({
|
||||
contentId: nextChapter.chapter.content_id,
|
||||
type: "ebook",
|
||||
libraryId: Number.isFinite(libraryIdNumber) ? libraryIdNumber : undefined,
|
||||
backTo: mangaSeriesHref,
|
||||
})
|
||||
: null;
|
||||
const showEndOfBookNext = nextChapterHref != null && (readerProgress ?? 0) >= 0.995;
|
||||
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
@@ -529,7 +595,7 @@ export default function EbookReader() {
|
||||
<div className="bg-background min-h-screen">
|
||||
<header className="border-border/70 bg-background/95 sticky top-0 z-20 border-b backdrop-blur">
|
||||
<div className="flex h-14 items-center gap-3 px-4">
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Back to ebook">
|
||||
<Button asChild variant="ghost" size="icon" aria-label="Back">
|
||||
<Link to={backHref}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
@@ -538,6 +604,22 @@ export default function EbookReader() {
|
||||
<div className="truncate text-sm font-semibold">{item.title}</div>
|
||||
<div className="text-muted-foreground truncate text-xs">{format.toUpperCase()}</div>
|
||||
</div>
|
||||
{nextChapterHref && nextChapter && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden gap-1 sm:inline-flex"
|
||||
title={`Next: ${nextChapter.label}`}
|
||||
>
|
||||
<Link to={nextChapterHref}>
|
||||
<span className="text-muted-foreground max-w-36 truncate text-xs">
|
||||
{nextChapter.label}
|
||||
</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{progressLabel && (
|
||||
<div className="text-muted-foreground hidden min-w-12 text-center text-xs tabular-nums sm:block">
|
||||
{progressLabel}
|
||||
@@ -579,15 +661,17 @@ export default function EbookReader() {
|
||||
>
|
||||
<Bookmark className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={readerSettings.readingRuler ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Toggle reading ruler"
|
||||
title="Reading ruler"
|
||||
onClick={() => updateReaderSettings({ readingRuler: !readerSettings.readingRuler })}
|
||||
>
|
||||
<Ruler className="size-4" />
|
||||
</Button>
|
||||
{!isComicFormat && (
|
||||
<Button
|
||||
variant={readerSettings.readingRuler ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Toggle reading ruler"
|
||||
title="Reading ruler"
|
||||
onClick={() => updateReaderSettings({ readingRuler: !readerSettings.readingRuler })}
|
||||
>
|
||||
<Ruler className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -887,90 +971,96 @@ export default function EbookReader() {
|
||||
Reset
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
<div className="border-border space-y-2 border-b pb-3">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Reading profile
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{READER_PROFILES.map((profile) => {
|
||||
const active = profileIsActive(profile, readerSettings);
|
||||
return (
|
||||
<Button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
variant={active ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={active}
|
||||
onClick={() => updateReaderSettings(profile.settings)}
|
||||
className="h-auto min-h-11 w-full justify-between px-3 py-2 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{profile.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{profile.description}
|
||||
{!isComicFormat && (
|
||||
<div className="border-border space-y-2 border-b pb-3">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Reading profile
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{READER_PROFILES.map((profile) => {
|
||||
const active = profileIsActive(profile, readerSettings);
|
||||
return (
|
||||
<Button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
variant={active ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={active}
|
||||
onClick={() => updateReaderSettings(profile.settings)}
|
||||
className="h-auto min-h-11 w-full justify-between px-3 py-2 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{profile.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{profile.description}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{active && <Check className="size-4 shrink-0" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{active && <Check className="size-4 shrink-0" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Volume2 className="size-4" />
|
||||
Read aloud
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label="Speak text"
|
||||
onClick={handleSpeak}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Speak
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={tts.state === "paused" ? "Resume speech" : "Pause speech"}
|
||||
onClick={tts.state === "paused" ? tts.resume : tts.pause}
|
||||
>
|
||||
<Pause className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Stop speech"
|
||||
onClick={tts.stop}
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ReaderRange
|
||||
label="Speech rate"
|
||||
value={ttsRate}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={setTtsRate}
|
||||
/>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">Voice</span>
|
||||
<select
|
||||
aria-label="Voice"
|
||||
value={ttsVoiceURI}
|
||||
onChange={(event) => setTtsVoiceURI(event.target.value)}
|
||||
className="border-border bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{tts.voices.map((voice) => (
|
||||
<option key={voice.voiceURI} value={voice.voiceURI}>
|
||||
{voice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{!isComicFormat && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Volume2 className="size-4" />
|
||||
Read aloud
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label="Speak text"
|
||||
onClick={handleSpeak}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Speak
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={tts.state === "paused" ? "Resume speech" : "Pause speech"}
|
||||
onClick={tts.state === "paused" ? tts.resume : tts.pause}
|
||||
>
|
||||
<Pause className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Stop speech"
|
||||
onClick={tts.stop}
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ReaderRange
|
||||
label="Speech rate"
|
||||
value={ttsRate}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={setTtsRate}
|
||||
/>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">Voice</span>
|
||||
<select
|
||||
aria-label="Voice"
|
||||
value={ttsVoiceURI}
|
||||
onChange={(event) => setTtsVoiceURI(event.target.value)}
|
||||
className="border-border bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{tts.voices.map((voice) => (
|
||||
<option key={voice.voiceURI} value={voice.voiceURI}>
|
||||
{voice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-border space-y-2 border-t pt-3">
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
@@ -1004,33 +1094,39 @@ export default function EbookReader() {
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">Font</span>
|
||||
<select
|
||||
aria-label="Font family"
|
||||
value={readerSettings.fontFamily}
|
||||
onChange={(event) => updateReaderSettings({ fontFamily: event.target.value })}
|
||||
className="border-border bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{READER_FONT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
{!READER_FONT_OPTIONS.some(
|
||||
(option) => option.value === readerSettings.fontFamily,
|
||||
) && <option value={readerSettings.fontFamily}>Custom</option>}
|
||||
</select>
|
||||
</label>
|
||||
<ReaderRange
|
||||
label="Font size"
|
||||
value={readerSettings.fontSize}
|
||||
min={80}
|
||||
max={180}
|
||||
step={1}
|
||||
suffix="%"
|
||||
onChange={(fontSize) => updateReaderSettings({ fontSize })}
|
||||
/>
|
||||
{!isComicFormat && (
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">Font</span>
|
||||
<select
|
||||
aria-label="Font family"
|
||||
value={readerSettings.fontFamily}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({ fontFamily: event.target.value })
|
||||
}
|
||||
className="border-border bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{READER_FONT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
{!READER_FONT_OPTIONS.some(
|
||||
(option) => option.value === readerSettings.fontFamily,
|
||||
) && <option value={readerSettings.fontFamily}>Custom</option>}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{!isComicFormat && (
|
||||
<ReaderRange
|
||||
label="Font size"
|
||||
value={readerSettings.fontSize}
|
||||
min={80}
|
||||
max={180}
|
||||
step={1}
|
||||
suffix="%"
|
||||
onChange={(fontSize) => updateReaderSettings({ fontSize })}
|
||||
/>
|
||||
)}
|
||||
<ReaderRange
|
||||
label="Brightness"
|
||||
value={readerSettings.fontBrightness}
|
||||
@@ -1040,14 +1136,16 @@ export default function EbookReader() {
|
||||
suffix="%"
|
||||
onChange={(fontBrightness) => updateReaderSettings({ fontBrightness })}
|
||||
/>
|
||||
<ReaderRange
|
||||
label="Line height"
|
||||
value={readerSettings.lineHeight}
|
||||
min={1.1}
|
||||
max={2.4}
|
||||
step={0.05}
|
||||
onChange={(lineHeight) => updateReaderSettings({ lineHeight })}
|
||||
/>
|
||||
{!isComicFormat && (
|
||||
<ReaderRange
|
||||
label="Line height"
|
||||
value={readerSettings.lineHeight}
|
||||
min={1.1}
|
||||
max={2.4}
|
||||
step={0.05}
|
||||
onChange={(lineHeight) => updateReaderSettings({ lineHeight })}
|
||||
/>
|
||||
)}
|
||||
<ReaderRange
|
||||
label="Margin"
|
||||
value={readerSettings.margin}
|
||||
@@ -1057,7 +1155,7 @@ export default function EbookReader() {
|
||||
suffix="px"
|
||||
onChange={(margin) => updateReaderSettings({ margin })}
|
||||
/>
|
||||
{readerSettings.flow !== "scrolled" && (
|
||||
{!isComicFormat && readerSettings.flow !== "scrolled" && (
|
||||
<ReaderRange
|
||||
label="Width"
|
||||
value={readerSettings.maxWidth}
|
||||
@@ -1069,17 +1167,19 @@ export default function EbookReader() {
|
||||
/>
|
||||
)}
|
||||
<div className="border-border space-y-2 border-t pt-3">
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>Hyphenation</span>
|
||||
<input
|
||||
aria-label="Hyphenation"
|
||||
type="checkbox"
|
||||
checked={readerSettings.hyphenation}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({ hyphenation: event.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{!isComicFormat && (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>Hyphenation</span>
|
||||
<input
|
||||
aria-label="Hyphenation"
|
||||
type="checkbox"
|
||||
checked={readerSettings.hyphenation}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({ hyphenation: event.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>Right to left</span>
|
||||
<input
|
||||
@@ -1089,17 +1189,19 @@ export default function EbookReader() {
|
||||
onChange={(event) => updateReaderSettings({ rtl: event.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>Reading ruler</span>
|
||||
<input
|
||||
aria-label="Reading ruler"
|
||||
type="checkbox"
|
||||
checked={readerSettings.readingRuler}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({ readingRuler: event.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{!isComicFormat && (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>Reading ruler</span>
|
||||
<input
|
||||
aria-label="Reading ruler"
|
||||
type="checkbox"
|
||||
checked={readerSettings.readingRuler}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({ readingRuler: event.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{readerSettings.readingRuler && (
|
||||
<ReaderRange
|
||||
label="Ruler position"
|
||||
@@ -1112,23 +1214,27 @@ export default function EbookReader() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">Writing mode</span>
|
||||
<select
|
||||
aria-label="Writing mode"
|
||||
value={readerSettings.writingMode}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({
|
||||
writingMode: event.target.value as ReaderSettings["writingMode"],
|
||||
})
|
||||
}
|
||||
className="border-border bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="horizontal-tb">Horizontal</option>
|
||||
<option value="vertical-rl">Vertical</option>
|
||||
</select>
|
||||
</label>
|
||||
{!isComicFormat && (
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Writing mode
|
||||
</span>
|
||||
<select
|
||||
aria-label="Writing mode"
|
||||
value={readerSettings.writingMode}
|
||||
onChange={(event) =>
|
||||
updateReaderSettings({
|
||||
writingMode: event.target.value as ReaderSettings["writingMode"],
|
||||
})
|
||||
}
|
||||
className="border-border bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="horizontal-tb">Horizontal</option>
|
||||
<option value="vertical-rl">Vertical</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{readerSettings.flow !== "scrolled" && (
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs font-medium">Spread</span>
|
||||
@@ -1167,6 +1273,20 @@ export default function EbookReader() {
|
||||
</aside>
|
||||
)}
|
||||
</main>
|
||||
{showEndOfBookNext && nextChapter && nextChapterHref && (
|
||||
<div className="fixed inset-x-0 bottom-6 z-30 flex justify-center px-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-11 gap-2 rounded-full px-6 text-[15px] font-bold shadow-lg"
|
||||
>
|
||||
<Link to={nextChapterHref}>
|
||||
Next: {nextChapter.label}
|
||||
<ChevronRight className="size-[18px]" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,30 +261,27 @@ export default function DetailHero({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crew line replaces genres when provided */}
|
||||
{crewLine ? (
|
||||
<div className="mt-3">{crewLine}</div>
|
||||
) : (
|
||||
genres &&
|
||||
genres.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{genres.map((genre) =>
|
||||
genreHref ? (
|
||||
<a
|
||||
key={genre}
|
||||
href={genreHref(genre)}
|
||||
className="metadata-badge hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
) : (
|
||||
<span key={genre} className="metadata-badge">
|
||||
{genre}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
{/* Crew line and genre chips render independently: pages that
|
||||
fold genres into their crew line simply omit the genres prop. */}
|
||||
{crewLine && <div className="mt-3">{crewLine}</div>}
|
||||
{genres && genres.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{genres.map((genre) =>
|
||||
genreHref ? (
|
||||
<a
|
||||
key={genre}
|
||||
href={genreHref(genre)}
|
||||
className="metadata-badge hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
) : (
|
||||
<span key={genre} className="metadata-badge">
|
||||
{genre}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import type { ItemDetail, MangaChapter } from "@/api/types";
|
||||
|
||||
vi.mock("@/hooks/useAmbientColor", () => ({ useAmbientColor: () => undefined }));
|
||||
vi.mock("@/components/PageBack", () => ({ default: () => null }));
|
||||
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { download_allowed: true } }) }));
|
||||
vi.mock("@/hooks/queries/items", () => ({
|
||||
useWatchedStateMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRefreshItemMetadata: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/queries/catalogRead", () => ({
|
||||
fetchCatalogItemVersions: vi.fn().mockResolvedValue([]),
|
||||
useMangaSeriesFiles: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
}));
|
||||
vi.mock("@/pages/ItemDetail/components/MetadataBadges", () => ({ default: () => null }));
|
||||
vi.mock("@/pages/ItemDetail/DetailHero", () => ({
|
||||
default: ({ title, actions }: { title: string; actions?: ReactNode }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{actions}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import MangaContent from "./MangaContent";
|
||||
|
||||
function mangaItem(chapters: MangaChapter[]): ItemDetail & { type: "manga" } {
|
||||
return {
|
||||
content_id: "manga-1",
|
||||
type: "manga",
|
||||
title: "Test Manga",
|
||||
year: 2024,
|
||||
overview: "",
|
||||
runtime: 0,
|
||||
content_rating: "",
|
||||
genres: [],
|
||||
rating_imdb: null,
|
||||
rating_tmdb: null,
|
||||
rating_rt_critic: null,
|
||||
rating_rt_audience: null,
|
||||
imdb_id: "",
|
||||
tmdb_id: "",
|
||||
tvdb_id: "",
|
||||
cast: [],
|
||||
crew: [],
|
||||
studios: [],
|
||||
networks: [],
|
||||
countries: [],
|
||||
release_date: null,
|
||||
first_air_date: null,
|
||||
last_air_date: null,
|
||||
season_count: null,
|
||||
poster_url: "",
|
||||
poster_thumbhash: "",
|
||||
backdrop_url: "",
|
||||
backdrop_thumbhash: "",
|
||||
logo_url: "",
|
||||
versions: [],
|
||||
subtitles: [],
|
||||
intro: null,
|
||||
credits: null,
|
||||
manga: { chapters },
|
||||
} as ItemDetail & { type: "manga" };
|
||||
}
|
||||
|
||||
function volumeSeries(): ItemDetail & { type: "manga" } {
|
||||
return mangaItem([
|
||||
{ content_id: "v01", title: "Railgun v01", chapter_index: 1, volume: "v01" },
|
||||
{ content_id: "v02", title: "Railgun v02", chapter_index: 2, volume: "v02" },
|
||||
]);
|
||||
}
|
||||
|
||||
function multiChapterVolume(): ItemDetail & { type: "manga" } {
|
||||
return mangaItem([
|
||||
{ content_id: "v1-c1", title: "Chapter 1", chapter_index: 1, volume: "v01" },
|
||||
{ content_id: "v1-c2", title: "Chapter 2", chapter_index: 2, volume: "v01" },
|
||||
]);
|
||||
}
|
||||
|
||||
const seriesBackTo = "&backTo=" + encodeURIComponent("/item/manga-1?libraryId=7");
|
||||
|
||||
describe("MangaContent", () => {
|
||||
it("renders a volume-based series as flat 'Volume N' rows with no nested chapter", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent item={volumeSeries()} libraryId={7} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Flat rows: the volume labels ARE the links, and there is no redundant
|
||||
// "Chapter 1" nested under "Volume 1".
|
||||
expect(screen.getByRole("link", { name: /^Volume 1$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /^Volume 2$/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Chapter \d/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links a flat volume row to the ebook reader by content_id with the library id and a backTo to the series", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent item={volumeSeries()} libraryId={7} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The reader link carries the series content id as backTo so the reader's
|
||||
// back action returns to the series instead of looping into the chapter.
|
||||
expect(screen.getByRole("link", { name: /^Volume 1$/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/reader/ebook/v01?libraryId=7" + seriesBackTo,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers per-row Read, Mark-read, and Download actions", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent item={volumeSeries()} libraryId={7} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Read remains the row link.
|
||||
expect(screen.getByRole("link", { name: /^Volume 1$/i })).toBeInTheDocument();
|
||||
// Mark-read + Download toggles exist per row (2 volumes → 2 of each).
|
||||
expect(screen.getAllByRole("button", { name: /Mark chapter read/i })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: /Download chapter/i })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a Start Reading hero CTA targeting the first volume on an unread series", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent item={volumeSeries()} libraryId={7} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const cta = screen.getByRole("link", { name: /Start Reading/i });
|
||||
expect(cta).toHaveTextContent("Volume 1");
|
||||
expect(cta).toHaveAttribute("href", "/reader/ebook/v01?libraryId=7" + seriesBackTo);
|
||||
});
|
||||
|
||||
it("shows a Continue hero CTA targeting the first unread chapter mid-series", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent
|
||||
item={mangaItem([
|
||||
{
|
||||
content_id: "v01",
|
||||
title: "Railgun v01",
|
||||
chapter_index: 1,
|
||||
volume: "v01",
|
||||
read: true,
|
||||
},
|
||||
{ content_id: "v02", title: "Railgun v02", chapter_index: 2, volume: "v02" },
|
||||
{ content_id: "v03", title: "Railgun v03", chapter_index: 3, volume: "v03" },
|
||||
])}
|
||||
libraryId={7}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const cta = screen.getByRole("link", { name: /Continue/i });
|
||||
expect(cta).toHaveTextContent("Volume 2");
|
||||
expect(cta).toHaveAttribute("href", "/reader/ebook/v02?libraryId=7" + seriesBackTo);
|
||||
});
|
||||
|
||||
it("offers a Read Again CTA from the start once every chapter is read", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent
|
||||
item={mangaItem([
|
||||
{
|
||||
content_id: "v01",
|
||||
title: "Railgun v01",
|
||||
chapter_index: 1,
|
||||
volume: "v01",
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
content_id: "v02",
|
||||
title: "Railgun v02",
|
||||
chapter_index: 2,
|
||||
volume: "v02",
|
||||
read: true,
|
||||
},
|
||||
])}
|
||||
libraryId={7}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const cta = screen.getByRole("link", { name: /Read Again/i });
|
||||
expect(cta).toHaveTextContent("Volume 1");
|
||||
});
|
||||
|
||||
it("marks read rows with a persistent check and seeds the toggle from server state", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent
|
||||
item={mangaItem([
|
||||
{
|
||||
content_id: "v01",
|
||||
title: "Railgun v01",
|
||||
chapter_index: 1,
|
||||
volume: "v01",
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
content_id: "v02",
|
||||
title: "Railgun v02",
|
||||
chapter_index: 2,
|
||||
volume: "v02",
|
||||
read: false,
|
||||
},
|
||||
])}
|
||||
libraryId={7}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The read row carries a visible "Read" indicator next to its label.
|
||||
const readRow = screen.getByRole("link", { name: /Volume 1\s*Read/i });
|
||||
expect(readRow).toBeInTheDocument();
|
||||
|
||||
// The read chapter's toggle starts pressed (label flips to "unread"); the
|
||||
// unread chapter's toggle stays in the default "read" prompt state.
|
||||
const readToggle = screen.getByRole("button", { name: /Mark chapter unread/i });
|
||||
expect(readToggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const unreadToggle = screen.getByRole("button", { name: /Mark chapter read/i });
|
||||
expect(unreadToggle).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("nests a multi-chapter volume as a section header with chapter rows", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent item={multiChapterVolume()} libraryId={7} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// "Volume 1" is a plain header (not a link); chapters are the links.
|
||||
expect(screen.queryByRole("link", { name: /^Volume 1$/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Volume 1")).toBeInTheDocument();
|
||||
|
||||
const firstChapter = screen.getByRole("link", { name: /^Chapter 1$/i });
|
||||
expect(firstChapter).toHaveAttribute("href", "/reader/ebook/v1-c1?libraryId=7" + seriesBackTo);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
const order = links
|
||||
.map((link) => within(link).queryByText(/Chapter \d/)?.textContent)
|
||||
.filter(Boolean);
|
||||
expect(order.indexOf("Chapter 1")).toBeLessThan(order.indexOf("Chapter 2"));
|
||||
});
|
||||
|
||||
it("shows an inline progress indicator for a part-read chapter", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent
|
||||
item={mangaItem([
|
||||
{
|
||||
content_id: "v01",
|
||||
title: "Railgun v01",
|
||||
chapter_index: 1,
|
||||
volume: "v01",
|
||||
progress: 0.42,
|
||||
},
|
||||
])}
|
||||
libraryId={7}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle("42% read")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses a fully read volume section by default and expands on toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent
|
||||
item={mangaItem([
|
||||
{
|
||||
content_id: "v1-c1",
|
||||
title: "Chapter 1",
|
||||
chapter_index: 1,
|
||||
volume: "v01",
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
content_id: "v1-c2",
|
||||
title: "Chapter 2",
|
||||
chapter_index: 2,
|
||||
volume: "v01",
|
||||
read: true,
|
||||
},
|
||||
])}
|
||||
libraryId={7}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const header = screen.getByRole("button", { name: /Volume 1/i });
|
||||
expect(header).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("link", { name: /^Chapter 1/i })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(header);
|
||||
expect(screen.getByRole("link", { name: /^Chapter 1/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chapter cover thumbnails when the payload carries them", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent
|
||||
item={mangaItem([
|
||||
{
|
||||
content_id: "v01",
|
||||
title: "Railgun v01",
|
||||
chapter_index: 1,
|
||||
volume: "v01",
|
||||
poster_url: "https://img.test/v01.jpg",
|
||||
},
|
||||
])}
|
||||
libraryId={7}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("link", { name: /^Volume 1$/i });
|
||||
expect(within(row).getByRole("presentation")).toHaveAttribute(
|
||||
"src",
|
||||
"https://img.test/v01.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a View Details action in the series menu", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MangaContent item={volumeSeries()} libraryId={7} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /More actions/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CornerDownRight,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import type { FileVersion, ItemDetail, MangaChapter } from "@/api/types";
|
||||
import DownloadVersionPicker from "@/components/DownloadVersionPicker";
|
||||
import MangaFilesDialog from "@/components/MangaFilesDialog";
|
||||
import PageBack from "@/components/PageBack";
|
||||
import RefreshMetadataDialog from "@/components/RefreshMetadataDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAmbientColor } from "@/hooks/useAmbientColor";
|
||||
import { fetchCatalogItemVersions } from "@/hooks/queries/catalogRead";
|
||||
import { useRefreshItemMetadata, useWatchedStateMutation } from "@/hooks/queries/items";
|
||||
import { buildItemHref, buildMediaPlayHref } from "@/lib/mediaNavigation";
|
||||
import {
|
||||
buildMangaList,
|
||||
chapterLabel,
|
||||
firstUnreadChapter,
|
||||
flattenMangaList,
|
||||
type MangaListEntry,
|
||||
} from "@/lib/mangaChapters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DetailHero from "./DetailHero";
|
||||
import HeroCrewLine from "./components/HeroCrewLine";
|
||||
import MetadataBadges from "./components/MetadataBadges";
|
||||
import ScoreRow from "./components/ScoreRow";
|
||||
import { formatFileSize, formatPageCount, metadataLine } from "./components/versionFormatUtils";
|
||||
|
||||
function genreHref(genre: string, libraryId?: number): string {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) {
|
||||
params.set("tab", "library");
|
||||
params.set("genre", genre);
|
||||
return `/library/${libraryId}?${params.toString()}`;
|
||||
}
|
||||
params.set("source", "query");
|
||||
params.set("type", "manga");
|
||||
params.set("genre", genre);
|
||||
return `/catalog?${params.toString()}`;
|
||||
}
|
||||
|
||||
function chapterVersionSummary(version: FileVersion): string {
|
||||
return metadataLine([
|
||||
version.container ? version.container.toUpperCase() : undefined,
|
||||
formatFileSize(version.file_size),
|
||||
formatPageCount(version.duration),
|
||||
]);
|
||||
}
|
||||
|
||||
// chapterReaderHref builds the reader link for a chapter with the series page
|
||||
// as the explicit back target (avoids the chapter→reader→chapter loop).
|
||||
function chapterReaderHref(
|
||||
chapterContentId: string,
|
||||
seriesContentId: string,
|
||||
libraryId?: number,
|
||||
): string {
|
||||
const backTo = buildItemHref({ contentId: seriesContentId, libraryId });
|
||||
return buildMediaPlayHref({
|
||||
contentId: chapterContentId,
|
||||
type: "ebook",
|
||||
libraryId,
|
||||
backTo,
|
||||
});
|
||||
}
|
||||
|
||||
// MangaRow is a single reader row used for volume units, loose chapters, and
|
||||
// chapters nested inside a volume section. Each row offers Read (the reader
|
||||
// link), Mark-read, and Download. Because the manga detail payload carries only
|
||||
// the chapter's content_id (no file versions), Download lazily fetches the
|
||||
// chapter's versions on demand and hands them to the shared picker.
|
||||
function MangaRow({
|
||||
chapter,
|
||||
label,
|
||||
seriesContentId,
|
||||
libraryId,
|
||||
}: {
|
||||
chapter: MangaChapter;
|
||||
label: string;
|
||||
seriesContentId: string;
|
||||
libraryId?: number;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const readerHref = chapterReaderHref(chapter.content_id, seriesContentId, libraryId);
|
||||
|
||||
// The mutation carries series_id so the series detail (this page's payload,
|
||||
// including every chapter's read flag) is invalidated and refetched after a
|
||||
// toggle. The local override only bridges the optimistic gap until the
|
||||
// refreshed chapter.read arrives.
|
||||
const watchedMutation = useWatchedStateMutation({
|
||||
content_id: chapter.content_id,
|
||||
type: "ebook",
|
||||
series_id: seriesContentId,
|
||||
});
|
||||
const [readOverride, setReadOverride] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
setReadOverride(null);
|
||||
}, [chapter.read]);
|
||||
const markedRead = readOverride ?? chapter.read ?? false;
|
||||
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
const [downloadVersions, setDownloadVersions] = useState<FileVersion[] | null>(null);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
const canDownload = Boolean(user?.download_allowed);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (loadingVersions) return;
|
||||
if (downloadVersions && downloadVersions.length > 0) {
|
||||
setDownloadOpen(true);
|
||||
return;
|
||||
}
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const versions = await fetchCatalogItemVersions(chapter.content_id);
|
||||
if (versions.length === 0) {
|
||||
toast.error("No downloadable files for this chapter");
|
||||
return;
|
||||
}
|
||||
setDownloadVersions(versions);
|
||||
setDownloadOpen(true);
|
||||
} catch {
|
||||
toast.error("Couldn't load chapter files. Try again later");
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const progressPct =
|
||||
!markedRead && typeof chapter.progress === "number" && chapter.progress > 0
|
||||
? Math.max(1, Math.min(99, Math.round(chapter.progress * 100)))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`manga-chapter-${chapter.content_id}`}
|
||||
className="hover:bg-muted/40 flex items-center gap-3 px-4 py-2 transition-colors"
|
||||
>
|
||||
<Link to={readerHref} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{chapter.poster_url ? (
|
||||
<img
|
||||
src={chapter.poster_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-12 w-8 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<BookOpen className="text-muted-foreground size-[18px] flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[15px] font-medium",
|
||||
markedRead ? "text-muted-foreground" : "text-foreground/90",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{markedRead && (
|
||||
<span className="text-success flex-shrink-0" title="Read">
|
||||
<Check className="size-4" />
|
||||
<span className="sr-only">Read</span>
|
||||
</span>
|
||||
)}
|
||||
{progressPct != null && (
|
||||
<span className="flex flex-shrink-0 items-center gap-1.5" title={`${progressPct}% read`}>
|
||||
<span className="bg-muted block h-1 w-16 overflow-hidden rounded-full">
|
||||
<span
|
||||
className="bg-primary block h-full rounded-full"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">{progressPct}%</span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={markedRead ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label={markedRead ? "Mark chapter unread" : "Mark chapter read"}
|
||||
aria-pressed={markedRead}
|
||||
title={markedRead ? "Mark unread" : "Mark read"}
|
||||
disabled={watchedMutation.isPending}
|
||||
onClick={() => {
|
||||
const next = !markedRead;
|
||||
setReadOverride(next);
|
||||
watchedMutation.mutate(next, {
|
||||
onError: () => setReadOverride(!next),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
{canDownload && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Download chapter"
|
||||
title="Download"
|
||||
disabled={loadingVersions}
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
{loadingVersions ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{canDownload && downloadVersions && (
|
||||
<DownloadVersionPicker
|
||||
open={downloadOpen}
|
||||
onOpenChange={setDownloadOpen}
|
||||
versions={downloadVersions}
|
||||
title={label}
|
||||
summaryBuilder={chapterVersionSummary}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MangaContent({
|
||||
item,
|
||||
libraryId,
|
||||
}: {
|
||||
item: ItemDetail & { type: "manga" };
|
||||
libraryId?: number;
|
||||
}) {
|
||||
useAmbientColor(item.poster_thumbhash);
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const entries = useMemo(() => buildMangaList(item.manga?.chapters ?? []), [item.manga?.chapters]);
|
||||
const year = item.year ? String(item.year) : "";
|
||||
const publisher = item.studios?.[0];
|
||||
const chapterRows = item.manga?.chapters ?? [];
|
||||
// Derive the badge counts from the rendered list so they always match the
|
||||
// rows on screen: a volume/section entry is one volume (buildMangaList
|
||||
// already canonicalizes v01 ≡ 1), a loose chapter entry is one chapter.
|
||||
const volumeCount = useMemo(
|
||||
() => entries.filter((e) => e.kind === "volume" || e.kind === "section").length,
|
||||
[entries],
|
||||
);
|
||||
const looseChapterCount = useMemo(
|
||||
() => entries.filter((e) => e.kind === "chapter").length,
|
||||
[entries],
|
||||
);
|
||||
|
||||
// The resume target is the first unfinished chapter in reading order. Any
|
||||
// finished chapter before it means the viewer is mid-series ("Continue");
|
||||
// a fully read series restarts from the beginning.
|
||||
const anyRead = chapterRows.some((chapter) => chapter.read === true);
|
||||
const resume = useMemo(() => firstUnreadChapter(entries), [entries]);
|
||||
const fallbackStart = entries.length > 0 ? flattenFirst(entries) : null;
|
||||
const cta = resume
|
||||
? { ...resume, verb: anyRead ? "Continue" : "Start Reading" }
|
||||
: fallbackStart
|
||||
? { ...fallbackStart, verb: "Read Again" }
|
||||
: null;
|
||||
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [refreshOpen, setRefreshOpen] = useState(false);
|
||||
const refreshMetadataMutation = useRefreshItemMetadata();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DetailHero
|
||||
title={item.title}
|
||||
topNav={<PageBack />}
|
||||
context="Manga"
|
||||
studioLabel={publisher}
|
||||
backdropUrl={item.backdrop_url}
|
||||
backdropThumbhash={item.backdrop_thumbhash}
|
||||
posterUrl={item.poster_url}
|
||||
posterThumbhash={item.poster_thumbhash}
|
||||
metadata={
|
||||
<MetadataBadges
|
||||
year={year || undefined}
|
||||
contentRating={item.content_rating || undefined}
|
||||
volumeCount={volumeCount}
|
||||
chapterCount={looseChapterCount}
|
||||
status={item.show_status || undefined}
|
||||
/>
|
||||
}
|
||||
scoreRow={
|
||||
<ScoreRow
|
||||
ratingImdb={item.rating_imdb}
|
||||
ratingRtCritic={item.rating_rt_critic}
|
||||
ratingRtAudience={item.rating_rt_audience}
|
||||
/>
|
||||
}
|
||||
overview={item.overview}
|
||||
crewLine={<HeroCrewLine crew={item.crew ?? []} />}
|
||||
genres={item.genres}
|
||||
genreHref={(genre) => genreHref(genre, libraryId)}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{cta && (
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 gap-2.5 rounded-full px-6 text-[15px] font-bold tracking-wide shadow-md"
|
||||
>
|
||||
<Link to={chapterReaderHref(cta.chapter.content_id, item.content_id, libraryId)}>
|
||||
<BookOpen className="size-[18px]" />
|
||||
{cta.verb}
|
||||
<span className="text-primary-foreground/75 text-xs font-semibold">
|
||||
{cta.label}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="icon-lg"
|
||||
title="More"
|
||||
aria-label="More actions"
|
||||
className="size-11 rounded-full"
|
||||
>
|
||||
<MoreVertical className="size-[18px]" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => setFilesOpen(true)}>
|
||||
<FileText className="size-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={refreshMetadataMutation.isPending}
|
||||
onSelect={() => setRefreshOpen(true)}
|
||||
>
|
||||
{refreshMetadataMutation.isPending && (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
)}
|
||||
Refresh Metadata
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page-shell space-y-4 py-10">
|
||||
{resume && flattenMangaList(entries).length > 10 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground gap-1.5"
|
||||
onClick={() => {
|
||||
document
|
||||
.getElementById(`manga-chapter-${resume.chapter.content_id}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}}
|
||||
>
|
||||
<CornerDownRight className="size-4" />
|
||||
Jump to {resume.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No chapters found. Chapters appear here once the library scan completes.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-border/40 border-border/40 divide-y overflow-hidden rounded-lg border">
|
||||
{entries.map((entry) =>
|
||||
entry.kind === "section" ? (
|
||||
<MangaSection
|
||||
key={`section-${entry.label}`}
|
||||
entry={entry}
|
||||
seriesContentId={item.content_id}
|
||||
libraryId={libraryId}
|
||||
/>
|
||||
) : (
|
||||
<li key={entry.chapter.content_id}>
|
||||
<MangaRow
|
||||
chapter={entry.chapter}
|
||||
label={entry.label}
|
||||
seriesContentId={item.content_id}
|
||||
libraryId={libraryId}
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MangaFilesDialog
|
||||
contentId={item.content_id}
|
||||
title={item.title}
|
||||
open={filesOpen}
|
||||
onOpenChange={setFilesOpen}
|
||||
/>
|
||||
<RefreshMetadataDialog
|
||||
open={refreshOpen}
|
||||
onOpenChange={setRefreshOpen}
|
||||
onConfirm={(mode) => {
|
||||
setRefreshOpen(false);
|
||||
refreshMetadataMutation.mutate({ item, mode });
|
||||
}}
|
||||
isPending={refreshMetadataMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MangaSection renders a multi-chapter volume as a collapsible block with a
|
||||
// sticky header. Fully read sections start collapsed so long series open at
|
||||
// the unread frontier.
|
||||
function MangaSection({
|
||||
entry,
|
||||
seriesContentId,
|
||||
libraryId,
|
||||
}: {
|
||||
entry: Extract<MangaListEntry, { kind: "section" }>;
|
||||
seriesContentId: string;
|
||||
libraryId?: number;
|
||||
}) {
|
||||
const allRead = entry.chapters.every((chapter) => chapter.read === true);
|
||||
const [open, setOpen] = useState(!allRead);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="bg-background/95 hover:bg-muted/40 sticky top-0 z-10 flex w-full items-center justify-between px-4 py-2 backdrop-blur transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground text-sm font-bold tracking-tight uppercase">
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
{allRead && (
|
||||
<span className="text-success flex items-center" title="All chapters read">
|
||||
<Check className="size-3.5" />
|
||||
<span className="sr-only">All chapters read</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.chapters.length} {entry.chapters.length === 1 ? "chapter" : "chapters"}
|
||||
<ChevronDown className={cn("size-4 transition-transform", !open && "-rotate-90")} />
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="divide-border/40 divide-y">
|
||||
{entry.chapters.map((chapter) => (
|
||||
<li key={chapter.content_id} className="pl-4">
|
||||
<MangaRow
|
||||
chapter={chapter}
|
||||
label={chapterLabel(chapter)}
|
||||
seriesContentId={seriesContentId}
|
||||
libraryId={libraryId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// flattenFirst returns the first readable unit of the series (used as the
|
||||
// re-read target once everything is read).
|
||||
function flattenFirst(entries: ReturnType<typeof buildMangaList>) {
|
||||
const [first] = entries;
|
||||
if (!first) return null;
|
||||
if (first.kind === "section") {
|
||||
const [chapter] = first.chapters;
|
||||
return chapter ? { chapter, label: `${first.label} · ${chapterLabel(chapter)}` } : null;
|
||||
}
|
||||
return { chapter: first.chapter, label: first.label };
|
||||
}
|
||||
@@ -50,14 +50,31 @@ export default function HeroCrewLine({
|
||||
.map((c): CrewPerson => ({ name: c.name, personId: c.person_id }))
|
||||
.slice(0, 2);
|
||||
|
||||
// Book/manga credits: shown when the item has Author people (video items
|
||||
// never do, so the section simply doesn't render there).
|
||||
const authors = crew
|
||||
.filter((c) => c.job === "Author")
|
||||
.map((c): CrewPerson => ({ name: c.name, personId: c.person_id }))
|
||||
.slice(0, 3);
|
||||
|
||||
const hasDirectors = directors.length > 0;
|
||||
const hasWriters = writers.length > 0;
|
||||
const hasAuthors = authors.length > 0;
|
||||
const hasGenres = genres && genres.length > 0;
|
||||
|
||||
if (!hasDirectors && !hasWriters && !hasGenres) return null;
|
||||
if (!hasDirectors && !hasWriters && !hasAuthors && !hasGenres) return null;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground text-[13px]">
|
||||
{hasAuthors && (
|
||||
<>
|
||||
<span className="text-muted-foreground/60">By </span>
|
||||
<CrewNames people={authors} />
|
||||
</>
|
||||
)}
|
||||
{hasAuthors && (hasDirectors || hasWriters || hasGenres) && (
|
||||
<span className="text-muted-foreground/40 mx-2">·</span>
|
||||
)}
|
||||
{hasDirectors && (
|
||||
<>
|
||||
<span className="text-muted-foreground/60">{jobLabel} </span>
|
||||
|
||||
@@ -4,6 +4,8 @@ interface MetadataBadgesProps {
|
||||
duration?: string;
|
||||
seasonCount?: number;
|
||||
episodeCount?: number;
|
||||
volumeCount?: number;
|
||||
chapterCount?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -13,6 +15,8 @@ export default function MetadataBadges({
|
||||
duration,
|
||||
seasonCount,
|
||||
episodeCount,
|
||||
volumeCount,
|
||||
chapterCount,
|
||||
status,
|
||||
}: MetadataBadgesProps) {
|
||||
return (
|
||||
@@ -30,6 +34,16 @@ export default function MetadataBadges({
|
||||
{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}
|
||||
</span>
|
||||
)}
|
||||
{volumeCount != null && volumeCount > 0 && (
|
||||
<span className="metadata-badge">
|
||||
{volumeCount} {volumeCount === 1 ? "Volume" : "Volumes"}
|
||||
</span>
|
||||
)}
|
||||
{chapterCount != null && chapterCount > 0 && (
|
||||
<span className="metadata-badge">
|
||||
{chapterCount} {chapterCount === 1 ? "Chapter" : "Chapters"}
|
||||
</span>
|
||||
)}
|
||||
{status && (
|
||||
<span className="metadata-badge border-primary/25 text-primary bg-primary/10">
|
||||
{status}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SeasonContent from "@/pages/ItemDetail/SeasonContent";
|
||||
import EpisodeContent from "@/pages/ItemDetail/EpisodeContent";
|
||||
import AudiobookContent from "@/pages/ItemDetail/AudiobookContent";
|
||||
import EbookContent from "@/pages/ItemDetail/EbookContent";
|
||||
import MangaContent from "@/pages/ItemDetail/MangaContent";
|
||||
import {
|
||||
CastSkeleton,
|
||||
CrewSkeleton,
|
||||
@@ -106,6 +107,8 @@ export default function ItemDetail() {
|
||||
);
|
||||
case "ebook":
|
||||
return <EbookContent item={item as ItemDetail & { type: "ebook" }} libraryId={libraryId} />;
|
||||
case "manga":
|
||||
return <MangaContent item={item as ItemDetail & { type: "manga" }} libraryId={libraryId} />;
|
||||
case "podcast":
|
||||
return <Navigate to={`/podcasts/show/${item.content_id}`} replace />;
|
||||
default:
|
||||
|
||||
@@ -108,6 +108,15 @@ describe("getWatchedActionLabel", () => {
|
||||
"Mark Unread",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns reading labels for manga series", () => {
|
||||
expect(getWatchedActionLabel(makeItem({ type: "manga", user_data: { played: false } }))).toBe(
|
||||
"Mark Read",
|
||||
);
|
||||
expect(getWatchedActionLabel(makeItem({ type: "manga", user_data: { played: true } }))).toBe(
|
||||
"Mark Unread",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWatchedToastMessage", () => {
|
||||
@@ -129,6 +138,11 @@ describe("getWatchedToastMessage", () => {
|
||||
expect(getWatchedToastMessage(makeItem({ type: "ebook" }), true)).toBe("Marked as read");
|
||||
expect(getWatchedToastMessage(makeItem({ type: "ebook" }), false)).toBe("Marked as unread");
|
||||
});
|
||||
|
||||
it("uses read copy for manga", () => {
|
||||
expect(getWatchedToastMessage(makeItem({ type: "manga" }), true)).toBe("Marked as read");
|
||||
expect(getWatchedToastMessage(makeItem({ type: "manga" }), false)).toBe("Marked as unread");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWatchedInvalidationKeys", () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ export function getWatchedActionLabel(item: Pick<ItemDetail, "type" | "user_data
|
||||
case "audiobook":
|
||||
return played ? "Mark Unlistened" : "Mark Listened";
|
||||
case "ebook":
|
||||
case "manga":
|
||||
return played ? "Mark Unread" : "Mark Read";
|
||||
case "episode":
|
||||
return played ? "Mark Unwatched" : "Mark Watched";
|
||||
@@ -44,6 +45,7 @@ export function getWatchedToastMessage(item: Pick<ItemDetail, "type">, played: b
|
||||
case "audiobook":
|
||||
return played ? "Marked as listened" : "Marked as unlistened";
|
||||
case "ebook":
|
||||
case "manga":
|
||||
return played ? "Marked as read" : "Marked as unread";
|
||||
default:
|
||||
return played ? "Marked as watched" : "Marked as unwatched";
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getLibrarySortRelevanceScope,
|
||||
isAudiobookLibraryType,
|
||||
isEbookLibraryType,
|
||||
isMangaLibraryType,
|
||||
type AudiobookBrowseAxis,
|
||||
type LibraryBrowseType,
|
||||
} from "./libraryPageSearchParams";
|
||||
@@ -122,9 +123,11 @@ export default function LibraryBrowse({
|
||||
? "audiobook"
|
||||
: isEbookLibraryType(libraryType)
|
||||
? "ebook"
|
||||
: libraryType === "movie"
|
||||
? libraryType
|
||||
: undefined,
|
||||
: isMangaLibraryType(libraryType)
|
||||
? "manga"
|
||||
: libraryType === "movie"
|
||||
? libraryType
|
||||
: undefined,
|
||||
sort: normalizeQuerySortForScope(queryDefinition.sort, {
|
||||
includePersonalized: true,
|
||||
relevanceScope: sortRelevanceScope,
|
||||
|
||||
@@ -219,6 +219,14 @@ describe("parseLibraryPageState", () => {
|
||||
expect(state.queryDefinition.sort).toEqual({ field: "author", order: "asc" });
|
||||
});
|
||||
|
||||
it("uses manga scope for manga libraries", () => {
|
||||
const state = parseLibraryPageState(params("tab=library&sort=author&order=asc"), "manga");
|
||||
|
||||
expect(state.queryDefinition.media_scope).toBe("manga");
|
||||
// Manga sort relevance mirrors ebooks, so ebook-applicable sorts survive.
|
||||
expect(state.queryDefinition.sort).toEqual({ field: "author", order: "asc" });
|
||||
});
|
||||
|
||||
it("normalizes legacy sort aliases to canonical values", () => {
|
||||
expect(
|
||||
parseLibraryPageState(params("tab=library&sort=sort_title"), "mixed").queryDefinition.sort
|
||||
@@ -455,6 +463,8 @@ describe("getLibrarySortRelevanceScope", () => {
|
||||
expect(getLibrarySortRelevanceScope("audiobooks")).toBe("audiobook");
|
||||
expect(getLibrarySortRelevanceScope("ebook")).toBe("ebook");
|
||||
expect(getLibrarySortRelevanceScope("ebooks")).toBe("ebook");
|
||||
// Manga has its own sort universe (no Duration/Bitrate, reading labels).
|
||||
expect(getLibrarySortRelevanceScope("manga")).toBe("manga");
|
||||
});
|
||||
|
||||
it("falls back to the media scope and then to all for mixed libraries", () => {
|
||||
|
||||
@@ -92,6 +92,11 @@ export function getLibrarySortRelevanceScope(
|
||||
if (libraryType === "ebook" || libraryType === "ebooks") {
|
||||
return "ebook";
|
||||
}
|
||||
// Manga series rows are file-less containers with their own sort universe
|
||||
// (no Duration/Bitrate, reading-verb labels).
|
||||
if (isMangaLibraryType(libraryType)) {
|
||||
return "manga";
|
||||
}
|
||||
if (
|
||||
mediaScope === "movie" ||
|
||||
mediaScope === "series" ||
|
||||
@@ -112,6 +117,10 @@ export function isEbookLibraryType(libraryType: string): boolean {
|
||||
return libraryType === "ebook" || libraryType === "ebooks";
|
||||
}
|
||||
|
||||
export function isMangaLibraryType(libraryType: string): boolean {
|
||||
return libraryType === "manga";
|
||||
}
|
||||
|
||||
function readString(value: string | null): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
@@ -310,7 +319,9 @@ export function parseLibraryPageState(
|
||||
? "audiobook"
|
||||
: isEbookLibraryType(libraryType)
|
||||
? "ebook"
|
||||
: undefined;
|
||||
: isMangaLibraryType(libraryType)
|
||||
? "manga"
|
||||
: undefined;
|
||||
const sortRelevanceScope =
|
||||
libraryType === "series" && browseType === "episode"
|
||||
? "all"
|
||||
|
||||
Reference in New Issue
Block a user