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:
Quick
2026-06-16 20:13:10 -04:00
committed by GitHub
co-authored by Silo Server Developer Claude Opus 4.8
parent 63d608f6fc
commit c4cbcddeae
88 changed files with 5596 additions and 366 deletions
+58 -4
View File
@@ -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;
+10
View File
@@ -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> */
+14 -5
View File
@@ -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}
+4 -2
View File
@@ -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) {
+2
View File
@@ -34,6 +34,8 @@ function typeLabel(type: BrowseItem["type"]): string {
return "Ebook";
case "audiobook":
return "Audiobook";
case "manga":
return "Manga";
default:
return type;
}
+126
View File
@@ -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: {
+61 -1
View File
@@ -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 && (
+111
View File
@@ -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>
);
}
+19
View File
@@ -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>
+18
View File
@@ -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),
+157
View File
@@ -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);
});
});
+153
View File
@@ -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;
}
+16 -2
View File
@@ -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 });
}
+16 -9
View File
@@ -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;
});
}
+17
View File
@@ -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
View File
@@ -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>
);
}
+21 -24
View File
@@ -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();
});
});
+500
View File
@@ -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">&middot;</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}
+3
View File
@@ -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", () => {
+2
View File
@@ -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";
+6 -3
View File
@@ -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", () => {
+12 -1
View File
@@ -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"