diff --git a/.gitignore b/.gitignore index e5296f0c..34febc7f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,5 +59,11 @@ node_modules/ logs/ .playwright-mcp/ demo/ +docker-compose.dev.yml +# Local-only deployment override (unpublishes bundled redis/postgres host +# ports via `ports: !override []`). Kept out of git so a rebase from main +# never disturbs it and it never lands in a PR; its absence once exposed +# Redis to the internet, so it must persist on the box. +docker-compose.override.yml .playwright-cli/ output/ diff --git a/cmd/silo/main.go b/cmd/silo/main.go index d55daae6..a59dfb1a 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -51,6 +51,7 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/database" "github.com/Silo-Server/silo-server/internal/ebooks" + "github.com/Silo-Server/silo-server/internal/manga" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/historyimport" "github.com/Silo-Server/silo-server/internal/imagecache" @@ -1058,6 +1059,7 @@ func main() { var episodeRepo *catalog.EpisodeRepository var audiobookEnricher *audiobooks.Enricher var ebookEnricher *ebooks.Enricher + var mangaEnricher *manga.Enricher if needsWorkers && deps.DB != nil && deps.FileRepo != nil { chainRepo := metadata.NewChainRepository(deps.DB) skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB) @@ -1149,6 +1151,14 @@ func main() { ) audiobookEnricher.SetLiteraryWorkLinker(literaryWorkService) ebookEnricher.SetLiteraryWorkLinker(literaryWorkService) + mangaEnricher = manga.NewEnricher( + deps.DB, + chainRepo, + pluginResolver, + itemRepo, + personRepo, + providerIDRepo, + ) // Always wire the image resolver so plugin-prefixed URLs (e.g. // metadb://) can be resolved to presigned HTTP URLs in API responses. @@ -1177,6 +1187,9 @@ func main() { if ebookEnricher != nil { ebookEnricher.SetImageCacher(imageCacher) } + if mangaEnricher != nil { + mangaEnricher.SetImageCacher(imageCacher) + } } matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second) @@ -1784,6 +1797,9 @@ func main() { if ebookEnricher != nil { taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher)) } + if mangaEnricher != nil { + taskMgr.Register(tasks.NewSyncMangaMetadataTask(mangaEnricher)) + } if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil { pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx) if err != nil { diff --git a/go.mod b/go.mod index 8f652015..6a6e3397 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.6.0 + github.com/Silo-Server/silo-plugin-sdk v0.7.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect diff --git a/go.sum b/go.sum index 23fc5643..369ce3df 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= -github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI= -github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= +github.com/Silo-Server/silo-plugin-sdk v0.7.0 h1:VbD7qXjwKYOdajFBYQq1eS2fgHDWyShZBYh0kxJly6U= +github.com/Silo-Server/silo-plugin-sdk v0.7.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= diff --git a/internal/api/handlers/catalog_resources.go b/internal/api/handlers/catalog_resources.go index 47cf2372..e8f0ead5 100644 --- a/internal/api/handlers/catalog_resources.go +++ b/internal/api/handlers/catalog_resources.go @@ -87,6 +87,37 @@ func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r writeJSON(w, http.StatusOK, detail.Versions) } +// HandleGetMangaFiles returns the local file listing for a manga series (the +// series "View Details" dialog): folder paths plus per-chapter file rows. +// Folder and file paths are stripped for viewers without file-path visibility, +// matching the item-versions policy; file names and sizes remain. +func (h *CatalogResourceHandler) HandleGetMangaFiles(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + files, err := h.items.detailSvc.GetMangaChapterFiles(r.Context(), id, h.items.accessFilter(r)) + if err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get manga files") + return + } + + if !h.items.requestCanViewFilePaths(r) { + files.FolderPaths = nil + for i := range files.Files { + files.Files[i].FilePath = "" + } + } + + writeJSON(w, http.StatusOK, files) +} + func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) { filter := h.items.accessFilter(r) id := chi.URLParam(r, "id") diff --git a/internal/api/handlers/ebook_reader.go b/internal/api/handlers/ebook_reader.go index 40d4865e..6360239d 100644 --- a/internal/api/handlers/ebook_reader.go +++ b/internal/api/handlers/ebook_reader.go @@ -1046,15 +1046,25 @@ func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookR if s == nil || s.pool == nil { return fmt.Errorf("ebook reader progress store is not configured") } - if _, err := s.pool.Exec(ctx, ` + // A routine autosave (e.g. reopening a finished book) must not silently drop + // a "finished" item below the threshold and un-mark it read; once finished, + // progress only moves on an explicit unread (which deletes the row). Below + // the threshold, progress tracks freely. Manga chapter ✓ marks ride on this + // same row, so the guard protects them too. + query := fmt.Sprintf(` INSERT INTO ebook_reader_progress (user_id, profile_id, content_id, file_id, location, progress, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET file_id = EXCLUDED.file_id, location = EXCLUDED.location, - progress = EXCLUDED.progress, - updated_at = EXCLUDED.updated_at`, + progress = CASE + WHEN ebook_reader_progress.progress >= %[1]v AND EXCLUDED.progress < %[1]v + THEN ebook_reader_progress.progress + ELSE EXCLUDED.progress + END, + updated_at = EXCLUDED.updated_at`, models.EbookFinishedProgressThreshold) + if _, err := s.pool.Exec(ctx, query, progress.UserID, progress.ProfileID, progress.ContentID, diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 2ba88540..7b30b273 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -223,6 +223,8 @@ type itemListResponse struct { ReleaseDate *string `json:"release_date,omitempty"` LastAirDate *string `json:"last_air_date,omitempty"` AddedAt *time.Time `json:"added_at,omitempty"` + MangaChapterCount *int `json:"manga_chapter_count,omitempty"` + MangaVolumeCount *int `json:"manga_volume_count,omitempty"` OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"` UserState *itemUserStateResponse `json:"user_state,omitempty"` @@ -657,6 +659,8 @@ func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *mode } resp.AddedAt = item.AddedAt + resp.MangaChapterCount = item.MangaChapterCount + resp.MangaVolumeCount = item.MangaVolumeCount resp.ReleaseDate = item.ReleaseDate resp.LastAirDate = item.LastAirDate resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card") diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 40c852e2..4414f546 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -2102,6 +2102,8 @@ func metadataContentLevelsForLibraryType(libraryType string) []string { return []string{"audiobook"} case "ebooks", "ebook": return []string{"ebook"} + case "manga": + return []string{"manga"} case "mixed": return []string{"movie", "series", "season", "episode", "audiobook", "ebook"} default: diff --git a/internal/api/handlers/libraries_metadata_levels_test.go b/internal/api/handlers/libraries_metadata_levels_test.go index b6a542b2..0554689c 100644 --- a/internal/api/handlers/libraries_metadata_levels_test.go +++ b/internal/api/handlers/libraries_metadata_levels_test.go @@ -14,6 +14,7 @@ func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) { {name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}}, {name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}}, {name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}}, + {name: "manga", libraryType: "manga", want: []string{"manga"}}, } for _, tc := range cases { diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index ea680a07..59b984d7 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -1179,6 +1179,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect userStates := h.listSectionItemUserStates(r, allItems) imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems) episodeMeta := h.listSectionEpisodeItemMeta(r.Context(), withItems, requestAccessFilter(r)) + mangaChapterMeta := h.listSectionMangaChapterItemMeta(r.Context(), allItems) for _, s := range withItems { items := make([]sectionItemResponse, 0, len(s.Items)) for _, item := range s.Items { @@ -1193,6 +1194,17 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect meta = &value } } + // Manga chapters carry their series linkage on top of whatever + // meta (e.g. reading progress) the section already resolved, so + // continue-reading cards can head to the series. + if value, ok := mangaChapterMeta[item.ContentID]; ok { + if meta == nil { + empty := sections.SectionItemMeta{} + meta = &empty + } + meta.SeriesID = value.SeriesID + meta.SeriesTitle = value.SeriesTitle + } imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID} items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey])) } @@ -1211,6 +1223,33 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect return resp } +// listSectionMangaChapterItemMeta resolves series linkage for every manga +// chapter (type='ebook' linked via manga_chapters) among the section items. +// Non-chapter ebooks simply get no entry. +func (h *SectionHandler) listSectionMangaChapterItemMeta(ctx context.Context, items []*models.MediaItem) map[string]sections.SectionItemMeta { + if h == nil || h.fetcher == nil { + return map[string]sections.SectionItemMeta{} + } + ids := make([]string, 0) + seen := make(map[string]struct{}) + for _, item := range items { + if item == nil || item.Type != "ebook" || strings.TrimSpace(item.ContentID) == "" { + continue + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + ids = append(ids, item.ContentID) + } + meta, err := h.fetcher.FetchMangaChapterSeriesMeta(ctx, ids) + if err != nil { + slog.Warn("loading section manga chapter metadata", "error", err) + return map[string]sections.SectionItemMeta{} + } + return meta +} + func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withItems []sections.SectionWithItems, filter catalog.AccessFilter) map[string]sections.SectionItemMeta { if h == nil || h.episodeFetcher == nil { return map[string]sections.SectionItemMeta{} diff --git a/internal/api/router.go b/internal/api/router.go index 2837ea69..cb05336c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1701,6 +1701,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail) r.Get("/catalog/items/{id}/episodes", catalogResourceHandler.HandleGetItemEpisodes) r.Get("/catalog/items/{id}/versions", catalogResourceHandler.HandleGetItemVersions) + r.Get("/catalog/items/{id}/manga-files", catalogResourceHandler.HandleGetMangaFiles) r.Get("/catalog/series/{id}/seasons", catalogResourceHandler.HandleGetSeasons) r.Get("/catalog/series/{id}/seasons/{num}", catalogResourceHandler.HandleGetSeason) r.Get("/catalog/series/{id}/seasons/{num}/episodes", catalogResourceHandler.HandleGetEpisodes) diff --git a/internal/catalog/browse.go b/internal/catalog/browse.go index 41cc3b41..df706fc1 100644 --- a/internal/catalog/browse.go +++ b/internal/catalog/browse.go @@ -344,6 +344,10 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl applyAccessFilter("mi", AccessFilter{MaxContentRating: filters.MaxContentRating}, &conditions, &args, &argIdx) + // Manga chapters (type='ebook' rows linked into a manga series) are internal + // sub-units and must never surface as standalone catalog items. + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + if filters.SnapshotAt != nil { conditions = append(conditions, fmt.Sprintf("mi.created_at <= $%d", argIdx)) args = append(args, *filters.SnapshotAt) @@ -372,7 +376,14 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl orderBy, orderArgs := buildOrderByPlan(filters.Sort, filters.Order, filters.SnapshotAt, argIdx, singleLibraryNoDedup, browseFiltersAreMovieOnly(filters)) argIdx += len(orderArgs) - selectClause := browseItemColumns("mi") + // Only run the manga count subqueries when the scope can contain manga + // series; a non-manga type filter rules them out, so substitute NULL + // placeholders and skip two correlated subqueries per row on the hot path. + mangaCounts := mangaCountColumns("mi") + if !browseScopeMayContainManga(filters) { + mangaCounts = nullMangaCountColumns() + } + selectClause := browseItemColumns("mi") + ", " + mangaCounts groupByClause := "" switch { case singleLibraryNoDedup: @@ -1123,6 +1134,63 @@ func browseItemColumns(alias string) string { return strings.Join(prefixed, ", ") } +// mangaCountColumns returns two index-backed correlated subqueries feeding the +// "X Volumes · X Chapters" poster chip: distinct volume tokens (many chapter +// rows can share one volume) and loose chapter rows without a volume token. +// They return 0 for non-manga rows (no matching manga_chapters), which the +// scan path nils out so only manga cards carry the counts. The subqueries are +// functionally dependent on alias.content_id (the media_items PK, which leads +// browseGroupByColumns), so they remain valid under the dedup GROUP BY without +// being listed there. +func mangaCountColumns(alias string) string { + return "(SELECT count(*) FROM manga_chapters mc WHERE mc.series_content_id = " + alias + ".content_id AND (mc.volume IS NULL OR mc.volume = '')) AS manga_chapter_count, " + + "(SELECT count(DISTINCT mc.volume) FROM manga_chapters mc WHERE mc.series_content_id = " + alias + ".content_id AND mc.volume IS NOT NULL AND mc.volume <> '') AS manga_volume_count" +} + +// nullMangaCountColumns substitutes NULL placeholders for the manga count +// subqueries when the browse scope cannot contain manga series. Column names +// and order match mangaCountColumns so the scan path is unchanged. +func nullMangaCountColumns() string { + return "NULL::bigint AS manga_chapter_count, NULL::bigint AS manga_volume_count" +} + +// browseScopeMayContainManga reports whether a browse with these filters could +// return type='manga' rows. An empty type filter (all types) or one that +// includes "manga" keeps the counts; any other explicit type filter rules +// manga out, letting the caller skip the count subqueries. +func browseScopeMayContainManga(filters BrowseFilters) bool { + if filters.Type == "" { + return true + } + for _, t := range strings.Split(filters.Type, ",") { + if strings.TrimSpace(t) == "manga" { + return true + } + } + return false +} + +// MangaChapterExclusionWhere returns a WHERE predicate that hides manga CHAPTER +// items (type='ebook' rows linked into a type='manga' series via the +// manga_chapters table) from catalog listing surfaces — browse, section +// resolution, and search. Chapters are internal sub-units of a manga series and +// must never appear as standalone catalog items; only the series should. +// +// It is index-backed: manga_chapters.chapter_content_id is the table's primary +// key, so the anti-join is a cheap unique-index probe. The predicate is global +// and harmless for every other row: regular ebooks have no manga_chapters link, +// and non-ebook types never match either, so they all pass. It is redundant +// (but harmless) for type='manga' browse scopes, whose series rows are linked +// via series_content_id, not chapter_content_id. +// +// By-id fetch paths that legitimately resolve chapters — the ebook reader, +// continue-reading (ebook_reader_progress / watch-progress), and the series +// detail chapter list (mangaChaptersQuery) — use separate queries and must NOT +// call this. +func MangaChapterExclusionWhere(alias string) string { + return "NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = " + alias + ".content_id)" +} + // browseGroupByColumns returns the columns needed for GROUP BY when joining // with the junction table. func browseGroupByColumns(alias string) string { @@ -1200,11 +1268,19 @@ func scanBrowseItems(rows pgx.Rows) ([]*models.MediaItem, error) { &item.Status, &item.CreatedAt, &item.UpdatedAt, + &item.MangaChapterCount, + &item.MangaVolumeCount, &item.AddedAt, ) if err != nil { return nil, fmt.Errorf("scanning browse item row: %w", err) } + // The manga count subqueries return 0 for non-manga rows; drop them so + // only manga cards carry the counts (movies/series stay clean). + if item.Type != "manga" { + item.MangaChapterCount = nil + item.MangaVolumeCount = nil + } items = append(items, &item) } if err := rows.Err(); err != nil { diff --git a/internal/catalog/browse_manga_counts_test.go b/internal/catalog/browse_manga_counts_test.go new file mode 100644 index 00000000..846216bc --- /dev/null +++ b/internal/catalog/browse_manga_counts_test.go @@ -0,0 +1,97 @@ +package catalog + +import ( + "strings" + "testing" +) + +// TestMangaCountColumns pins the browse-card manga count contract: two +// index-backed correlated subqueries over manga_chapters, scoped to the series +// content ID and aliased so the scan paths can read them positionally. The +// card chip reads "X Volumes · X Chapters", so manga_volume_count must count +// DISTINCT volume tokens (many chapter rows can share one volume) and +// manga_chapter_count must count only loose rows without a volume token. +func TestMangaCountColumns(t *testing.T) { + cols := mangaCountColumns("mi") + + for _, want := range []string{ + "FROM manga_chapters mc", + "mc.series_content_id = mi.content_id", + "count(DISTINCT mc.volume)", + "mc.volume IS NOT NULL AND mc.volume <> ''", + "AS manga_volume_count", + "(mc.volume IS NULL OR mc.volume = '')", + "AS manga_chapter_count", + } { + if !strings.Contains(cols, want) { + t.Fatalf("manga count columns missing %q\ngot: %s", want, cols) + } + } + + // Both counts must be present (two correlated subqueries). + if got := strings.Count(cols, "FROM manga_chapters mc"); got != 2 { + t.Fatalf("expected 2 manga count subqueries, got %d\ngot: %s", got, cols) + } +} + +// TestBrowseScopeMayContainManga pins the gating that lets browse skip the two +// manga count subqueries when the scope cannot return manga rows. An empty type +// filter (all types) or one that includes "manga" keeps them; any other +// explicit type filter rules manga out. +func TestBrowseScopeMayContainManga(t *testing.T) { + cases := []struct { + typeFilter string + want bool + }{ + {"", true}, + {"manga", true}, + {"movie,manga", true}, + {" manga ", true}, + {"movie", false}, + {"movie,series,episode", false}, + {"ebook", false}, + } + for _, c := range cases { + if got := browseScopeMayContainManga(BrowseFilters{Type: c.typeFilter}); got != c.want { + t.Fatalf("browseScopeMayContainManga(%q) = %v, want %v", c.typeFilter, got, c.want) + } + } +} + +// nullMangaCountColumns must keep the exact column names and order of +// mangaCountColumns so the shared scan path is unchanged when the subqueries +// are skipped. +func TestNullMangaCountColumnsMatchScanContract(t *testing.T) { + null := nullMangaCountColumns() + for _, want := range []string{"AS manga_chapter_count", "AS manga_volume_count"} { + if !strings.Contains(null, want) { + t.Fatalf("null manga count columns missing %q\ngot: %s", want, null) + } + } + if strings.Contains(null, "FROM manga_chapters") { + t.Fatalf("null manga count columns must not run subqueries\ngot: %s", null) + } + if a, b := strings.Index(null, "manga_chapter_count"), strings.Index(null, "manga_volume_count"); a > b { + t.Fatalf("column order must match mangaCountColumns (chapter then volume)\ngot: %s", null) + } +} + +// The library page browses through the catalog query preview path +// (previewQuerySource -> QueryExecutor.PreviewPage), not BrowseRepository, so +// the preview-page SELECT must carry the same manga count columns or manga +// cards in /library/{id}?tab=library render without the Vols/Ch chip. +func TestPreviewPageSQLIncludesMangaCounts(t *testing.T) { + sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL( + QueryDefinition{MediaScope: "manga"}, + AccessFilter{}, + 20, 0, true, + ) + if err != nil { + t.Fatalf("buildPreviewPageSQL error: %v", err) + } + for _, want := range []string{"AS manga_chapter_count", "AS manga_volume_count"} { + if !strings.Contains(sql, want) { + t.Fatalf("preview-page SQL missing %q\ngot: %s", want, sql) + } + } +} diff --git a/internal/catalog/catalog_parser_test.go b/internal/catalog/catalog_parser_test.go index 8f865ea0..25fd80f5 100644 --- a/internal/catalog/catalog_parser_test.go +++ b/internal/catalog/catalog_parser_test.go @@ -7,3 +7,9 @@ func TestParseCatalogMediaScope_AllowsEbook(t *testing.T) { t.Fatalf("expected ebook media scope, got %q", got) } } + +func TestParseCatalogMediaScope_AllowsManga(t *testing.T) { + if got := parseCatalogMediaScope(" manga "); got != "manga" { + t.Fatalf("expected manga media scope, got %q", got) + } +} diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index fa26f325..b556088a 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -1090,7 +1090,7 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error { func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error { if !IsValidMediaScope(def.MediaScope) { - return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', or 'video'", ErrInvalidCatalogRequest) + return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', 'manga', or 'video'", ErrInvalidCatalogRequest) } if def.Match != "" && def.Match != "all" && def.Match != "any" { return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest) diff --git a/internal/catalog/catalog_resolver_test.go b/internal/catalog/catalog_resolver_test.go index ef4f89a2..aae92746 100644 --- a/internal/catalog/catalog_resolver_test.go +++ b/internal/catalog/catalog_resolver_test.go @@ -54,6 +54,21 @@ func TestValidateCatalogQueryRequest_AllowsEbookMediaScope(t *testing.T) { } } +func TestValidateCatalogQueryRequest_AllowsMangaMediaScope(t *testing.T) { + req := CatalogRequest{ + Source: CatalogSourceQuery, + Query: QueryDefinition{ + MediaScope: "manga", + Match: "all", + Sort: QuerySort{Field: "title", Order: "asc"}, + }, + } + + if err := validateCatalogQueryRequest(req, true); err != nil { + t.Fatalf("expected manga media scope to be accepted, got %v", err) + } +} + func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) { req := CatalogRequest{ Source: CatalogSourceQuery, diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 2402f293..8a6828ba 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -10,6 +10,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/Silo-Server/silo-server/internal/access" @@ -189,6 +190,9 @@ type ItemDetail struct { // Ebook-specific detail. Present only when Type == "ebook". Ebook *EbookDetailExtension `json:"ebook,omitempty"` + + // Manga-specific detail. Present only when Type == "manga". + Manga *MangaDetailExtension `json:"manga,omitempty"` } type AudiobookDetailExtension struct { @@ -240,6 +244,29 @@ type EbookDetailExtension struct { Related AudiobookRelatedContent `json:"related"` } +// MangaDetailExtension is the manga-series detail payload. A manga series item +// (media_items.type='manga') owns a set of readable chapter items +// (media_items.type='ebook') linked via the manga_chapters table. +type MangaDetailExtension struct { + Chapters []MangaChapter `json:"chapters"` +} + +// MangaChapter is one chapter of a manga series, ordered by chapter index. +type MangaChapter struct { + ContentID string `json:"content_id"` + Title string `json:"title"` + ChapterIndex *float64 `json:"chapter_index,omitempty"` + Volume string `json:"volume,omitempty"` + // Read is true when the current viewer has finished this chapter, mirroring + // ebook read state: ebook_reader_progress.progress >= the finished threshold. + Read bool `json:"read"` + // Progress is the viewer's reading position as a 0..1 fraction, present + // only when a progress row exists. The row progress bar uses it. + Progress *float64 `json:"progress,omitempty"` + // PosterURL is the chapter's extracted cover (presigned), for row thumbnails. + PosterURL string `json:"poster_url,omitempty"` +} + // ItemUserState is per-profile viewer state included in item detail responses. type ItemUserState struct { Played bool `json:"played"` @@ -992,6 +1019,16 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M } if item.Type == "ebook" { detail.Ebook = s.buildEbookExtension(ctx, item, crewCredits, filter) + // A manga chapter is an ebook item linked to its series; exposing the + // linkage lets the reader navigate back/next within the series and + // continue-reading cards show the series instead of the chapter. + if seriesID, seriesTitle, ok := s.lookupMangaSeriesForChapter(ctx, item.ContentID); ok { + detail.SeriesID = seriesID + detail.SeriesTitle = seriesTitle + } + } + if item.Type == "manga" { + detail.Manga = s.buildMangaExtension(ctx, item, filter) } // Series folder paths from confirmed claims when available, otherwise from @@ -1157,17 +1194,117 @@ func (s *DetailService) buildEbookExtension( if item == nil { return nil } + // The three related-content lookups are independent read-only queries; run + // them concurrently so detail latency is the slowest one, not their sum + // (mirrors buildAudiobookExtension). + var ( + series *AudiobookSeriesGroup + alsoByAuthor []AudiobookRelatedItem + similar []AudiobookRelatedItem + wg sync.WaitGroup + ) + wg.Add(3) + go func() { defer wg.Done(); series = s.fetchEbookSeries(ctx, item.ContentID, filter) }() + go func() { defer wg.Done(); alsoByAuthor = s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter) }() + go func() { defer wg.Done(); similar = s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter) }() + wg.Wait() + return &EbookDetailExtension{ Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()), Publisher: firstNonEmptyString(item.Studios), - Series: s.fetchEbookSeries(ctx, item.ContentID, filter), + Series: series, Related: AudiobookRelatedContent{ - AlsoByAuthor: s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter), - Similar: s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter), + AlsoByAuthor: alsoByAuthor, + Similar: similar, }, } } +// buildMangaExtension assembles the manga-series detail payload by listing the +// series' chapters (ebook items linked via manga_chapters). +func (s *DetailService) buildMangaExtension(ctx context.Context, item *models.MediaItem, filter AccessFilter) *MangaDetailExtension { + if item == nil { + return nil + } + return &MangaDetailExtension{ + Chapters: s.fetchMangaChapters(ctx, item.ContentID, filter), + } +} + +// mangaChaptersQuery is the SQL listing a manga series' chapters in reading +// order. Chapters with a parsed index sort first (ascending); those without +// fall back to sort_title. Kept as a package var so the ordering contract can +// be asserted without a database. +// +// Manga chapters are ebook items, so per-chapter read state mirrors the ebook +// surfaces: a chapter is read when the current viewer's ebook_reader_progress +// row has progress >= the finished threshold. The LEFT JOIN is scoped by the +// viewer's user_id + profile_id ($2/$3) and yields false when no row exists. +var mangaChaptersQuery = fmt.Sprintf(` + SELECT m.content_id, m.title, mc.chapter_index, mc.volume, + COALESCE(erp.progress >= %s, false) AS read, + erp.progress::double precision, + COALESCE(m.poster_path, '') AS poster_path + FROM manga_chapters mc + JOIN media_items m ON m.content_id = mc.chapter_content_id + LEFT JOIN ebook_reader_progress erp + ON erp.content_id = mc.chapter_content_id + AND erp.user_id = $2 + AND erp.profile_id = $3 + WHERE mc.series_content_id = $1 + ORDER BY mc.chapter_index NULLS LAST, m.sort_title +`, EbookFinishedProgressThresholdSQL) + +// fetchMangaChapters returns the ordered chapters for a manga series. It never +// returns nil so the JSON payload always carries a (possibly empty) array. The +// access filter supplies the viewer (user_id/profile_id) used to resolve each +// chapter's per-viewer read state. +func (s *DetailService) fetchMangaChapters(ctx context.Context, seriesContentID string, filter AccessFilter) []MangaChapter { + chapters := make([]MangaChapter, 0, 16) + if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil { + return chapters + } + rows, err := s.itemRepo.pool.Query(ctx, mangaChaptersQuery, seriesContentID, filter.UserID, filter.ProfileID) + if err != nil { + return chapters + } + defer rows.Close() + + posterPaths := make([]string, 0, 16) + for rows.Next() { + var ( + ch MangaChapter + index *float64 + volume *string + progress *float64 + posterPath string + ) + if err := rows.Scan(&ch.ContentID, &ch.Title, &index, &volume, &ch.Read, &progress, &posterPath); err != nil { + return chapters + } + ch.ChapterIndex = index + if volume != nil { + ch.Volume = *volume + } + ch.Progress = progress + ch.PosterURL = posterPath // raw path; resolved in one batch below + if posterPath != "" { + posterPaths = append(posterPaths, posterPath) + } + chapters = append(chapters, ch) + } + if err := rows.Err(); err != nil { + slog.Warn("manga chapters: row iteration error", "series", seriesContentID, "error", err) + } + // Presign every chapter poster in one batch rather than per chapter — a + // long-running series has hundreds of chapters. + resolved := s.PresignImageURLs(ctx, posterPaths, "poster", "") + for i := range chapters { + chapters[i].PosterURL = resolved[chapters[i].PosterURL] + } + return chapters +} + func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson { out := make([]AudiobookPerson, 0) for _, credit := range crew { diff --git a/internal/catalog/detail_manga_test.go b/internal/catalog/detail_manga_test.go new file mode 100644 index 00000000..b30369d1 --- /dev/null +++ b/internal/catalog/detail_manga_test.go @@ -0,0 +1,49 @@ +package catalog + +import ( + "context" + "strings" + "testing" +) + +// TestMangaChaptersQueryOrdering pins the manga chapter listing contract: join +// manga_chapters to media_items on the chapter content ID, scope to the series, +// and order by chapter_index (NULLs last) then sort_title. A wrong ORDER BY +// would surface chapters out of reading order in the series detail. +func TestMangaChaptersQueryOrdering(t *testing.T) { + q := strings.Join(strings.Fields(mangaChaptersQuery), " ") + + for _, want := range []string{ + "FROM manga_chapters mc", + "JOIN media_items m ON m.content_id = mc.chapter_content_id", + "WHERE mc.series_content_id = $1", + "ORDER BY mc.chapter_index NULLS LAST, m.sort_title", + // Per-chapter read state: viewer-scoped LEFT JOIN onto ebook progress. + "LEFT JOIN ebook_reader_progress erp", + "AND erp.user_id = $2", + "AND erp.profile_id = $3", + "AS read", + } { + if !strings.Contains(q, want) { + t.Fatalf("manga chapters query missing %q\nquery: %s", want, q) + } + } +} + +// TestFetchMangaChaptersNilSafe asserts the helper never returns nil (the JSON +// payload must always carry an array) and tolerates an unconfigured pool. +func TestFetchMangaChaptersNilSafe(t *testing.T) { + var s *DetailService + if got := s.fetchMangaChapters(context.Background(), "series-1", AccessFilter{}); got == nil { + t.Fatal("nil receiver should yield an empty slice, not nil") + } + + s = &DetailService{} + got := s.fetchMangaChapters(context.Background(), "series-1", AccessFilter{}) + if got == nil { + t.Fatal("unconfigured pool should yield an empty slice, not nil") + } + if len(got) != 0 { + t.Fatalf("expected no chapters without a pool, got %d", len(got)) + } +} diff --git a/internal/catalog/discovery_repo.go b/internal/catalog/discovery_repo.go index 6979e06f..7898ed98 100644 --- a/internal/catalog/discovery_repo.go +++ b/internal/catalog/discovery_repo.go @@ -54,6 +54,8 @@ func buildRatingThresholdQuery(f RatingFilter) (string, []any) { applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx) + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + query := fmt.Sprintf( "SELECT %s FROM media_items mi WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC", qualifiedItemColumns("mi"), @@ -134,6 +136,8 @@ func buildUnplayedHighRatedQuery(f UnplayedFilter) (string, []any) { applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx) + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + query := fmt.Sprintf( "SELECT %s FROM media_items mi WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC", qualifiedItemColumns("mi"), @@ -226,6 +230,8 @@ func buildForgottenFavoritesQuery(f ForgottenFavoritesFilter) (string, []any) { applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx) + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + query := fmt.Sprintf( "SELECT %s FROM media_items mi WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC", qualifiedItemColumns("mi"), diff --git a/internal/catalog/favorites_browse.go b/internal/catalog/favorites_browse.go index b54b4ff2..05d8ced6 100644 --- a/internal/catalog/favorites_browse.go +++ b/internal/catalog/favorites_browse.go @@ -233,6 +233,11 @@ func buildBrowseFavoritesPlan(f BrowseFavoritesFilters) (browseFavoritesPlan, er applyAccessFilter("mi", AccessFilter{MaxContentRating: f.MaxContentRating, ExcludedMediaTypes: f.ExcludedMediaTypes}, &conditions, &args, &argIdx) + // Manga chapters (type='ebook' rows linked into a manga series) are internal + // sub-units and must never surface as standalone cards, matching the + // exclusion applied across browse/search/discovery/sections. + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + orderBy := buildBrowseFavoritesOrderBy(f.SortField, f.SortOrder) return browseFavoritesPlan{ diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index a2245d36..095352ae 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -255,61 +255,67 @@ func scanItem(row pgx.Row) (*models.MediaItem, error) { } // scanItems scans multiple rows into a []*models.MediaItem slice. +// listItemScanDests returns the scan destinations matching +// qualifiedListItemColumns, in column order. Every scan over that select list +// must use this so the column list and destinations cannot drift apart. +func listItemScanDests(item *models.MediaItem) []any { + return []any{ + &item.ContentID, + &item.Type, + &item.Title, + &item.SortTitle, + &item.DefaultMetadataLanguage, + &item.OriginalTitle, + &item.Year, + &item.Genres, + &item.ContentRating, + &item.Runtime, + &item.Overview, + &item.Tagline, + &item.RatingIMDB, + &item.RatingTMDB, + &item.RatingRTCritic, + &item.RatingRTAudience, + &item.ImdbID, + &item.TmdbID, + &item.TvdbID, + &item.PosterPath, + &item.PosterSourcePath, + &item.PosterThumbhash, + &item.BackdropPath, + &item.BackdropThumbhash, + &item.LogoPath, + &item.MetadataS3Path, + &item.MetadataEtag, + &item.SeasonCount, + &item.Studios, + &item.Networks, + &item.Countries, + &item.Keywords, + &item.OriginalLanguage, + &item.ReleaseDate, + &item.FirstAirDate, + &item.LastAirDate, + &item.AirTime, + &item.AirTimezone, + &item.ShowStatus, + &item.MatchedAt, + &item.LastRefreshed, + &item.RefreshFailures, + &item.EpisodeMetadataIncomplete, + &item.EpisodeMetadataLastCheckedAt, + &item.LockedFields, + &item.Status, + &item.CreatedAt, + &item.UpdatedAt, + } +} + func scanItems(rows pgx.Rows) ([]*models.MediaItem, error) { var items []*models.MediaItem for rows.Next() { var item models.MediaItem - err := rows.Scan( - &item.ContentID, - &item.Type, - &item.Title, - &item.SortTitle, - &item.DefaultMetadataLanguage, - &item.OriginalTitle, - &item.Year, - &item.Genres, - &item.ContentRating, - &item.Runtime, - &item.Overview, - &item.Tagline, - &item.RatingIMDB, - &item.RatingTMDB, - &item.RatingRTCritic, - &item.RatingRTAudience, - &item.ImdbID, - &item.TmdbID, - &item.TvdbID, - &item.PosterPath, - &item.PosterSourcePath, - &item.PosterThumbhash, - &item.BackdropPath, - &item.BackdropThumbhash, - &item.LogoPath, - &item.MetadataS3Path, - &item.MetadataEtag, - &item.SeasonCount, - &item.Studios, - &item.Networks, - &item.Countries, - &item.Keywords, - &item.OriginalLanguage, - &item.ReleaseDate, - &item.FirstAirDate, - &item.LastAirDate, - &item.AirTime, - &item.AirTimezone, - &item.ShowStatus, - &item.MatchedAt, - &item.LastRefreshed, - &item.RefreshFailures, - &item.EpisodeMetadataIncomplete, - &item.EpisodeMetadataLastCheckedAt, - &item.LockedFields, - &item.Status, - &item.CreatedAt, - &item.UpdatedAt, - ) - if err != nil { + if err := rows.Scan(listItemScanDests(&item)...); err != nil { return nil, fmt.Errorf("scanning media item row: %w", err) } items = append(items, &item) @@ -320,6 +326,30 @@ func scanItems(rows pgx.Rows) ([]*models.MediaItem, error) { return items, nil } +// scanItemsWithMangaCounts scans rows selected with qualifiedListItemColumns +// followed by mangaCountColumns. The count subqueries return 0 for non-manga +// rows; they are nilled out so only manga cards carry the counts (mirrors +// scanBrowseItems). +func scanItemsWithMangaCounts(rows pgx.Rows) ([]*models.MediaItem, error) { + var items []*models.MediaItem + for rows.Next() { + var item models.MediaItem + dests := append(listItemScanDests(&item), &item.MangaChapterCount, &item.MangaVolumeCount) + if err := rows.Scan(dests...); err != nil { + return nil, fmt.Errorf("scanning media item row with manga counts: %w", err) + } + if item.Type != "manga" { + item.MangaChapterCount = nil + item.MangaVolumeCount = nil + } + items = append(items, &item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating media item rows: %w", err) + } + return items, nil +} + // scanItemsWithTotal scans rows that include a trailing total_count column // emitted by COUNT(*) OVER (). The total is identical for every row in the // result set; we read it from the first row (or leave it zero when the result @@ -333,58 +363,8 @@ func scanItemsWithTotal(rows pgx.Rows) ([]*models.MediaItem, int, error) { for rows.Next() { var item models.MediaItem var rowTotal int - err := rows.Scan( - &item.ContentID, - &item.Type, - &item.Title, - &item.SortTitle, - &item.DefaultMetadataLanguage, - &item.OriginalTitle, - &item.Year, - &item.Genres, - &item.ContentRating, - &item.Runtime, - &item.Overview, - &item.Tagline, - &item.RatingIMDB, - &item.RatingTMDB, - &item.RatingRTCritic, - &item.RatingRTAudience, - &item.ImdbID, - &item.TmdbID, - &item.TvdbID, - &item.PosterPath, - &item.PosterSourcePath, - &item.PosterThumbhash, - &item.BackdropPath, - &item.BackdropThumbhash, - &item.LogoPath, - &item.MetadataS3Path, - &item.MetadataEtag, - &item.SeasonCount, - &item.Studios, - &item.Networks, - &item.Countries, - &item.Keywords, - &item.OriginalLanguage, - &item.ReleaseDate, - &item.FirstAirDate, - &item.LastAirDate, - &item.AirTime, - &item.AirTimezone, - &item.ShowStatus, - &item.MatchedAt, - &item.LastRefreshed, - &item.RefreshFailures, - &item.EpisodeMetadataIncomplete, - &item.EpisodeMetadataLastCheckedAt, - &item.LockedFields, - &item.Status, - &item.CreatedAt, - &item.UpdatedAt, - &rowTotal, - ) - if err != nil { + dests := append(listItemScanDests(&item), &rowTotal) + if err := rows.Scan(dests...); err != nil { return nil, 0, fmt.Errorf("scanning media item row with total: %w", err) } items = append(items, &item) @@ -1002,6 +982,10 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, } applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, &conditions, &args, &argIdx) + // Manga chapters (type='ebook' rows linked into a manga series) are internal + // sub-units and must never surface as standalone search results. + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + whereClause := "WHERE " + strings.Join(conditions, " AND ") // Bind ExactTitleHint exactly once. The same arg index is referenced by @@ -1144,7 +1128,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, // items that are linked to at least one present file within the given folder // subtree. This intentionally includes ambiguous items so a library scan can // revisit legacy scanner ambiguities after inference heuristics improve. -func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error) { +func (r *ItemRepository) buildListUnmatchedByFolderAndPathPrefixSQL(folderID int, pathPrefix string, limit int) (string, []any) { query := ` SELECT mi.content_id FROM media_items mi @@ -1158,6 +1142,11 @@ func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context, WHERE mil.media_folder_id = $1 AND folders.enabled = true AND mi.status IN ('unmatched', 'pending', 'ambiguous') + -- Manga chapters stay status='pending' by design: provider metadata + -- lives on the type='manga' series item, so chapters are never + -- matchable and must not feed the matcher's retry loop (mirrors the + -- exclusion in the ebook enricher's claim query). + AND ` + MangaChapterExclusionWhere("mi") + ` AND mf.missing_since IS NULL AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\') GROUP BY mi.content_id @@ -1168,6 +1157,11 @@ func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context, query += ` LIMIT $4` args = append(args, limit) } + return query, args +} + +func (r *ItemRepository) ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error) { + query, args := r.buildListUnmatchedByFolderAndPathPrefixSQL(folderID, pathPrefix, limit) rows, err := r.pool.Query(ctx, query, args...) if err != nil { diff --git a/internal/catalog/item_repo_test.go b/internal/catalog/item_repo_test.go index dcdafa1b..fcfdf778 100644 --- a/internal/catalog/item_repo_test.go +++ b/internal/catalog/item_repo_test.go @@ -443,3 +443,31 @@ func TestItemRepo_Search_GroupByHasNoOutputAliases(t *testing.T) { } } } + +// TestItemRepo_ListUnmatchedByFolderAndPathPrefix_ExcludesMangaChapters pins +// the manga-chapter exclusion in the unmatched-item lister. Manga chapters are +// type='ebook' items that stay status='pending' by design (the type='manga' +// series item carries all provider metadata), so without a NOT EXISTS guard +// against manga_chapters every library scan funnels each chapter through the +// matcher's retry loop — one rate-limited ebook-plugin search per chapter +// (observed live 2026-06-12: 31,564 chapters x ~1s = 8h46m per scan, 100% +// no-match). Mirrors the same exclusion in the ebook enricher's claim query. +func TestItemRepo_ListUnmatchedByFolderAndPathPrefix_ExcludesMangaChapters(t *testing.T) { + repo := &ItemRepository{} + + sql, args := repo.buildListUnmatchedByFolderAndPathPrefixSQL(10, "/mnt/media/manga", 0) + if !strings.Contains(sql, "NOT EXISTS") || !strings.Contains(sql, "manga_chapters") { + t.Fatalf("expected manga_chapters NOT EXISTS guard in unmatched lister; got:\n%s", sql) + } + if len(args) != 3 { + t.Fatalf("expected 3 args without limit; got %v", args) + } + + sql, args = repo.buildListUnmatchedByFolderAndPathPrefixSQL(10, "/mnt/media/manga", 25) + if !strings.Contains(sql, "LIMIT $4") { + t.Fatalf("expected LIMIT $4 when limit > 0; got:\n%s", sql) + } + if len(args) != 4 { + t.Fatalf("expected 4 args with limit; got %v", args) + } +} diff --git a/internal/catalog/library_repo.go b/internal/catalog/library_repo.go index a3496485..6a59bc97 100644 --- a/internal/catalog/library_repo.go +++ b/internal/catalog/library_repo.go @@ -447,6 +447,10 @@ func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, f } defer func() { _ = tx.Rollback(ctx) }() + // Manga series items (type='manga') are virtual parents with no media_file of + // their own — their membership is keyed to having chapters, not files. Exclude + // them here so file-presence reconciliation never sweeps a live series; orphan + // series (no remaining chapters) are cleaned up separately by the manga scan. rows, err := tx.Query(ctx, ` DELETE FROM media_item_libraries mil WHERE mil.media_folder_id = $1 @@ -457,6 +461,12 @@ func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, f AND mf.content_id = mil.content_id AND mf.missing_since IS NULL ) + AND NOT EXISTS ( + SELECT 1 + FROM media_items mi + WHERE mi.content_id = mil.content_id + AND mi.type = 'manga' + ) RETURNING mil.content_id `, folderID) if err != nil { diff --git a/internal/catalog/manga_chapter_exclusion_test.go b/internal/catalog/manga_chapter_exclusion_test.go new file mode 100644 index 00000000..5ac989b9 --- /dev/null +++ b/internal/catalog/manga_chapter_exclusion_test.go @@ -0,0 +1,53 @@ +package catalog + +import ( + "strings" + "testing" +) + +// mangaChapterExclusionFor returns the predicate that excludes manga chapter +// ebook rows (type='ebook' rows linked via manga_chapters) from a catalog +// listing query keyed on the given media_items alias. Manga chapters are +// internal sub-units of a type='manga' series and must never surface as +// standalone catalog items on browse / section / search surfaces. +// +// TestMangaChapterExclusionPredicate_AllListingBuilders pins the exact text so +// the three independent listing builders (buildBrowsePlan, +// QueryExecutor.buildPreviewPagePlan, ItemRepository.buildSearchSQL) all carry +// the same index-backed exclusion (manga_chapters.chapter_content_id is the PK). +func mangaChapterExclusionPredicate(alias string) string { + return "NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = " + alias + ".content_id)" +} + +func TestMangaChapterExclusion_BrowsePlan(t *testing.T) { + repo := &BrowseRepository{} + plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "ebook"}) + if err != nil || earlyEmpty { + t.Fatalf("buildBrowsePlan err=%v earlyEmpty=%v", err, earlyEmpty) + } + if !strings.Contains(plan.whereClause, mangaChapterExclusionPredicate("mi")) { + t.Fatalf("browse plan WHERE missing manga-chapter exclusion.\ngot: %s", plan.whereClause) + } +} + +func TestMangaChapterExclusion_PreviewPageSQL(t *testing.T) { + sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL( + QueryDefinition{MediaScope: "ebook"}, + AccessFilter{}, + 20, 0, true, + ) + if err != nil { + t.Fatalf("buildPreviewPageSQL error: %v", err) + } + if !strings.Contains(sql, mangaChapterExclusionPredicate("mi")) { + t.Fatalf("preview-page SQL missing manga-chapter exclusion.\ngot: %s", sql) + } +} + +func TestMangaChapterExclusion_SearchSQL(t *testing.T) { + repo := &ItemRepository{} + sql, _, _ := repo.buildSearchSQL("naruto", []string{"ebook"}, 20, 0, AccessFilter{}) + if !strings.Contains(sql, mangaChapterExclusionPredicate("mi")) { + t.Fatalf("search SQL missing manga-chapter exclusion.\ngot: %s", sql) + } +} diff --git a/internal/catalog/manga_files.go b/internal/catalog/manga_files.go new file mode 100644 index 00000000..eeae9fbd --- /dev/null +++ b/internal/catalog/manga_files.go @@ -0,0 +1,114 @@ +package catalog + +// Manga chapter ↔ series linkage helpers for surfaces beyond the series +// detail page: the chapter detail payload (reader back/next navigation), +// continue-reading cards (series heading), and the series file-details +// dialog. + +import ( + "context" + "path/filepath" + "strings" +) + +// mangaSeriesForChapterQuery resolves the owning manga series for a chapter +// (a type='ebook' item linked via manga_chapters). +const mangaSeriesForChapterQuery = ` + SELECT mc.series_content_id, si.title + FROM manga_chapters mc + JOIN media_items si ON si.content_id = mc.series_content_id + WHERE mc.chapter_content_id = $1 +` + +// lookupMangaSeriesForChapter returns the series content id and title when the +// given item is a manga chapter; ok is false for ordinary ebooks. +func (s *DetailService) lookupMangaSeriesForChapter(ctx context.Context, chapterContentID string) (string, string, bool) { + if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil { + return "", "", false + } + var seriesID, seriesTitle string + err := s.itemRepo.pool.QueryRow(ctx, mangaSeriesForChapterQuery, chapterContentID). + Scan(&seriesID, &seriesTitle) + if err != nil { + return "", "", false + } + return seriesID, seriesTitle, true +} + +// MangaChapterFile is one local file backing a chapter of a manga series, for +// the series "View Details" dialog. +type MangaChapterFile struct { + ContentID string `json:"content_id"` + Title string `json:"title"` + ChapterIndex *float64 `json:"chapter_index,omitempty"` + Volume string `json:"volume,omitempty"` + FilePath string `json:"file_path,omitempty"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + Container string `json:"container,omitempty"` +} + +// MangaSeriesFiles is the series file-details payload: the folder(s) the +// chapter files live in plus one row per file in reading order. +type MangaSeriesFiles struct { + FolderPaths []string `json:"folder_paths,omitempty"` + Files []MangaChapterFile `json:"files"` +} + +// mangaChapterFilesQuery lists a manga series' chapter files in reading order +// (mirrors mangaChaptersQuery ordering). +const mangaChapterFilesQuery = ` + SELECT m.content_id, m.title, mc.chapter_index, mc.volume, + f.file_path, COALESCE(f.file_size, 0), COALESCE(f.container, '') + FROM manga_chapters mc + JOIN media_items m ON m.content_id = mc.chapter_content_id + JOIN media_files f ON f.content_id = mc.chapter_content_id + WHERE mc.series_content_id = $1 + ORDER BY mc.chapter_index NULLS LAST, m.sort_title, f.file_path +` + +// GetMangaChapterFiles returns the local file listing for an accessible manga +// series. File paths are always populated here; the API layer strips them for +// viewers without file-path visibility (same policy as item versions). +func (s *DetailService) GetMangaChapterFiles(ctx context.Context, seriesContentID string, filter AccessFilter) (*MangaSeriesFiles, error) { + if err := s.itemRepo.EnsureAccessible(ctx, seriesContentID, filter); err != nil { + return nil, err + } + + rows, err := s.itemRepo.pool.Query(ctx, mangaChapterFilesQuery, seriesContentID) + if err != nil { + return nil, err + } + defer rows.Close() + + result := &MangaSeriesFiles{Files: make([]MangaChapterFile, 0, 16)} + folders := make([]string, 0, 1) + seenFolders := make(map[string]struct{}) + for rows.Next() { + var ( + file MangaChapterFile + index *float64 + volume *string + ) + if err := rows.Scan(&file.ContentID, &file.Title, &index, &volume, &file.FilePath, &file.FileSize, &file.Container); err != nil { + return nil, err + } + file.ChapterIndex = index + if volume != nil { + file.Volume = *volume + } + file.FileName = filepath.Base(file.FilePath) + if dir := filepath.Dir(file.FilePath); dir != "." && dir != "/" && strings.TrimSpace(dir) != "" { + if _, ok := seenFolders[dir]; !ok { + seenFolders[dir] = struct{}{} + folders = append(folders, dir) + } + } + result.Files = append(result.Files, file) + } + if err := rows.Err(); err != nil { + return nil, err + } + result.FolderPaths = folders + return result, nil +} diff --git a/internal/catalog/media_scope_test.go b/internal/catalog/media_scope_test.go index b08c8206..9c1aee84 100644 --- a/internal/catalog/media_scope_test.go +++ b/internal/catalog/media_scope_test.go @@ -18,6 +18,9 @@ func TestMediaScopeItemTypes(t *testing.T) { {"", nil}, {"movie", []string{"movie"}}, {"audiobook", []string{"audiobook"}}, + // A manga library browses only its series items; the per-chapter ebook + // items are excluded because the manga scope expands to type=manga only. + {"manga", []string{"manga"}}, {"video", []string{"movie", "series"}}, {" Video ", []string{"movie", "series"}}, } @@ -39,6 +42,8 @@ func TestMediaScopeMatchesItemType(t *testing.T) { {"video", "series", true}, {"video", "audiobook", false}, {"audiobook", "audiobook", true}, + {"manga", "manga", true}, + {"manga", "ebook", false}, {"movie", "series", false}, } for _, tc := range cases { diff --git a/internal/catalog/provider_id_repo.go b/internal/catalog/provider_id_repo.go index 6022f846..5838017b 100644 --- a/internal/catalog/provider_id_repo.go +++ b/internal/catalog/provider_id_repo.go @@ -129,10 +129,32 @@ func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, item const providerIDColumns = `content_id, item_type, provider, provider_id, created_at, updated_at` +// excludedProviderIDs lists providers that ReplaceByContentID does NOT manage: +// it neither persists nor deletes them. Two kinds live here: +// - ephemeral, query-only inputs (metadb, _filepath, oshash) that must never +// be written as durable rows; and +// - Silo-internal identity anchors (manga_series) stamped directly by the +// scanner to keep manga re-scans idempotent. Replace must leave these rows +// intact — otherwise the first manga enrichment (which calls +// ReplaceByContentID with only the external IDs) would delete the +// manga_series anchor, and the next scan would mint a duplicate series and +// lose the enriched metadata. var excludedProviderIDs = map[string]struct{}{ - "metadb": {}, - "_filepath": {}, - "oshash": {}, + "metadb": {}, + "_filepath": {}, + "oshash": {}, + "manga_series": {}, +} + +// unmanagedProviderIDList returns excludedProviderIDs as a lowercased, sorted +// slice for binding into the Replace DELETE so those rows are preserved. +func unmanagedProviderIDList() []string { + out := make([]string, 0, len(excludedProviderIDs)) + for p := range excludedProviderIDs { + out = append(out, p) + } + sort.Strings(out) + return out } var preferredProviderIDOrder = map[string]int{ @@ -243,7 +265,45 @@ func (r *ProviderIDRepository) GetByContentID(ctx context.Context, contentID str return scanProviderIDs(rows) } -// ReplaceByContentID replaces all durable provider IDs for a content item. +// GetByContentIDs fetches provider IDs for many content items in one query, +// grouped by content_id (IDs with no rows are absent). Replaces per-item +// GetByContentID loops on the enrichment claim path. +func (r *ProviderIDRepository) GetByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaItemProviderID, error) { + out := make(map[string][]*models.MediaItemProviderID, len(contentIDs)) + if len(contentIDs) == 0 { + return out, nil + } + rows, err := r.pool.Query(ctx, ` + SELECT `+providerIDColumns+` + FROM media_item_provider_ids + WHERE content_id = ANY($1) + ORDER BY content_id, + CASE LOWER(provider) + WHEN 'tmdb' THEN 0 + WHEN 'tvdb' THEN 1 + WHEN 'imdb' THEN 2 + ELSE 3 + END, + LOWER(provider) ASC, + provider_id ASC + `, contentIDs) + if err != nil { + return nil, fmt.Errorf("getting provider IDs by content_ids: %w", err) + } + defer rows.Close() + all, err := scanProviderIDs(rows) + if err != nil { + return nil, err + } + for _, pid := range all { + out[pid.ContentID] = append(out[pid.ContentID], pid) + } + return out, nil +} + +// ReplaceByContentID replaces the durable provider IDs it manages for a content +// item, leaving unmanaged providers (excludedProviderIDs, e.g. the scanner's +// manga_series identity anchor) intact. func (r *ProviderIDRepository) ReplaceByContentID(ctx context.Context, contentID string, providerIDs map[string]string) error { if strings.TrimSpace(contentID) == "" { return fmt.Errorf("content_id is required") @@ -290,7 +350,13 @@ func (r *ProviderIDRepository) ReplaceByContentIDTx( } entries := normalizeDurableProviderIDs(providerIDs) - if _, err := tx.Exec(ctx, `DELETE FROM media_item_provider_ids WHERE content_id = $1`, contentID); err != nil { + // Preserve providers Replace does not manage (see excludedProviderIDs): + // query-only inputs and the scanner's manga_series identity anchor. + if _, err := tx.Exec(ctx, ` + DELETE FROM media_item_provider_ids + WHERE content_id = $1 + AND lower(provider) <> ALL($2::text[]) + `, contentID, unmanagedProviderIDList()); err != nil { return fmt.Errorf("deleting provider IDs for %s: %w", contentID, err) } diff --git a/internal/catalog/query_definition.go b/internal/catalog/query_definition.go index a3076c45..6cae6820 100644 --- a/internal/catalog/query_definition.go +++ b/internal/catalog/query_definition.go @@ -113,7 +113,7 @@ const MediaScopeVideo = "video" // is an accepted media_scope value. Empty means unscoped and is valid. func IsValidMediaScope(scope string) bool { switch scope { - case "", "movie", "series", "episode", "audiobook", "ebook", MediaScopeVideo: + case "", "movie", "series", "episode", "audiobook", "ebook", "manga", MediaScopeVideo: return true default: return false @@ -230,7 +230,7 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson } if !IsValidMediaScope(normalized.MediaScope) { - return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', or 'video'") + return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', 'manga', or 'video'") } if normalized.Match != "all" && normalized.Match != "any" { diff --git a/internal/catalog/query_definition_test.go b/internal/catalog/query_definition_test.go index b95762e1..bf2c3daa 100644 --- a/internal/catalog/query_definition_test.go +++ b/internal/catalog/query_definition_test.go @@ -84,6 +84,18 @@ func TestValidate_EbookMediaScope(t *testing.T) { } } +func TestValidate_MangaMediaScope(t *testing.T) { + qd := QueryDefinition{ + MediaScope: "manga", + Match: "all", + Groups: []QueryGroup{}, + Sort: QuerySort{Field: "title", Order: "asc"}, + } + if err := qd.Validate(); err != nil { + t.Fatalf("expected manga media scope to be valid, got %v", err) + } +} + func TestValidate_EbookMediaScopeRejectsNarratorRule(t *testing.T) { qd := QueryDefinition{ MediaScope: "ebook", diff --git a/internal/catalog/query_executor.go b/internal/catalog/query_executor.go index 37950cf8..e3b9043f 100644 --- a/internal/catalog/query_executor.go +++ b/internal/catalog/query_executor.go @@ -80,7 +80,7 @@ func (e *QueryExecutor) PreviewPage( items []*models.MediaItem total int ) - items, err = scanItems(rows) + items, err = scanItemsWithMangaCounts(rows) if err != nil { return nil, 0, false, err } @@ -192,7 +192,9 @@ func (p previewPagePlan) pagedSQL(includeTotal bool) (string, []any) { offsetClause = fmt.Sprintf(" OFFSET $%d", offsetArgIdx) args = append(args, p.offset) } - selectList := qualifiedListItemColumns("mi") + // mangaCountColumns feeds the Vols/Ch poster chip on manga cards; the + // library page browses through this preview path, not BrowseRepository. + selectList := qualifiedListItemColumns("mi") + ", " + mangaCountColumns("mi") withClause := "" if len(p.ctes) > 0 { withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n" @@ -327,6 +329,10 @@ func (e *QueryExecutor) buildPreviewPagePlan( argIdx++ } + // Manga chapters (type='ebook' rows linked into a manga series) are internal + // sub-units and must never surface as standalone catalog items. + conditions = append(conditions, MangaChapterExclusionWhere("mi")) + if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" { // Dual-column OR matching browse.go and favorites_browse.go: items where // a curated sort_title differs from title (e.g. title="The Office", diff --git a/internal/ebooks/enrichment.go b/internal/ebooks/enrichment.go index bb6c459b..731eb69c 100644 --- a/internal/ebooks/enrichment.go +++ b/internal/ebooks/enrichment.go @@ -216,7 +216,7 @@ func (e *Enricher) runBatch( // claimBatchQuery selects unenriched ebooks. Items with fewer prior failures // are claimed first and items at/above enrichFailureCap are skipped entirely, // so a block of permanently failing items cannot occupy every sweep. -const claimBatchQuery = ` +var claimBatchQuery = ` SELECT mi.content_id, mi.title, @@ -238,6 +238,11 @@ const claimBatchQuery = ` LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id LEFT JOIN ebook_enrichment_state ees ON ees.content_id = mi.content_id WHERE mi.type = 'ebook' + -- Manga chapters are type='ebook' but are parts of a series, not + -- standalone books. They are enriched via their type='manga' series (a + -- separate path), never individually against book sources — excluding + -- them here stops a pointless search storm over Gutenberg/Anna's/etc. + AND ` + catalog.MangaChapterExclusionWhere("mi") + ` AND (mi.poster_path IS NULL OR mi.poster_path = '') AND mi.last_refreshed IS NULL AND COALESCE(ees.failures, 0) < $2 diff --git a/internal/manga/enrichment.go b/internal/manga/enrichment.go new file mode 100644 index 00000000..f1dd60fe --- /dev/null +++ b/internal/manga/enrichment.go @@ -0,0 +1,968 @@ +package manga + +// Enricher periodically enriches manga media_items that are missing metadata +// by querying the configured metadata-provider chain for each item's library +// folder. + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" +) + +const ( + mangaMetadataImageProviderID = "manga-metadata" + + // defaultEnrichBatchSize is sized so a sweep finishes just within the + // 5-minute task interval: the plugin serves GetMetadata from its search + // cache, so an item costs one AniList request at the plugin's ~28 req/min + // budget (AniList's degraded-mode ceiling is 30/min) — 140 items ≈ 295s. + // Larger batches are not faster: the task manager drops a trigger while a + // sweep is still running, so an overlong sweep idles until the trigger + // after next and the effective rate drops below the AniList budget. + defaultEnrichBatchSize = 140 + defaultEnrichWorkers = 4 + + // enrichFailureCap is the manga_enrichment_state.failures count at which + // a manga stops being claimed for enrichment. Combined with the + // failure-count-first claim ordering this prevents a head-of-line block + // of permanently failing items from starving newer items and hammering + // providers. + enrichFailureCap = 5 +) + +// errEnrichmentSkipped marks an item that could not be attempted at all (no +// library folder linked yet, no providers configured). Skipped items are +// neither stamped as refreshed nor counted against the failure cap, so they +// are retried on every sweep until the missing prerequisite appears. +var errEnrichmentSkipped = errors.New("manga enrichment skipped") + +// errEnrichmentNoMatch marks an item every provider answered for without a +// confident match. The item was stamped (it will not be re-claimed); the +// sentinel only keeps the sweep counters honest — a no-match is neither an +// enrichment nor a failure. +var errEnrichmentNoMatch = errors.New("manga enrichment: no confident match") + +func mangaContentType() string { + return "manga" +} + +func mangaEnrichWorkers() int { + n := defaultEnrichWorkers + if v := os.Getenv("SILO_MANGA_ENRICH_WORKERS"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + n = parsed + } + } + if n > mangaEnrichBatchSize() { + n = mangaEnrichBatchSize() + } + return n +} + +func mangaEnrichBatchSize() int { + if v := os.Getenv("SILO_MANGA_ENRICH_BATCH"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + return parsed + } + } + return defaultEnrichBatchSize +} + +type enrichmentItemRow struct { + ContentID string + Title string + Year int + FolderID int + Language string + Author string + ProviderIDs map[string]string + + // HasPoster marks an already-enriched item claimed only because a + // secondary field (backdrop, status) is missing; the sweep then fetches by + // stored provider ID and touches only the missing secondary fields. + HasPoster bool + // HasBackdrop guards the secondary pass against re-caching an existing + // backdrop when the item was claimed for another missing field. + HasBackdrop bool +} + +// Enricher drives the manga metadata enrichment sweep. +type Enricher struct { + pool *pgxpool.Pool + chainRepo *metadata.ChainRepository + resolver *metadata.PluginResolverAdapter + itemRepo *catalog.ItemRepository + personRepo *catalog.PersonRepository + providerIDs *catalog.ProviderIDRepository + imageCacher metadata.ImageCacher + batchSize int + workers int +} + +func NewEnricher( + pool *pgxpool.Pool, + chainRepo *metadata.ChainRepository, + resolver *metadata.PluginResolverAdapter, + itemRepo *catalog.ItemRepository, + personRepo *catalog.PersonRepository, + providerIDs *catalog.ProviderIDRepository, +) *Enricher { + return &Enricher{ + pool: pool, + chainRepo: chainRepo, + resolver: resolver, + itemRepo: itemRepo, + personRepo: personRepo, + providerIDs: providerIDs, + batchSize: mangaEnrichBatchSize(), + workers: mangaEnrichWorkers(), + } +} + +func (e *Enricher) SetImageCacher(cacher metadata.ImageCacher) { + if e == nil { + return + } + e.imageCacher = cacher +} + +func (e *Enricher) Run(ctx context.Context) (int, error) { + if e == nil || e.pool == nil || e.chainRepo == nil { + return 0, nil + } + + items, err := e.claimBatch(ctx) + if err != nil { + return 0, fmt.Errorf("manga enrichment: claim batch: %w", err) + } + if len(items) == 0 { + return 0, nil + } + + slog.Info("manga enrichment: sweep started", + "count", len(items), + "workers", e.workers, + ) + + stats := e.runBatch(ctx, items, e.enrichItem, e.recordEnrichFailure) + + slog.Info("manga enrichment: sweep complete", + "attempted", len(items), + "enriched", stats.enriched, + "no_match", stats.noMatch, + "failed", stats.failed, + ) + return int(stats.enriched), nil +} + +// sweepStats separates the three terminal outcomes of a sweep so the log and +// task result do not overcount: a stamped no-match is not an enrichment. +type sweepStats struct { + enriched int64 + noMatch int64 + failed int64 +} + +func (e *Enricher) runBatch( + ctx context.Context, + items []enrichmentItemRow, + enrichFn func(context.Context, enrichmentItemRow) error, + recordFailure func(context.Context, enrichmentItemRow), +) sweepStats { + workers := e.workers + if workers <= 0 { + workers = 1 + } + if workers > len(items) { + workers = len(items) + } + + ch := make(chan enrichmentItemRow, workers) + var ( + wg sync.WaitGroup + stats sweepStats + ) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for item := range ch { + if ctx.Err() != nil { + continue + } + if err := enrichFn(ctx, item); err != nil { + if errors.Is(err, errEnrichmentSkipped) { + slog.Debug("manga enrichment: item skipped", + "content_id", item.ContentID, + "title", item.Title, + "reason", err, + ) + continue + } + if errors.Is(err, errEnrichmentNoMatch) { + atomic.AddInt64(&stats.noMatch, 1) + continue + } + slog.Warn("manga enrichment: item failed", + "content_id", item.ContentID, + "title", item.Title, + "error", err, + ) + // A cancelled sweep says nothing about the item itself, + // so it does not count against the failure cap. + if recordFailure != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + recordFailure(ctx, item) + } + atomic.AddInt64(&stats.failed, 1) + continue + } + atomic.AddInt64(&stats.enriched, 1) + } + }() + } + for _, item := range items { + if ctx.Err() != nil { + break + } + ch <- item + } + close(ch) + wg.Wait() + return stats +} + +// claimBatchQuery selects manga needing enrichment. Both arms require +// last_refreshed IS NULL: +// - the common arm is unenriched items (no poster); +// - the secondary arm (poster present, backdrop or show_status empty) only +// becomes reachable when an operator resets last_refreshed to backfill a +// newly-added field across an already-enriched library — exactly how the +// banner and publication-status backfills were rolled out. It is an +// efficient fast-path for that admin action (fetch by stored provider id, +// write only the missing secondary field) and is intentionally NOT an +// automatic periodic re-check: a series whose provider simply has no +// banner would otherwise be re-fetched every sweep. +// +// Stamping after the attempt keeps items whose provider has no banner/status +// from being re-claimed within the same backfill. Items with fewer prior +// failures are claimed first and items at/above enrichFailureCap are skipped +// entirely, so a block of permanently failing items cannot occupy every sweep. +const claimBatchQuery = ` + SELECT + mi.content_id, + mi.title, + mi.year, + COALESCE(mil.media_folder_id, 0) AS folder_id, + COALESCE(mf.metadata_language, 'en') AS language, + COALESCE( + (SELECT p.name + FROM item_people ip + JOIN people p ON p.id = ip.person_id + WHERE ip.content_id = mi.content_id + AND ip.kind = 7 + ORDER BY ip.sort_order, ip.id + LIMIT 1), + '' + ) AS author, + (mi.poster_path IS NOT NULL AND mi.poster_path <> '') AS has_poster, + (mi.backdrop_path IS NOT NULL AND mi.backdrop_path <> '') AS has_backdrop + FROM media_items mi + LEFT JOIN media_item_libraries mil ON mil.content_id = mi.content_id + LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id + LEFT JOIN manga_enrichment_state ees ON ees.content_id = mi.content_id + WHERE mi.type = 'manga' + AND ((mi.poster_path IS NULL OR mi.poster_path = '') + OR (mi.backdrop_path IS NULL OR mi.backdrop_path = '') + OR (mi.show_status IS NULL OR mi.show_status = '')) + AND mi.last_refreshed IS NULL + AND COALESCE(ees.failures, 0) < $2 + ORDER BY COALESCE(ees.failures, 0) ASC, mi.created_at ASC + LIMIT $1 +` + +func (e *Enricher) claimBatch(ctx context.Context) ([]enrichmentItemRow, error) { + rows, err := e.pool.Query(ctx, claimBatchQuery, e.batchSize, enrichFailureCap) + if err != nil { + return nil, fmt.Errorf("querying unenriched manga: %w", err) + } + defer rows.Close() + + var items []enrichmentItemRow + seen := make(map[string]struct{}) + for rows.Next() { + var item enrichmentItemRow + if err := rows.Scan( + &item.ContentID, + &item.Title, + &item.Year, + &item.FolderID, + &item.Language, + &item.Author, + &item.HasPoster, + &item.HasBackdrop, + ); err != nil { + return nil, fmt.Errorf("scanning manga enrichment row: %w", err) + } + if _, dup := seen[item.ContentID]; dup { + continue + } + seen[item.ContentID] = struct{}{} + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating manga enrichment rows: %w", err) + } + + if e.providerIDs != nil && len(items) > 0 { + ids := make([]string, len(items)) + for i := range items { + ids[i] = items[i].ContentID + } + if byID, err := e.providerIDs.GetByContentIDs(ctx, ids); err == nil { + for i := range items { + items[i].ProviderIDs = providerIDMapFromRows(byID[items[i].ContentID]) + } + } + } + + return items, nil +} + +func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error { + if item.FolderID == 0 { + // The scanner inserts the library membership after the item upsert, so + // a freshly indexed manga can be claimed inside that window. Skip it: + // stamping here would terminally mark the item refreshed before any + // provider ever saw it. + return fmt.Errorf("%w: item %s has no library folder yet", errEnrichmentSkipped, item.ContentID) + } + + providers, err := metadata.ResolveChain(ctx, item.FolderID, mangaContentType(), e.chainRepo, e.resolver) + if err != nil { + return fmt.Errorf("resolving manga chain for folder %d: %w", item.FolderID, err) + } + return e.enrichWithProviders(ctx, item, providers) +} + +// enrichWithProviders runs the provider chain for one claimed item. Outcomes: +// - metadata obtained: persist it and stamp last_refreshed (nil error); +// - providers answered but nothing matched: stamp last_refreshed so the +// item is not re-claimed every sweep (errEnrichmentNoMatch); +// - one or more providers errored and no metadata was obtained: return an +// error so the failure cap/backoff engages, without stamping; +// - no providers configured: skip (no stamp, no failure) so the item is +// retried once a chain exists. +func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemRow, providers []metadata.Provider) error { + if len(providers) == 0 { + return fmt.Errorf("%w: no metadata providers configured for folder %d", errEnrichmentSkipped, item.FolderID) + } + + accumulator, accumulatedIDs, providerErrs := collectMangaMetadata(ctx, item, providers) + + if item.HasPoster { + return e.enrichSecondaryOnly(ctx, item, accumulator, providerErrs) + } + + if !accumulator.HasMetadata && accumulator.PosterPath == "" && accumulator.Overview == "" { + if err := ctx.Err(); err != nil { + // A cancelled sweep says nothing about the item or the providers. + return err + } + if len(providerErrs) > 0 { + // Transient provider trouble must not stamp the item terminally; + // surfacing an error engages the failure cap and backoff instead. + return fmt.Errorf("no metadata obtained, %d provider error(s): %w", + len(providerErrs), errors.Join(providerErrs...)) + } + slog.Info("manga enrichment: no metadata found", + "content_id", item.ContentID, + "title", item.Title, + ) + if err := e.stampLastRefreshed(ctx, item.ContentID); err != nil { + return err + } + return errEnrichmentNoMatch + } + + e.cacheRemoteImages(ctx, item.ContentID, accumulator) + + if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil { + return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err) + } + + slog.Info("manga enrichment: enriched", + "content_id", item.ContentID, + "title", item.Title, + "poster", accumulator.PosterPath != "", + "backdrop", accumulator.BackdropPath != "", + "overview", accumulator.Overview != "", + "people", len(filterMangaPeople(accumulator.People)), + ) + + return nil +} + +// enrichSecondaryOnly finishes a secondary-fields claim: an already-enriched +// item missing its backdrop and/or publication status. Only the missing +// secondary fields are written — the existing poster, overview, people, and +// provider IDs stay untouched. Whatever the outcome (fields filled, provider +// has neither), the item is stamped so it is not re-claimed every sweep; +// provider errors engage the failure cap without stamping, like the full path. +func (e *Enricher) enrichSecondaryOnly(ctx context.Context, item enrichmentItemRow, result *metadata.MetadataResult, providerErrs []error) error { + upd := &catalog.MetadataUpdate{} + if result != nil && result.BackdropPath != "" && !item.HasBackdrop { + path, thumbhash := e.cacheRemoteImage(ctx, item.ContentID, result.BackdropPath, metadata.ImageBackdrop) + upd.BackdropPath = &path + if thumbhash != "" { + upd.BackdropThumbhash = &thumbhash + } + } + if result != nil { + if status := normalizeMangaStatus(result.ShowStatus); status != "" { + upd.ShowStatus = &status + } + } + + if upd.BackdropPath == nil && upd.ShowStatus == nil { + if err := ctx.Err(); err != nil { + return err + } + if len(providerErrs) > 0 { + return fmt.Errorf("no secondary metadata obtained, %d provider error(s): %w", + len(providerErrs), errors.Join(providerErrs...)) + } + slog.Info("manga enrichment: no secondary metadata available", + "content_id", item.ContentID, + "title", item.Title, + ) + if err := e.stampLastRefreshed(ctx, item.ContentID); err != nil { + return err + } + return errEnrichmentNoMatch + } + + if err := e.updateMetadataAndTimestamps(ctx, item.ContentID, upd); err != nil { + return fmt.Errorf("persisting secondary metadata for %s: %w", item.ContentID, err) + } + + slog.Info("manga enrichment: secondary metadata added", + "content_id", item.ContentID, + "title", item.Title, + "backdrop", upd.BackdropPath != nil, + "status", upd.ShowStatus != nil, + ) + return nil +} + +// collectMangaMetadata queries every provider in the chain and accumulates +// IDs and metadata. Individual provider failures are collected (not fatal) so +// the caller can distinguish "providers answered, no match" from "providers +// were unreachable". The search pass is skipped when the item already carries +// provider IDs (a previously matched item only needs the by-ID fetch). +func collectMangaMetadata(ctx context.Context, item enrichmentItemRow, providers []metadata.Provider) (*metadata.MetadataResult, map[string]string, []error) { + searchQuery, accumulatedIDs := buildMangaSearchQuery(item) + var providerErrs []error + + // An item that already carries provider IDs was matched before; the by-ID + // fetch below is enough and re-searching would spend a rate-limited + // request (and risk re-matching differently). + searchProviders := providers + if len(accumulatedIDs) > 0 { + searchProviders = nil + } + + for _, p := range searchProviders { + sp, ok := p.(metadata.SearchProvider) + if !ok { + continue + } + results, searchErr := sp.Search(ctx, searchQuery) + if searchErr != nil { + slog.Warn("manga enrichment: search error", + "provider", p.Slug(), + "content_id", item.ContentID, + "error", searchErr, + ) + providerErrs = append(providerErrs, fmt.Errorf("%s search: %w", p.Slug(), searchErr)) + continue + } + if len(results) == 0 { + continue + } + for k, v := range results[0].ProviderIDs { + if v != "" { + if _, exists := accumulatedIDs[k]; !exists { + accumulatedIDs[k] = v + } + } + } + slog.Debug("manga enrichment: search result", + "provider", p.Slug(), + "content_id", item.ContentID, + "matched_ids", accumulatedIDs, + ) + } + + accumulator := &metadata.MetadataResult{ + ProviderIDs: accumulatedIDs, + } + + for _, p := range providers { + mp, ok := p.(metadata.MetadataProvider) + if !ok { + continue + } + result, getErr := mp.GetMetadata(ctx, buildMangaMetadataRequest(accumulator.ProviderIDs, item.Language)) + if getErr != nil { + slog.Warn("manga enrichment: GetMetadata error", + "provider", p.Slug(), + "content_id", item.ContentID, + "error", getErr, + ) + providerErrs = append(providerErrs, fmt.Errorf("%s metadata: %w", p.Slug(), getErr)) + continue + } + if result == nil || !result.HasMetadata { + continue + } + mergeEnrichmentProviderIDs(accumulator, result) + metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty) + // MergeMetadata does not propagate HasMetadata; without this a confident + // match carrying only genres/authors/status/year (no cover, no overview) + // would fail the no-match check below and be discarded + stamped. + accumulator.HasMetadata = true + + slog.Debug("manga enrichment: metadata received", + "provider", p.Slug(), + "content_id", item.ContentID, + "has_poster", result.PosterPath != "", + "has_overview", result.Overview != "", + ) + } + + return accumulator, accumulator.ProviderIDs, providerErrs +} + +// cacheRemoteImages localizes the remote poster and backdrop URLs on a full +// enrichment result, replacing each with the cached path + thumbhash when +// caching succeeds (the provider URL is kept as a fallback otherwise). +func (e *Enricher) cacheRemoteImages(ctx context.Context, contentID string, result *metadata.MetadataResult) { + if e == nil || result == nil { + return + } + if path, thumbhash := e.cacheRemoteImage(ctx, contentID, result.PosterPath, metadata.ImagePoster); path != "" { + result.PosterPath = path + if thumbhash != "" { + result.PosterThumbhash = thumbhash + } + } + if path, thumbhash := e.cacheRemoteImage(ctx, contentID, result.BackdropPath, metadata.ImageBackdrop); path != "" { + result.BackdropPath = path + if thumbhash != "" { + result.BackdropThumbhash = thumbhash + } + } +} + +// cacheRemoteImage downloads and caches one remote image, returning the +// stored path and thumbhash. On any failure it returns the original URL (a +// remote URL in the column still renders; the cache is an optimization). +func (e *Enricher) cacheRemoteImage(ctx context.Context, contentID, url string, imageType metadata.ImageType) (string, string) { + if e == nil || url == "" { + return url, "" + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return url, "" + } + if isNilImageCacher(e.imageCacher) { + return url, "" + } + + cached, err := e.imageCacher.CacheImage(ctx, metadata.CacheImageRequest{ + SourceURL: url, + ProviderID: mangaMetadataImageProviderID, + ContentType: "manga", + ContentID: contentID, + ImageType: imageType, + }) + if err != nil { + slog.Warn("manga enrichment: image cache failed, keeping provider URL", + "content_id", contentID, + "url", url, + "error", err, + ) + return url, "" + } + if cached == nil { + slog.Warn("manga enrichment: image cache returned no result, keeping provider URL", + "content_id", contentID, + "url", url, + ) + return url, "" + } + + storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext) + if storedPath == "" { + return url, "" + } + return storedPath, cached.Thumbhash +} + +func isNilImageCacher(cacher metadata.ImageCacher) bool { + if cacher == nil { + return true + } + value := reflect.ValueOf(cacher) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return value.IsNil() + default: + return false + } +} + +func cachedOriginalImagePath(basePath, ext string) string { + if basePath == "" { + return "" + } + if strings.Contains(basePath, "/original.") { + return basePath + } + if ext == "" { + ext = ".jpg" + } + return strings.TrimRight(basePath, "/") + "/original" + ext +} + +func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error { + upd := &catalog.MetadataUpdate{} + + if result.PosterPath != "" { + upd.PosterPath = &result.PosterPath + } + if result.PosterThumbhash != "" { + upd.PosterThumbhash = &result.PosterThumbhash + } + if result.BackdropPath != "" { + upd.BackdropPath = &result.BackdropPath + } + if result.BackdropThumbhash != "" { + upd.BackdropThumbhash = &result.BackdropThumbhash + } + if result.LogoPath != "" { + upd.LogoPath = &result.LogoPath + } + if result.Overview != "" { + upd.Overview = &result.Overview + } + if result.Tagline != "" { + upd.Tagline = &result.Tagline + } + if result.ReleaseDate != "" { + upd.ReleaseDate = &result.ReleaseDate + } + if len(result.Genres) > 0 { + genres := append([]string(nil), result.Genres...) + upd.Genres = &genres + } + if len(result.Studios) > 0 { + studios := append([]string(nil), result.Studios...) + upd.Studios = &studios + } + if result.ContentRating != "" { + upd.ContentRating = &result.ContentRating + } + if result.Runtime > 0 { + upd.Runtime = &result.Runtime + } + if result.Year > 0 { + upd.Year = &result.Year + } + if status := normalizeMangaStatus(result.ShowStatus); status != "" { + upd.ShowStatus = &status + } + + providerIDs = filterMangaProviderIDs(providerIDs) + if e.providerIDs != nil && len(providerIDs) > 0 { + if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil { + slog.Warn("manga enrichment: failed to persist provider IDs", + "content_id", contentID, + "error", err, + ) + } + } + + if err := e.updateMetadataAndTimestamps(ctx, contentID, upd); err != nil { + return err + } + + authors := filterMangaPeople(result.People) + if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil { + if err := e.persistPeople(ctx, contentID, authors); err != nil { + slog.Warn("manga enrichment: failed to persist people", + "content_id", contentID, + "error", err, + ) + } + } + + return nil +} + +func (e *Enricher) updateMetadataAndTimestamps(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error { + if e.itemRepo == nil { + return nil + } + if err := e.itemRepo.UpdateMetadata(ctx, contentID, upd); err != nil { + return fmt.Errorf("UpdateMetadata: %w", err) + } + return e.stampLastRefreshed(ctx, contentID) +} + +func (e *Enricher) stampLastRefreshed(ctx context.Context, contentID string) error { + if e.pool == nil { + return nil + } + now := time.Now().UTC() + if _, err := e.pool.Exec(ctx, ` + UPDATE media_items + SET last_refreshed = $1, + matched_at = COALESCE(matched_at, $1), + status = CASE WHEN status = 'pending' THEN 'matched' ELSE status END + WHERE content_id = $2 + `, now, contentID); err != nil { + return err + } + // Success clears the enrichment failure backlog. media_items.refresh_failures + // is intentionally left alone: it belongs to the metadata refresh-debt system. + _, err := e.pool.Exec(ctx, ` + DELETE FROM manga_enrichment_state WHERE content_id = $1 + `, contentID) + return err +} + +// recordEnrichFailure increments the item's manga_enrichment_state failure +// counter so claimBatch deprioritizes it on the next sweep and stops claiming +// it at enrichFailureCap. The state is dedicated to manga enrichment; +// media_items.refresh_failures is owned by the metadata refresh-debt system +// and is never touched here. +func (e *Enricher) recordEnrichFailure(ctx context.Context, item enrichmentItemRow) { + if e == nil || e.pool == nil { + return + } + if _, err := e.pool.Exec(ctx, ` + INSERT INTO manga_enrichment_state (content_id, failures, updated_at) + VALUES ($1, 1, NOW()) + ON CONFLICT (content_id) DO UPDATE SET + failures = manga_enrichment_state.failures + 1, + updated_at = NOW() + `, item.ContentID); err != nil { + slog.Warn("manga enrichment: failed to record enrichment failure", + "content_id", item.ContentID, + "error", err, + ) + } +} + +func (e *Enricher) persistPeople(ctx context.Context, contentID string, people []models.ItemPerson) error { + people = filterMangaPeople(people) + if len(people) == 0 { + return nil + } + + persons := make([]models.Person, len(people)) + for i := range people { + persons[i] = people[i].Person + } + + personIDs, err := e.personRepo.BatchFindOrCreate(ctx, persons) + if err != nil { + return fmt.Errorf("BatchFindOrCreate people: %w", err) + } + + linked := make([]models.ItemPerson, 0, len(people)) + for i := range people { + if i >= len(personIDs) || personIDs[i] == 0 { + continue + } + ip := people[i] + ip.Person.ID = personIDs[i] + linked = append(linked, ip) + } + + if len(linked) == 0 { + return nil + } + + existing, err := e.itemRepo.GetPeople(ctx, contentID) + if err != nil { + return fmt.Errorf("get existing people: %w", err) + } + return e.itemRepo.ReplacePeople(ctx, contentID, mergeMangaAuthorCredits(existing, linked)) +} + +// mergeMangaAuthorCredits mirrors the scanner's mergeEbookPeople semantics: +// the provider authors replace existing author (and stale narrator) credits, +// while every other curated people kind on the item is preserved. +func mergeMangaAuthorCredits(existing []models.ItemPerson, authors []models.ItemPerson) []models.ItemPerson { + merged := make([]models.ItemPerson, 0, len(existing)+len(authors)) + for _, p := range existing { + if p.Kind == models.PersonKindAuthor || p.Kind == models.PersonKindNarrator { + continue + } + p.SortOrder = len(merged) + merged = append(merged, p) + } + for _, a := range authors { + a.SortOrder = len(merged) + merged = append(merged, a) + } + return merged +} + +func filterMangaPeople(people []models.ItemPerson) []models.ItemPerson { + authors := make([]models.ItemPerson, 0, len(people)) + for _, person := range people { + if person.Kind != models.PersonKindAuthor { + continue + } + person.SortOrder = len(authors) + authors = append(authors, person) + } + return authors +} + +func buildMangaSearchQuery(item enrichmentItemRow) (metadata.SearchQuery, map[string]string) { + accumulatedIDs := filterMangaProviderIDs(item.ProviderIDs) + if accumulatedIDs == nil { + accumulatedIDs = map[string]string{} + } + return metadata.SearchQuery{ + Title: item.Title, + Year: item.Year, + ContentType: mangaContentType(), + ProviderIDs: accumulatedIDs, + Language: item.Language, + }, accumulatedIDs +} + +func buildMangaMetadataRequest(providerIDs map[string]string, language string) metadata.MetadataRequest { + return metadata.MetadataRequest{ + ProviderIDs: filterMangaProviderIDs(providerIDs), + ContentType: mangaContentType(), + Language: language, + } +} + +func mergeEnrichmentProviderIDs(dst *metadata.MetadataResult, src *metadata.MetadataResult) { + if src == nil || len(src.ProviderIDs) == 0 { + return + } + if dst.ProviderIDs == nil { + dst.ProviderIDs = make(map[string]string, len(src.ProviderIDs)) + } + for k, v := range filterMangaProviderIDs(src.ProviderIDs) { + if v != "" { + if _, exists := dst.ProviderIDs[k]; !exists { + dst.ProviderIDs[k] = v + } + } + } +} + +func filterMangaProviderIDs(providerIDs map[string]string) map[string]string { + if len(providerIDs) == 0 { + return nil + } + filtered := make(map[string]string, len(providerIDs)) + for provider, providerID := range providerIDs { + provider = strings.TrimSpace(provider) + providerID = strings.TrimSpace(providerID) + if provider == "" || providerID == "" { + continue + } + provider = strings.ToLower(provider) + if isMangaASINProvider(provider) || isInternalMangaProvider(provider) { + continue + } + filtered[provider] = providerID + } + if len(filtered) == 0 { + return nil + } + return filtered +} + +// normalizeMangaStatus maps the varied publication-status strings returned by +// manga metadata providers (AniList: RELEASING/FINISHED/NOT_YET_RELEASED/ +// CANCELLED/HIATUS, MangaDex: ongoing/completed/hiatus/cancelled, and the SDK's +// Continuing/Ended) onto the stable label set the clients render, so the shared +// show_status field carries one consistent manga value-domain instead of raw +// provider casing. Unknown values pass through trimmed so nothing is lost. +func normalizeMangaStatus(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + switch strings.ToLower(strings.ReplaceAll(s, " ", "_")) { + case "ongoing", "releasing", "current", "publishing", "continuing": + return "Ongoing" + case "completed", "finished", "ended": + return "Completed" + case "hiatus", "on_hiatus", "paused": + return "Hiatus" + case "cancelled", "canceled", "discontinued": + return "Cancelled" + case "upcoming", "not_yet_released", "unreleased", "announced": + return "Upcoming" + default: + return s + } +} + +func isMangaASINProvider(provider string) bool { + normalized := strings.ReplaceAll(strings.ReplaceAll(provider, "_", ""), "-", "") + return normalized == "asin" || normalized == "audibleasin" +} + +// isInternalMangaProvider filters Silo-internal identity providers out of the +// metadata flow. The scanner stamps every manga series with a manga_series +// identity row for idempotency; passing it to the plugin made the +// search-skip-when-already-matched guard treat every item as matched, so +// unmatched items went straight to a by-ID fetch with no usable ID and were +// stamped as no-match without a single search. +func isInternalMangaProvider(provider string) bool { + return strings.ReplaceAll(provider, "-", "_") == "manga_series" +} + +func providerIDMapFromRows(rows []*models.MediaItemProviderID) map[string]string { + if len(rows) == 0 { + return nil + } + m := make(map[string]string, len(rows)) + for _, r := range rows { + if r != nil { + for provider, providerID := range filterMangaProviderIDs(map[string]string{ + r.Provider: r.ProviderID, + }) { + m[provider] = providerID + } + } + } + return m +} diff --git a/internal/manga/enrichment_test.go b/internal/manga/enrichment_test.go new file mode 100644 index 00000000..eeef1bd9 --- /dev/null +++ b/internal/manga/enrichment_test.go @@ -0,0 +1,137 @@ +package manga + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" +) + +func TestClaimBatchQueryTargetsManga(t *testing.T) { + if !strings.Contains(claimBatchQuery, "mi.type = 'manga'") { + t.Fatalf("claimBatchQuery must filter type='manga'") + } + if strings.Contains(claimBatchQuery, "'ebook'") { + t.Fatalf("claimBatchQuery must not reference ebook") + } + if !strings.Contains(claimBatchQuery, "manga_enrichment_state") { + t.Fatalf("claimBatchQuery must join manga_enrichment_state") + } + // Secondary-fields arm: enriched items missing a backdrop or publication + // status are claimed too; has_poster/has_backdrop distinguish them so only + // the missing secondary fields are written. + if !strings.Contains(claimBatchQuery, "mi.backdrop_path IS NULL OR mi.backdrop_path = ''") { + t.Fatalf("claimBatchQuery must claim backdrop-missing items") + } + if !strings.Contains(claimBatchQuery, "mi.show_status IS NULL OR mi.show_status = ''") { + t.Fatalf("claimBatchQuery must claim status-missing items") + } + if !strings.Contains(claimBatchQuery, "AS has_poster") { + t.Fatalf("claimBatchQuery must project has_poster") + } + if !strings.Contains(claimBatchQuery, "AS has_backdrop") { + t.Fatalf("claimBatchQuery must project has_backdrop") + } +} + +func TestContentTypeIsManga(t *testing.T) { + if got := mangaContentType(); got != "manga" { + t.Fatalf("mangaContentType() = %q, want %q", got, "manga") + } +} + +// runBatch must keep the three terminal outcomes apart: a stamped no-match is +// neither an enrichment (the old behavior overcounted it as one) nor a +// failure, and only real failures reach recordFailure. +func TestRunBatchSeparatesOutcomes(t *testing.T) { + e := &Enricher{workers: 2} + items := []enrichmentItemRow{ + {ContentID: "enriched-1"}, + {ContentID: "enriched-2"}, + {ContentID: "no-match"}, + {ContentID: "skipped"}, + {ContentID: "failed"}, + } + + var failures int64 + stats := e.runBatch(context.Background(), items, + func(_ context.Context, item enrichmentItemRow) error { + switch item.ContentID { + case "no-match": + return errEnrichmentNoMatch + case "skipped": + return errEnrichmentSkipped + case "failed": + return errors.New("provider exploded") + default: + return nil + } + }, + func(context.Context, enrichmentItemRow) { + atomic.AddInt64(&failures, 1) + }, + ) + + if stats.enriched != 2 { + t.Fatalf("enriched = %d, want 2", stats.enriched) + } + if stats.noMatch != 1 { + t.Fatalf("noMatch = %d, want 1", stats.noMatch) + } + if stats.failed != 1 { + t.Fatalf("failed = %d, want 1", stats.failed) + } + if failures != 1 { + t.Fatalf("recordFailure calls = %d, want 1", failures) + } +} + +// The scanner's manga_series identity rows must never reach the metadata +// flow: they made the search-skip guard treat every item as already matched. +func TestFilterMangaProviderIDsDropsInternalIdentity(t *testing.T) { + filtered := filterMangaProviderIDs(map[string]string{ + "manga_series": "abc123", + "anilist": "42", + "asin": "B000", + }) + if _, ok := filtered["manga_series"]; ok { + t.Fatalf("manga_series identity must be filtered, got %v", filtered) + } + if filtered["anilist"] != "42" { + t.Fatalf("anilist id must survive, got %v", filtered) + } + if len(filtered) != 1 { + t.Fatalf("filtered = %v, want only anilist", filtered) + } +} + +func TestNormalizeMangaStatus(t *testing.T) { + cases := map[string]string{ + // AniList enum + "RELEASING": "Ongoing", + "FINISHED": "Completed", + "NOT_YET_RELEASED": "Upcoming", + "CANCELLED": "Cancelled", + "HIATUS": "Hiatus", + // MangaDex / lowercase + "ongoing": "Ongoing", + "completed": "Completed", + "hiatus": "Hiatus", + "cancelled": "Cancelled", + // SDK Continuing/Ended + spacing/casing variants + "Continuing": "Ongoing", + "Ended": "Completed", + "on hiatus": "Hiatus", + " Upcoming ": "Upcoming", + // Empty and unknown pass through (trimmed) + "": "", + " ": "", + "Weird-Val": "Weird-Val", + } + for in, want := range cases { + if got := normalizeMangaStatus(in); got != want { + t.Fatalf("normalizeMangaStatus(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/metadata/merge.go b/internal/metadata/merge.go index fbeb65bc..b06c2e11 100644 --- a/internal/metadata/merge.go +++ b/internal/metadata/merge.go @@ -49,6 +49,7 @@ func MergeMetadata(source, target *MetadataResult, locked []MetadataField, mode mergeInt(&target.SeasonCount, source.SeasonCount, mode) mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode) mergeScalar(&target.LastAirDate, source.LastAirDate, mode) + mergeScalar(&target.ShowStatus, source.ShowStatus, mode) if !isLocked(FieldAirSchedule) { mergeScalar(&target.AirTime, source.AirTime, mode) mergeScalar(&target.AirTimezone, source.AirTimezone, mode) @@ -117,6 +118,7 @@ func MergeGlobalMetadata(source, target *MetadataResult, locked []MetadataField, mergeInt(&target.SeasonCount, source.SeasonCount, mode) mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode) mergeScalar(&target.LastAirDate, source.LastAirDate, mode) + mergeScalar(&target.ShowStatus, source.ShowStatus, mode) if !isLocked(FieldAirSchedule) { mergeScalar(&target.AirTime, source.AirTime, mode) mergeScalar(&target.AirTimezone, source.AirTimezone, mode) diff --git a/internal/metadata/plugin_provider.go b/internal/metadata/plugin_provider.go index 3da5b86f..80be70f4 100644 --- a/internal/metadata/plugin_provider.go +++ b/internal/metadata/plugin_provider.go @@ -257,6 +257,7 @@ func (p *PluginProvider) GetMetadata(ctx context.Context, req MetadataRequest) ( PosterPath: response.GetItem().GetPosterPath(), PosterThumbhash: response.GetItem().GetPosterThumbhash(), BackdropPath: response.GetItem().GetBackdropPath(), + ShowStatus: response.GetItem().GetStatus(), BackdropThumbhash: response.GetItem().GetBackdropThumbhash(), LogoPath: response.GetItem().GetLogoPath(), SeasonCount: int(response.GetItem().GetSeasonCount()), diff --git a/internal/metadata/types.go b/internal/metadata/types.go index cb7e433b..a5f05d4d 100644 --- a/internal/metadata/types.go +++ b/internal/metadata/types.go @@ -180,6 +180,9 @@ type MetadataResult struct { LastAirDate string AirTime string AirTimezone string + // ShowStatus is the publication/airing status ("Ongoing", "Completed", + // "Continuing", "Ended") when the provider reports one. + ShowStatus string } // Ratings holds ratings from multiple sources. diff --git a/internal/models/media.go b/internal/models/media.go index ed196ab1..e3d722af 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -320,6 +320,8 @@ type MediaItem struct { MetadataS3Path string MetadataEtag string SeasonCount *int // series only + MangaChapterCount *int // manga series only: loose manga_chapters rows without a volume token + MangaVolumeCount *int // manga series only: distinct non-empty volume tokens in manga_chapters Studios []string Networks []string Countries []string diff --git a/internal/scanner/manga_chapters_repo.go b/internal/scanner/manga_chapters_repo.go new file mode 100644 index 00000000..c42f4911 --- /dev/null +++ b/internal/scanner/manga_chapters_repo.go @@ -0,0 +1,65 @@ +package scanner + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// mangaChapterWrite turns a parsed (volume, index, has) into the (index, volume) +// values to persist: index is nil when has=false, volume is "" when absent. +func mangaChapterWrite(volume string, index float64, has bool) (idx *float64, vol string) { + if !has { + return nil, "" + } + i := index + return &i, volume +} + +// upsertMangaChapter inserts or updates a row in manga_chapters for the given +// chapter. A nil index is stored as NULL (chapter number unparseable or absent). +func upsertMangaChapter(ctx context.Context, pool *pgxpool.Pool, chapterID, seriesID string, index *float64, volume string) error { + _, err := pool.Exec(ctx, ` + INSERT INTO manga_chapters (chapter_content_id, series_content_id, chapter_index, volume, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (chapter_content_id) DO UPDATE SET + series_content_id = EXCLUDED.series_content_id, + chapter_index = EXCLUDED.chapter_index, + volume = EXCLUDED.volume, + updated_at = NOW() + `, chapterID, seriesID, index, volume) + if err != nil { + return fmt.Errorf("upsert manga_chapters row: %w", err) + } + return nil +} + +// listMangaChapters returns the chapter_content_id values for all chapters +// belonging to the given series, ordered by chapter_index (NULLs last) then +// by content ID for a stable secondary sort. +func listMangaChapters(ctx context.Context, pool *pgxpool.Pool, seriesID string) ([]string, error) { + rows, err := pool.Query(ctx, ` + SELECT chapter_content_id + FROM manga_chapters + WHERE series_content_id = $1 + ORDER BY chapter_index NULLS LAST, chapter_content_id + `, seriesID) + if err != nil { + return nil, fmt.Errorf("list manga_chapters: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan manga_chapters row: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate manga_chapters: %w", err) + } + return ids, nil +} diff --git a/internal/scanner/manga_chapters_repo_test.go b/internal/scanner/manga_chapters_repo_test.go new file mode 100644 index 00000000..9beeba55 --- /dev/null +++ b/internal/scanner/manga_chapters_repo_test.go @@ -0,0 +1,18 @@ +package scanner + +import "testing" + +func TestMangaChapterWrite(t *testing.T) { + idx, vol := mangaChapterWrite("v13", 13, true) + if idx == nil || *idx != 13 || vol != "v13" { + t.Fatalf("has=true: got (%v,%q), want (13,\"v13\")", idx, vol) + } + idx, vol = mangaChapterWrite("", 178, true) + if idx == nil || *idx != 178 || vol != "" { + t.Fatalf("has=true no vol: got (%v,%q), want (178,\"\")", idx, vol) + } + idx, vol = mangaChapterWrite("", 0, false) + if idx != nil || vol != "" { + t.Fatalf("has=false: got (%v,%q), want (nil,\"\")", idx, vol) + } +} diff --git a/internal/scanner/manga_parse.go b/internal/scanner/manga_parse.go new file mode 100644 index 00000000..0a137662 --- /dev/null +++ b/internal/scanner/manga_parse.go @@ -0,0 +1,136 @@ +package scanner + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// mangaSeriesWhitespace collapses any run of whitespace to a single space so a +// series name keys identically regardless of incidental spacing. +var mangaSeriesWhitespace = regexp.MustCompile(`\s+`) + +// mangaTrailingParen matches a single trailing parenthetical group, allowing +// optional whitespace before it. Applied repeatedly to strip all trailing +// groups (year, year-range, "Digital", release-group names, etc.). +var mangaTrailingParen = regexp.MustCompile(`\s*\([^)]*\)\s*$`) + +// cleanMangaSeriesName removes all trailing parenthetical groups (scene-release +// metadata such as years, "Digital", and release-group tags) from a manga +// folder name, then trims any dangling whitespace or trailing " -". +// +// Parentheticals in the middle of the name are left untouched so titles like +// "JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra" are +// preserved. The function is pure and idempotent. If stripping would produce +// an empty string the original trimmed input is returned unchanged so a series +// name is never empty. +func cleanMangaSeriesName(name string) string { + s := strings.TrimSpace(name) + for { + stripped := mangaTrailingParen.ReplaceAllString(s, "") + if stripped == s { + break + } + s = stripped + } + // Trim any trailing dash (with optional surrounding spaces) left after + // stripping, e.g. "Series Name - (Digital)" → "Series Name -" → "Series Name". + s = strings.TrimRight(s, " -") + s = strings.TrimSpace(s) + if s == "" { + return strings.TrimSpace(name) + } + return s +} + +// mangaSeriesGroupKey is the stable, library-scoped content-group key that all +// chapters of one series resolve their series item by. It lowercases, trims, +// and collapses internal whitespace so cosmetic variations of the same folder +// name yield the same key. Returns "" for an empty name (caller must skip). +func mangaSeriesGroupKey(folderID int, name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + normalized = strings.TrimSpace(mangaSeriesWhitespace.ReplaceAllString(normalized, " ")) + if normalized == "" { + return "" + } + return fmt.Sprintf("manga:series:%d:%s", folderID, normalized) +} + +// mangaVolumeFolder matches directory names that are volume markers, not series. +var mangaVolumeFolder = regexp.MustCompile(`(?i)^v(?:ol(?:ume)?\.?)?\s*\d+$`) + +var ( + mangaVolYearIssue = regexp.MustCompile(`(?i)\b(Vol\.?\s*\d{4})\b.*?#\s*(\d+(?:\.\d+)?)`) + mangaVolYearLabel = regexp.MustCompile(`(?i)\bvol\.?\s*\d{4}\b`) // strip a year-style "Vol.YYYY" so it never reads as an index + // mangaVolume / mangaChapterC only match the abbreviated forms (v13, vol.4, c128, ch.5). + // Full English words ("volume 3", "chapter 5") intentionally fall through to the bare-number path. + mangaVolume = regexp.MustCompile(`(?i)\bv(?:ol\.?)?\s*(\d+(?:\.\d+)?)\b`) + mangaChapterC = regexp.MustCompile(`(?i)\bc(?:h\.?)?\s*(\d+(?:\.\d+)?)\b`) + mangaBareNumber = regexp.MustCompile(`\b(\d+(?:\.\d+)?)\b`) + mangaParenNoise = regexp.MustCompile(`\([^)]*\)`) // (year) (Digital) (group) (Month, Year) +) + +// mangaSeriesFromPath returns the series name: the nearest ancestor directory of +// the file whose name is not a volume marker. +func mangaSeriesFromPath(filePath string) string { + dir := filepath.Dir(filePath) + for dir != "" && dir != "." && dir != string(filepath.Separator) { + base := filepath.Base(dir) + if !mangaVolumeFolder.MatchString(strings.TrimSpace(base)) { + return cleanMangaSeriesName(base) + } + dir = filepath.Dir(dir) + } + return "" +} + +// mangaIndexForFile parses the volume/chapter index from a manga file's base +// name (extension already stripped), first removing the series-name prefix so +// numbers inside the series title (e.g. "404 Demons", "365 Days") are not +// mistaken for the chapter/volume number. Falls back to the full base name when +// the file does not start with the series name. +func mangaIndexForFile(base, seriesName string) (volume string, index float64, has bool) { + trimmedBase := strings.TrimSpace(base) + trimmedSeries := strings.TrimSpace(seriesName) + if trimmedSeries != "" && strings.HasPrefix(strings.ToLower(trimmedBase), strings.ToLower(trimmedSeries)) { + remainder := trimmedBase[len(trimmedSeries):] + return parseMangaIndex(remainder) + } + return parseMangaIndex(trimmedBase) +} + +// parseMangaIndex extracts the ordering index (volume or chapter number) and the +// raw volume token from a manga release filename (extension already stripped). +// Returns has=false when no number is present (e.g. a one-shot). +// +// The returned volume is a display token (e.g. "v13" or "Vol.2003") and is not +// normalized across forms; callers should treat it as label text, not a key. +func parseMangaIndex(name string) (volume string, index float64, has bool) { + if m := mangaVolYearIssue.FindStringSubmatch(name); m != nil { + if n, err := strconv.ParseFloat(m[2], 64); err == nil { + return "v" + strings.TrimSpace(m[2]), n, true + } + } + clean := strings.TrimSpace(mangaParenNoise.ReplaceAllString(name, " ")) + // A bare "Vol.YYYY" (no "#issue") is a year, not an index — strip it so it + // never leaks into the volume/chapter/bare-number scans below. + clean = mangaVolYearLabel.ReplaceAllString(clean, " ") + if m := mangaVolume.FindStringSubmatch(clean); m != nil { + if n, err := strconv.ParseFloat(m[1], 64); err == nil { + return "v" + m[1], n, true + } + } + if m := mangaChapterC.FindStringSubmatch(clean); m != nil { + if n, err := strconv.ParseFloat(m[1], 64); err == nil { + return "", n, true + } + } + if m := mangaBareNumber.FindStringSubmatch(clean); m != nil { + if n, err := strconv.ParseFloat(m[1], 64); err == nil { + return "", n, true + } + } + return "", 0, false +} diff --git a/internal/scanner/manga_parse_test.go b/internal/scanner/manga_parse_test.go new file mode 100644 index 00000000..3c2bcf55 --- /dev/null +++ b/internal/scanner/manga_parse_test.go @@ -0,0 +1,243 @@ +package scanner + +import "testing" + +func TestMangaSeriesFromPath(t *testing.T) { + cases := []struct { + path string + want string + }{ + {"/m/manga/Official/Kurosagi Corpse Delivery Service/V2006/Kurosagi 10.cbz", "Kurosagi Corpse Delivery Service"}, + {"/m/manga/One-Punch Man/One-Punch Man 178 (2023) (Digital) (LuCaZ).cbz", "One-Punch Man"}, + {"/m/manga/Bakuman/v13/Bakuman v13 (2012).cbz", "Bakuman"}, + } + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { + if got := mangaSeriesFromPath(tc.path); got != tc.want { + t.Fatalf("mangaSeriesFromPath(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} + +func TestMangaSeriesGroupKey(t *testing.T) { + a := mangaSeriesGroupKey(8, "One-Punch Man") + b := mangaSeriesGroupKey(8, " one-punch man ") + c := mangaSeriesGroupKey(8, "Bakuman") + d := mangaSeriesGroupKey(9, "One-Punch Man") + if a == "" || a != b { + t.Fatalf("same series must yield same key: %q vs %q", a, b) + } + if a == c || a == d { + t.Fatalf("different series/library must differ: a=%q c=%q d=%q", a, c, d) + } +} + +func TestParseMangaIndex(t *testing.T) { + cases := []struct { + name string + file string + wantVol string + wantIdx float64 + wantHas bool + }{ + {"bare chapter", "One-Punch Man 178 (2023) (Digital) (LuCaZ)", "", 178, true}, + {"volume", "Bakuman v13 (2012) (Digital) (aKraa)", "v13", 13, true}, + {"chapter c-prefix", "Dead Mount Death Play c128 (2025) (Digital) (UP!) (Oak)", "", 128, true}, + {"vol-year issue", "Berserk Vol.2003 #04 (July, 2004)", "v04", 4, true}, + {"vol-year issue real-world", "10 Things I Want to Do Before I Turn 40 Vol.2025 #01 (May, 2025)", "v01", 1, true}, + {"vol-year no issue", "Berserk Vol.2003 (2004)", "", 0, false}, + {"decimal chapter", "Kindergarten WARS 109.1 (2025) (Digital) (Rillant)", "", 109.1, true}, + {"subtitle then volume", "The Ancient Magus' Bride - Wizard's Blue v04 (2022) (Digital)", "v04", 4, true}, + {"no number", "Some Oneshot (2020) (Digital) (grp)", "", 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + vol, idx, has := parseMangaIndex(tc.file) + if vol != tc.wantVol || has != tc.wantHas || idx != tc.wantIdx { + t.Fatalf("parseMangaIndex(%q) = (%q,%v,%v), want (%q,%v,%v)", tc.file, vol, idx, has, tc.wantVol, tc.wantIdx, tc.wantHas) + } + }) + } +} + +func TestCleanMangaSeriesName(t *testing.T) { + cases := []struct { + input string + want string + }{ + // Trailing parentheticals stripped. + {"404 Demons (Digital) (Oak)", "404 Demons"}, + {"Arifureta - From Commonplace to World's Strongest (Digital) (1r0n)", "Arifureta - From Commonplace to World's Strongest"}, + {"Angels of Death Episode.0 (2019-2024) (Digital) (LuCaZ)", "Angels of Death Episode.0"}, + {"Angel of the Night - Lucian - One-shot (2026) (Digital)", "Angel of the Night - Lucian - One-shot"}, + {"'Tis Time for 'Torture,' Princess (2019-2026) (Digital) (Antrill-Oak)", "'Tis Time for 'Torture,' Princess"}, + {"A Certain Scientific Railgun - Astral Buddy (2019)", "A Certain Scientific Railgun - Astral Buddy"}, + // No junk — must be returned unchanged. + {"Amefurashi", "Amefurashi"}, + // Guardrail: folder that is ONLY parentheticals — return original trimmed input. + {"(2025) (Digital)", "(2025) (Digital)"}, + // Middle parentheticals must be preserved. + {"JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra", "JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := cleanMangaSeriesName(tc.input) + if got != tc.want { + t.Fatalf("cleanMangaSeriesName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestMangaIndexForFile(t *testing.T) { + cases := []struct { + name string + base string + seriesName string + wantVol string + wantIdx float64 + wantHas bool + }{ + { + name: "404 Demons ch01 — series prefix stripped", + base: "404 Demons 01 (Digital-Compilation) (Oak)", + seriesName: "404 Demons", + wantVol: "", + wantIdx: 1, + wantHas: true, + }, + { + name: "404 Demons ch09", + base: "404 Demons 09 (Digital-Compilation) (Oak)", + seriesName: "404 Demons", + wantVol: "", + wantIdx: 9, + wantHas: true, + }, + { + name: "404 Demons v01 — volume token unambiguous", + base: "404 Demons v01 (Digital-Compilation) (Oak)", + seriesName: "404 Demons", + wantVol: "v01", + wantIdx: 1, + wantHas: true, + }, + { + name: "404 Demons v10", + base: "404 Demons v10 (Digital-Compilation) (Oak)", + seriesName: "404 Demons", + wantVol: "v10", + wantIdx: 10, + wantHas: true, + }, + { + name: "One-Punch Man ch178", + base: "One-Punch Man 178 (2023) (Digital) (LuCaZ)", + seriesName: "One-Punch Man", + wantVol: "", + wantIdx: 178, + wantHas: true, + }, + { + name: "365 Days to the Wedding v03 — series number not grabbed", + base: "365 Days to the Wedding v03 (2024)", + seriesName: "365 Days to the Wedding", + wantVol: "v03", + wantIdx: 3, + wantHas: true, + }, + { + name: "404 Demons one-shot — no number after prefix", + base: "404 Demons (2025) (Digital)", + seriesName: "404 Demons", + wantVol: "", + wantIdx: 0, + wantHas: false, + }, + { + name: "fallback — base does not start with series name", + base: "Random 12", + seriesName: "Different Series", + wantVol: "", + wantIdx: 12, + wantHas: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + vol, idx, has := mangaIndexForFile(tc.base, tc.seriesName) + if vol != tc.wantVol || idx != tc.wantIdx || has != tc.wantHas { + t.Fatalf("mangaIndexForFile(%q, %q) = (%q, %v, %v), want (%q, %v, %v)", + tc.base, tc.seriesName, vol, idx, has, tc.wantVol, tc.wantIdx, tc.wantHas) + } + }) + } +} + +// TestParseMangaIndexCorpus is a regression test against real-world manga +// release filenames following common scanlation naming conventions. +// Extensions are already stripped (as parseMangaIndex expects). +// At least 95% of names must yield has==true; pure one-shots with no number +// are the only legitimate misses. +func TestParseMangaIndexCorpus(t *testing.T) { + corpus := []string{ + // bare chapter numbers (most common pattern) + "One-Punch Man 178 (2023) (Digital) (LuCaZ)", + "One-Punch Man 001 (2012) (Digital) (LuCaZ)", + "Attack on Titan 139 (2021) (Digital) (Chromatic)", + "Chainsaw Man 097 (2021) (Digital) (LuCaZ)", + "Chainsaw Man 001 (2019) (Digital) (LuCaZ)", + "Spy x Family 090 (2024) (Digital) (Izar)", + "Demon Slayer - Kimetsu no Yaiba 205 (2020) (Digital) (LuCaZ)", + "My Hero Academia 430 (2024) (Digital) (LuCaZ)", + "Jujutsu Kaisen 271 (2024) (Digital) (LuCaZ)", + "Vinland Saga 215 (2024) (Digital) (dAY)", + // decimal chapter numbers + "Kindergarten WARS 109.1 (2025) (Digital) (Rillant)", + "Bleach 686.5 (2016) (Digital) (LuCaZ)", + "One Piece 1000.1 (2021) (Digital) (LuCaZ)", + "Berserk 364.1 (2022) (Digital) (Oak)", + // volume prefix (vNN form) + "Bakuman v13 (2012) (Digital) (aKraa)", + "Fullmetal Alchemist v27 (2011) (Digital) (Izar)", + "Death Note v12 (2006) (Digital) (Chromatic)", + "Vinland Saga v26 (2022) (Digital) (dAY)", + "The Ancient Magus' Bride - Wizard's Blue v04 (2022) (Digital)", + "Blue Period v14 (2023) (Digital) (LuCaZ)", + // volume prefix (vol. form) + "Dragon Ball Vol.001 (2003) (Digital) (Izar)", + "Naruto Vol.072 (2014) (Digital) (Chromatic)", + "Bleach Vol.074 (2016) (Digital) (LuCaZ)", + // chapter c-prefix + "Dead Mount Death Play c128 (2025) (Digital) (UP!) (Oak)", + "To Your Eternity c185 (2024) (Digital) (LuCaZ)", + "Kaiju No. 8 ch.100 (2024) (Digital) (Izar)", + // zero-padded chapter numbers + "Berserk 001 (1990) (Digital) (Scans)", + "Berserk 364 (2021) (Digital) (Oak)", + "Vagabond 327 (2015) (Digital) (LuCaZ)", + // series with hyphens and special chars in name + "One-Punch Man 001 (2012) (Digital) (LuCaZ)", + "Fullmetal Alchemist - Brotherhood 064 (2010) (Digital) (Izar)", + "JoJo's Bizarre Adventure - Part 8 - JoJolion 110 (2021) (Digital) (Chromatic)", + // high chapter numbers + "One Piece 1100 (2023) (Digital) (LuCaZ)", + "Fairy Tail 545 (2017) (Digital) (Chromatic)", + // two-digit volumes + "Berserk v41 (2022) (Digital) (Oak)", + "Vagabond v37 (2009) (Digital) (LuCaZ)", + } + + misses := 0 + for _, name := range corpus { + if _, _, has := parseMangaIndex(name); !has { + misses++ + t.Logf("no index parsed: %q", name) + } + } + // Allow a small fraction of legitimate one-shots with no number. + if float64(misses)/float64(len(corpus)) > 0.05 { + t.Fatalf("parser missed %d/%d (>5%%); investigate patterns above", misses, len(corpus)) + } +} diff --git a/internal/scanner/manga_scan.go b/internal/scanner/manga_scan.go new file mode 100644 index 00000000..b609bb12 --- /dev/null +++ b/internal/scanner/manga_scan.go @@ -0,0 +1,404 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/titleutil" + "github.com/jackc/pgx/v5" +) + +// ScanMangaFolder scans a manga library. It is a fork of ScanEbookFolder: the +// chapter files are kept as readable type='ebook' items exactly as the ebook +// pipeline does, while each file additionally find-or-creates a single +// type='manga' series item per series folder and links the chapter to it. +func (s *Scanner) ScanMangaFolder(ctx context.Context, folder *models.MediaFolder) error { + if s == nil || folder == nil { + return fmt.Errorf("ScanMangaFolder: nil scanner or folder") + } + return s.scanMangaPaths(ctx, folder, folder.Paths, true) +} + +// scanMangaPaths mirrors scanEbookPaths exactly (root collection, worker pool, +// missing-file reconciliation) but dispatches each file to reconcileMangaFile. +func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder, roots []string, fullScan bool) error { + if s == nil || folder == nil { + return fmt.Errorf("scanMangaPaths: nil scanner or folder") + } + scans, err := collectEbookRootScans(ctx, folder.ID, roots) + if err != nil { + return err + } + // Every discovered file is indexed, including files under roots whose walk + // partially failed: indexing is additive and safe, only the destructive + // reconciliation below is restricted to cleanly walked roots. + var candidates []string + for i := range scans { + candidates = append(candidates, scans[i].files...) + } + + if len(candidates) == 0 { + return s.reconcileMangaScan(ctx, folder, scans, nil, fullScan) + } + + workers := ebookScanWorkers() + slog.Info("manga scan: starting", + "folder_id", folder.ID, + "candidates", len(candidates), + "workers", workers, + ) + reportEbookScanProgress(ctx, folder.ID, len(candidates), 0, 0, 0) + + ch := make(chan string, workers*2) + groupLocks := newEbookGroupLocks() + var ( + wg sync.WaitGroup + processed int64 + failed int64 + skipped int64 + failMu sync.Mutex + failures []error + cancelErr error + ) + start := time.Now() + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for path := range ch { + if ctx.Err() != nil { + return + } + if err := s.reconcileMangaFile(ctx, folder, path, &skipped, groupLocks); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + failMu.Lock() + if cancelErr == nil { + cancelErr = err + } + failMu.Unlock() + return + } + atomic.AddInt64(&failed, 1) + failMu.Lock() + failures = append(failures, fmt.Errorf("%s: %w", path, err)) + failMu.Unlock() + slog.Warn("manga scan: file failed", + "folder_id", folder.ID, + "path", path, + "error", err, + ) + } + n := atomic.AddInt64(&processed, 1) + if n%500 == 0 || n == int64(len(candidates)) { + failedCount := atomic.LoadInt64(&failed) + skippedCount := atomic.LoadInt64(&skipped) + slog.Info("manga scan: progress", + "folder_id", folder.ID, + "processed", n, + "failed", failedCount, + "skipped", skippedCount, + "total", len(candidates), + "elapsed_sec", int(time.Since(start).Seconds()), + ) + reportEbookScanProgress(ctx, folder.ID, len(candidates), int(n), int(failedCount), int(skippedCount)) + } + } + }() + } + + for _, p := range candidates { + select { + case ch <- p: + case <-ctx.Done(): + close(ch) + wg.Wait() + return ctx.Err() + } + } + close(ch) + wg.Wait() + if err := ctx.Err(); err != nil { + return err + } + if cancelErr != nil { + return cancelErr + } + + slog.Info("manga scan: completed", + "folder_id", folder.ID, + "processed", atomic.LoadInt64(&processed), + "failed", atomic.LoadInt64(&failed), + "skipped", atomic.LoadInt64(&skipped), + "elapsed_sec", int(time.Since(start).Seconds()), + ) + if processedCount := atomic.LoadInt64(&processed); processedCount > 0 { + failedCount := atomic.LoadInt64(&failed) + skippedCount := atomic.LoadInt64(&skipped) + if failedCount > 0 && skippedCount == 0 && failedCount == processedCount { + return fmt.Errorf("manga scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...)) + } + } + + seenPaths := make(map[string]bool, len(candidates)) + for _, p := range candidates { + seenPaths[p] = true + } + return s.reconcileMangaScan(ctx, folder, scans, seenPaths, fullScan) +} + +// reconcileMangaScan runs the shared ebook missing-file reconciliation (which +// removes vanished chapters) and then deletes any type='manga' series left with +// no chapters. Series items are file-less parents reconciled by chapter count, +// not file presence — catalog.ReconcileFolderMembership deliberately skips them. +func (s *Scanner) reconcileMangaScan(ctx context.Context, folder *models.MediaFolder, scans []ebookRootScan, seenPaths map[string]bool, fullScan bool) error { + if err := s.reconcileEbookScan(ctx, folder, scans, seenPaths, fullScan); err != nil { + return err + } + return s.deleteOrphanedMangaSeries(ctx, folder.ID) +} + +// deleteOrphanedMangaSeries removes type='manga' series items in the folder that +// have no remaining linked chapters (e.g. once every chapter was deleted as +// missing). The cascade clears the now-empty library membership. +func (s *Scanner) deleteOrphanedMangaSeries(ctx context.Context, folderID int) error { + if s == nil || s.fileRepo == nil { + return nil + } + tag, err := s.fileRepo.Pool().Exec(ctx, ` + DELETE FROM media_items mi + WHERE mi.type = 'manga' + AND EXISTS ( + SELECT 1 FROM media_item_libraries mil + WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $1 + ) + AND NOT EXISTS ( + SELECT 1 FROM manga_chapters mc WHERE mc.series_content_id = mi.content_id + ) + `, folderID) + if err != nil { + return fmt.Errorf("deleting orphaned manga series for folder %d: %w", folderID, err) + } + if n := tag.RowsAffected(); n > 0 { + slog.Info("manga scan: removed orphaned series", "folder_id", folderID, "deleted", n) + } + return nil +} + +// reconcileMangaFile indexes one .cbz/.cbr chapter file: it keeps the file as a +// readable type='ebook' chapter item (exactly as reconcileEbookFile does), then +// find-or-creates the single type='manga' series item for the chapter's series +// folder and links the chapter to it with its parsed index/volume. +func (s *Scanner) reconcileMangaFile(ctx context.Context, folder *models.MediaFolder, filePath string, skipped *int64, groupLocks *ebookGroupLocks) error { + info, err := os.Stat(filePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat manga file %s: %w", filePath, err) + } + size := info.Size() + modifiedAt := normalizeFileModifiedAt(info.ModTime()) + + _, isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt) + if skipErr != nil { + slog.Warn("manga scan: skip-check failed, falling through", + "folder_id", folder.ID, + "path", filePath, + "error", skipErr, + ) + } else if isUnchanged { + atomic.AddInt64(skipped, 1) + return nil + } + + parsed, err := parseEbookFile(filePath) + if err != nil { + return fmt.Errorf("parse manga file %s: %w", filePath, err) + } + if parsed.Title == "" { + parsed.Title = ebookTitleFromPath(filePath) + } + + seriesName := mangaSeriesFromPath(filePath) + if seriesName == "" { + seriesName = ebookTitleFromPath(filePath) + } + stem := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + vol, idx, has := mangaIndexForFile(stem, seriesName) + + // 1. Keep the file as a readable type='ebook' chapter item, exactly as the + // ebook pipeline does (cover + page count + media file + membership). + chapterGroupKey := ebookContentGroupKey(&parsed, filePath) + chapterID, err := func() (string, error) { + unlock := groupLocks.lock(chapterGroupKey) + defer unlock() + + contentID, curated, err := s.upsertEbookMediaItem(ctx, folder.ID, filePath, &parsed, chapterGroupKey) + if err != nil { + return "", fmt.Errorf("upsert manga chapter item: %w", err) + } + if err := s.upsertEbookMediaFile(ctx, folder, contentID, filePath, size, modifiedAt, &parsed, chapterGroupKey); err != nil { + return "", fmt.Errorf("upsert manga chapter file: %w", err) + } + if err := applyEbookLocalCover(ctx, s.itemRepo, s.imageCacher, contentID, filePath, &parsed); err != nil { + slog.Warn("manga scan: local cover upload failed", + "folder_id", folder.ID, + "content_id", contentID, + "path", filePath, + "error", err, + ) + } + if err := s.upsertEbookPeople(ctx, contentID, &parsed, curated); err != nil { + return "", fmt.Errorf("upsert manga chapter people: %w", err) + } + if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), contentID, folder.ID); err != nil { + return "", fmt.Errorf("upsert manga chapter library membership: %w", err) + } + return contentID, nil + }() + if err != nil { + return err + } + + // 2. Find-or-create the single type='manga' series item for this folder and + // link the chapter to it. + seriesID, err := s.findOrCreateMangaSeries(ctx, folder.ID, seriesName, groupLocks) + if err != nil { + return fmt.Errorf("find-or-create manga series: %w", err) + } + if seriesID != "" { + // The series item carries no media file of its own, so it must be given a + // library membership explicitly (the chapter path gets this via its file + // reconcile). Without it the library-scoped catalog browse, which joins + // media_item_libraries, would never surface the series card. The insert is + // ON CONFLICT DO NOTHING, so re-scans never duplicate the membership. + if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), seriesID, folder.ID); err != nil { + return fmt.Errorf("upsert manga series library membership: %w", err) + } + idxPtr, volOut := mangaChapterWrite(vol, idx, has) + if err := upsertMangaChapter(ctx, s.fileRepo.Pool(), chapterID, seriesID, idxPtr, volOut); err != nil { + return fmt.Errorf("link manga chapter to series: %w", err) + } + } + + slog.Debug("manga scan: indexed", + "folder_id", folder.ID, + "chapter_id", chapterID, + "series_id", seriesID, + "series", seriesName, + "path", filePath, + ) + return nil +} + +// mangaSeriesProvider is the provider namespace under which a manga series +// item's content-group key is recorded in media_item_provider_ids. The table's +// UNIQUE (provider, provider_id, item_type) constraint guarantees exactly one +// type='manga' series item per group key, which is what makes re-scans +// idempotent across processes. +const mangaSeriesProvider = "manga_series" + +// findOrCreateMangaSeries resolves the single type='manga' series item for the +// given series name in the folder, creating it on first sight. It is idempotent: +// re-scanning any chapter of the same series resolves to the same series +// content_id. The group-key lock serializes creation across this process's +// worker goroutines; the SELECT-after-conflicting-INSERT recovers the winner's +// content_id if another process raced us. +func (s *Scanner) findOrCreateMangaSeries(ctx context.Context, folderID int, seriesName string, groupLocks *ebookGroupLocks) (string, error) { + if s.itemRepo == nil { + return "", fmt.Errorf("itemRepo not configured on Scanner") + } + if s.fileRepo == nil { + return "", fmt.Errorf("fileRepo not configured on Scanner") + } + groupKey := mangaSeriesGroupKey(folderID, seriesName) + if groupKey == "" { + return "", nil + } + + unlock := groupLocks.lock(groupKey) + defer unlock() + + if existing, err := s.lookupMangaSeries(ctx, groupKey); err != nil { + return "", err + } else if existing != "" { + return existing, nil + } + + id, err := idgen.NextID() + if err != nil { + return "", fmt.Errorf("generate manga series content_id: %w", err) + } + title := strings.TrimSpace(seriesName) + item := &models.MediaItem{ + ContentID: id, + Type: "manga", + // Explicit "pending" mirrors the ebook chapter path: enrichment promotes + // it to "matched". This is the "needs metadata" status. + Status: "pending", + Title: title, + SortTitle: titleutil.DeriveDefaultSortTitle(title), + } + if err := s.itemRepo.Upsert(ctx, item); err != nil { + return "", fmt.Errorf("create manga series item: %w", err) + } + + tag, err := s.fileRepo.Pool().Exec(ctx, ` + INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type) + VALUES ($1, $2, $3, 'manga') + ON CONFLICT (provider, provider_id, item_type) DO NOTHING + `, id, mangaSeriesProvider, groupKey) + if err != nil { + return "", fmt.Errorf("record manga series key: %w", err) + } + if tag.RowsAffected() == 0 { + // Another process created the series first; our freshly minted item is a + // dangling orphan. Delete it and adopt the winner so no duplicate series + // survives. + winner, lookupErr := s.lookupMangaSeries(ctx, groupKey) + if lookupErr != nil { + return "", lookupErr + } + if winner != "" && winner != id { + if _, delErr := s.fileRepo.Pool().Exec(ctx, + `DELETE FROM media_items WHERE content_id = $1`, id); delErr != nil { + slog.Warn("manga scan: failed to delete duplicate series item", + "folder_id", folderID, + "content_id", id, + "error", delErr, + ) + } + return winner, nil + } + } + return id, nil +} + +// lookupMangaSeries returns the content_id of the type='manga' series item +// already recorded for the group key, or "" if none exists. +func (s *Scanner) lookupMangaSeries(ctx context.Context, groupKey string) (string, error) { + var id string + err := s.fileRepo.Pool().QueryRow(ctx, ` + SELECT content_id + FROM media_item_provider_ids + WHERE provider = $1 AND provider_id = $2 AND item_type = 'manga' + LIMIT 1 + `, mangaSeriesProvider, groupKey).Scan(&id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("lookup manga series by key: %w", err) + } + return id, nil +} diff --git a/internal/scanner/probe_repair.go b/internal/scanner/probe_repair.go index e62d1011..ba2f15fc 100644 --- a/internal/scanner/probe_repair.go +++ b/internal/scanner/probe_repair.go @@ -14,6 +14,14 @@ func NeedsCriticalProbeRepair(file *models.MediaFile) bool { if file == nil { return true } + // Ebook/comic files (epub, pdf, cbz, cbr — including manga chapters, which + // are BaseType "ebook") are read directly by the reader and never go through + // the transcode/playback probe pipeline. ffprobe yields nothing useful for + // them, so requiring probe metadata re-ran ffprobe on every detail/watch + // load and never converged. + if file.BaseType == "ebook" { + return false + } if strings.TrimSpace(file.ProbeSource) == "" || file.ProbeUpdatedAt == nil { return true } diff --git a/internal/scanner/probe_repair_ebook_test.go b/internal/scanner/probe_repair_ebook_test.go new file mode 100644 index 00000000..92567100 --- /dev/null +++ b/internal/scanner/probe_repair_ebook_test.go @@ -0,0 +1,28 @@ +package scanner + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// Ebook/comic files (epub, pdf, cbz, cbr — including manga chapters, which are +// BaseType "ebook") are read directly and never carry ffprobe playback +// metadata. Treating them as needing repair re-ran ffprobe on every detail +// load and never converged. +func TestNeedsCriticalProbeRepair_EbookFileNeverNeedsRepair(t *testing.T) { + f := &models.MediaFile{ + BaseType: "ebook", + Container: "epub", + // no ProbeUpdatedAt, no audio/video — the unprobed state ebooks ship in + } + if NeedsCriticalProbeRepair(f) { + t.Fatal("an ebook/comic file must not need probe repair") + } +} + +func TestNeedsCriticalProbeRepair_UnprobedNonEbookFileRepairs(t *testing.T) { + if !NeedsCriticalProbeRepair(&models.MediaFile{}) { + t.Fatal("an unprobed non-ebook file must need probe repair") + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 5ff2ec57..b1b41242 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -270,6 +270,13 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (* return &ScanResult{}, nil } + if isMangaLibraryType(folder.Type) { + if err := s.ScanMangaFolder(watchCtx, folder); err != nil { + return nil, err + } + return &ScanResult{}, nil + } + if isEbookLibraryType(folder.Type) { if err := s.ScanEbookFolder(watchCtx, folder); err != nil { return nil, err @@ -299,6 +306,12 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s } return &ScanResult{}, nil } + if isMangaLibraryType(folder.Type) { + if err := s.scanMangaPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil { + return nil, err + } + return &ScanResult{}, nil + } if isEbookLibraryType(folder.Type) { if err := s.scanEbookPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil { return nil, err @@ -360,6 +373,15 @@ func isEbookLibraryType(libraryType string) bool { } } +func isMangaLibraryType(t string) bool { + switch strings.ToLower(strings.TrimSpace(t)) { + case "manga": + return true + default: + return false + } +} + // walkMode tells walkLogicalTree which file extensions to surface and // which library-specific filename heuristics (sample/extra skipping) // to apply. @@ -386,6 +408,9 @@ func walkModeFor(folderType string) walkMode { return walkModePodcast case isEbookLibraryType(folderType): return walkModeEbook + case isMangaLibraryType(folderType): + // Manga chapters are .cbz/.cbr archives, surfaced by the ebook walk. + return walkModeEbook default: return walkModeVideo } diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index d548c264..b0aba96d 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -221,6 +221,25 @@ func TestIsEbookLibraryType(t *testing.T) { } } +func TestIsMangaLibraryType(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"manga", true}, + {"Manga", true}, + {" MANGA ", true}, + {"ebooks", false}, + {"movies", false}, + {"", false}, + } + for _, tc := range cases { + if got := isMangaLibraryType(tc.in); got != tc.want { + t.Errorf("isMangaLibraryType(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + func TestWalkModeForEbookLibraryTypes(t *testing.T) { for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} { if got := walkModeFor(libraryType); got != walkModeEbook { diff --git a/internal/sections/defaults.go b/internal/sections/defaults.go index 409546bc..34ee6842 100644 --- a/internal/sections/defaults.go +++ b/internal/sections/defaults.go @@ -92,13 +92,24 @@ func generatedHomeLibraryRecentID(section *PageSection, libraryID int) string { } func generatedHomeLibraryRecentDefaults(libraryID int, libraryName, libraryType string) []*PageSection { + // addedConfig/releasedConfig default to the library-scoped (no media_scope) + // generated config. A manga library mixes type='manga' series with + // type='ebook' chapters, so we scope its generated home rows to the series + // only — otherwise the chapter junk filenames leak into the home page. + addedConfig := GeneratedHomeLibraryRecentConfig(libraryID) + releasedConfig := GeneratedHomeLibraryRecentConfig(libraryID) + if libraryType == "manga" { + addedConfig = GeneratedHomeLibraryRecentConfigScoped(libraryID, "manga") + releasedConfig = GeneratedHomeLibraryRecentConfigScoped(libraryID, "manga") + } + sections := []*PageSection{ { Scope: "home", SectionType: SectionRecentlyAdded, Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyAdded, libraryName), ItemLimit: 20, - Config: GeneratedHomeLibraryRecentConfig(libraryID), + Config: addedConfig, Enabled: true, }, } @@ -119,7 +130,7 @@ func generatedHomeLibraryRecentDefaults(libraryID int, libraryName, libraryType SectionType: SectionRecentlyReleased, Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyReleased, libraryName), ItemLimit: 20, - Config: GeneratedHomeLibraryRecentConfig(libraryID), + Config: releasedConfig, Enabled: true, }) } @@ -207,6 +218,16 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, {ID: "default-random-ebooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("ebook"), Enabled: true}, } + case "manga": + // Manga libraries browse the series items (media_items.type='manga'); + // the per-chapter ebook items are scoped out by the "manga" media scope. + return []*PageSection{ + {ID: "default-continue-reading", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Reading", ItemLimit: 20, Config: ContinueTypeConfig(ContinueTypeReading), Enabled: true}, + {ID: "default-recently-added-manga", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Manga", ItemLimit: 20, Config: defaultMediaScopeConfig("manga"), Enabled: true}, + {ID: "default-recently-released-manga", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Manga", ItemLimit: 20, Config: defaultMediaScopeConfig("manga"), Enabled: true}, + {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-random-manga", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("manga"), Enabled: true}, + } default: return DefaultLibrarySections(libraryID) } diff --git a/internal/sections/defaults_test.go b/internal/sections/defaults_test.go index 7b1b898e..ca263a1a 100644 --- a/internal/sections/defaults_test.go +++ b/internal/sections/defaults_test.go @@ -386,6 +386,60 @@ func TestDefaultLibrarySectionsForTypeEbooks(t *testing.T) { }) } +func TestDefaultLibrarySectionsForTypeManga(t *testing.T) { + libraryID := 13 + got := DefaultLibrarySectionsForType(&libraryID, "manga") + + if len(got) != 5 { + t.Fatalf("expected 5 manga default sections, got %d", len(got)) + } + + tests := []struct { + index int + id string + sectionType SectionType + title string + position int + }{ + {index: 0, id: "default-continue-reading", sectionType: SectionContinueWatching, title: "Continue Reading", position: 0}, + {index: 1, id: "default-recently-added-manga", sectionType: SectionRecentlyAdded, title: "Recently Added Manga", position: 1}, + {index: 2, id: "default-recently-released-manga", sectionType: SectionRecentlyReleased, title: "Recently Released Manga", position: 2}, + {index: 3, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 3}, + {index: 4, id: "default-random-manga", sectionType: SectionRandom, title: "Random Picks", position: 4}, + } + + for _, tt := range tests { + section := got[tt.index] + if section.ID != tt.id { + t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id) + } + if section.SectionType != tt.sectionType { + t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType) + } + if section.Title != tt.title { + t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title) + } + if section.Position != tt.position { + t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position) + } + } + + // The manga library browses only its series items: every query section is + // scoped to media_items.type='manga', so the per-chapter ebook items are + // excluded from the library feed. + assertContinueType(t, got[0].Config, ContinueTypeReading) + mangaScope := catalog.QueryDefinition{ + MediaScope: "manga", + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "added_at", Order: "desc"}, + } + assertQueryDefinition(t, got[1].Config, mangaScope) + assertQueryDefinition(t, got[2].Config, mangaScope) + assertEmptyJSON(t, got[3].Config) + assertQueryDefinition(t, got[4].Config, mangaScope) +} + func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) { libraryID := 99 got := DefaultLibrarySectionsForType(&libraryID, "mixed") @@ -501,3 +555,54 @@ func TestHomeDefaultsIncludeRecipeRichSet(t *testing.T) { } } } + +func TestGeneratedHomeLibraryRecentDefaultsMangaScope(t *testing.T) { + got := generatedHomeLibraryRecentDefaults(7, "Manga", "manga") + if len(got) != 2 { + t.Fatalf("expected 2 generated manga home sections, got %d", len(got)) + } + + wantTitles := map[SectionType]string{ + SectionRecentlyAdded: "Recently Added in Manga", + SectionRecentlyReleased: "Recently Released in Manga", + } + for _, sec := range got { + wantTitle, ok := wantTitles[sec.SectionType] + if !ok { + t.Fatalf("unexpected section type %s", sec.SectionType) + } + if sec.Title != wantTitle { + t.Fatalf("section %s title = %q, want %q", sec.SectionType, sec.Title, wantTitle) + } + + def, err := ParseQueryDefinition(sec.Config) + if err != nil { + t.Fatalf("ParseQueryDefinition(%s) error = %v", sec.SectionType, err) + } + if def.MediaScope != "manga" { + t.Fatalf("section %s media_scope = %q, want manga", sec.SectionType, def.MediaScope) + } + if len(def.LibraryIDs) != 1 || def.LibraryIDs[0] != 7 { + t.Fatalf("section %s library_ids = %v, want [7]", sec.SectionType, def.LibraryIDs) + } + if id, ok := ParseGeneratedHomeLibraryRecentConfig(sec.Config); !ok || id != 7 { + t.Fatalf("section %s generated config id = %d ok = %v, want 7 true", sec.SectionType, id, ok) + } + } +} + +func TestGeneratedHomeLibraryRecentDefaultsNonMangaNoScope(t *testing.T) { + got := generatedHomeLibraryRecentDefaults(7, "Movies", "movies") + if len(got) != 2 { + t.Fatalf("expected 2 generated movies home sections, got %d", len(got)) + } + for _, sec := range got { + def, err := ParseQueryDefinition(sec.Config) + if err != nil { + t.Fatalf("ParseQueryDefinition(%s) error = %v", sec.SectionType, err) + } + if def.MediaScope == "manga" { + t.Fatalf("section %s unexpectedly carries manga media_scope", sec.SectionType) + } + } +} diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index c28ee8dc..688e3bbf 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -397,6 +397,14 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res if err != nil { return SectionWithItems{}, err } + // Manga chapters are ebook items linked to a series; collapse multiple + // in-progress chapters of the same manga to a single card (keeping the + // most recently read), mirroring the episode→series collapse. Resolve + // the linkage into itemMeta so the shared collapse can group by series. + if len(orderedItems) > 1 { + f.applyMangaChapterSeriesMeta(ctx, orderedItems, itemMeta) + orderedItems = collapseContinueWatchingSeriesCandidates(orderedItems, itemMeta) + } return SectionWithItems{ ResolvedSection: resolved, Items: orderedItems, @@ -1580,6 +1588,8 @@ func (f *Fetcher) fetchFormatShowcase(ctx context.Context, s ResolvedSection, li argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + whereClause := "WHERE " + strings.Join(conditions, " AND ") limit := s.ItemLimit @@ -2008,6 +2018,8 @@ func buildRecentlyAddedQuery(s ResolvedSection, libraryID *int, libraryIDs []int argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + whereClause := "" if len(conditions) > 0 { whereClause = "WHERE " + strings.Join(conditions, " AND ") @@ -2015,7 +2027,7 @@ func buildRecentlyAddedQuery(s ResolvedSection, libraryID *int, libraryIDs []int query := fmt.Sprintf( `SELECT %s FROM %s %s ORDER BY mi.created_at DESC, mi.content_id ASC LIMIT $%d`, - itemColumns("mi"), fromClause, whereClause, argIdx, + itemColumnsLatestMangaPoster("mi"), fromClause, whereClause, argIdx, ) args = append(args, s.ItemLimit) return query, args @@ -2071,16 +2083,18 @@ func buildRecentlyAddedSingleLibraryQuery(s ResolvedSection, cfgFilters SectionC applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx) catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + whereClause := "WHERE " + strings.Join(conditions, " AND ") query := fmt.Sprintf( `SELECT %s FROM media_item_libraries mil JOIN media_items mi ON mi.content_id = mil.content_id %s ORDER BY mil.first_seen_at DESC, mil.content_id ASC LIMIT $%d`, - itemColumns("mi"), whereClause, argIdx, + itemColumnsLatestMangaPoster("mi"), whereClause, argIdx, ) args = append(args, s.ItemLimit) return sectionQuery{sql: query, args: args}, true } -func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) { +func buildRecentlyReleasedQuery(s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (string, []any) { cfgFilters := ParseConfigFilters(s.Config) var conditions []string @@ -2095,6 +2109,8 @@ func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + whereClause := "" if len(conditions) > 0 { whereClause = "WHERE " + strings.Join(conditions, " AND ") @@ -2102,9 +2118,14 @@ func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, query := fmt.Sprintf( `SELECT %s FROM %s %s ORDER BY mi.year DESC, mi.created_at DESC LIMIT $%d`, - itemColumns("mi"), fromClause, whereClause, argIdx, + itemColumnsLatestMangaPoster("mi"), fromClause, whereClause, argIdx, ) args = append(args, s.ItemLimit) + return query, args +} + +func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) { + query, args := buildRecentlyReleasedQuery(s, libraryID, libraryIDs, filter) rows, err := f.pool.Query(ctx, query, args...) if err != nil { @@ -2152,7 +2173,7 @@ func (f *Fetcher) fetchFiltered(ctx context.Context, s ResolvedSection, libraryI return items, total, nil } -func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) { +func buildRandomQuery(s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (string, []any, int) { cfgFilters := ParseConfigFilters(s.Config) var conditions []string @@ -2167,6 +2188,8 @@ func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + whereClause := "" if len(conditions) > 0 { whereClause = "WHERE " + strings.Join(conditions, " AND ") @@ -2190,6 +2213,11 @@ func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID fromClause, whereClause, argIdx, ) args = append(args, queryLimit) + return query, args, limit +} + +func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) { + query, args, limit := buildRandomQuery(s, libraryID, libraryIDs, filter) rows, err := f.pool.Query(ctx, query, args...) if err != nil { @@ -2421,9 +2449,10 @@ func utcDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } -// itemColumns returns the SELECT column list matching scanMediaItems. -// Mirrors catalog.browseItemColumns. -func itemColumns(alias string) string { +// itemColumnsList returns the alias-prefixed SELECT columns matching +// scanMediaItems, in scan order. Mirrors catalog.browseItemColumns. The shared +// source of truth for itemColumns and its manga-poster-override variant. +func itemColumnsList(alias string) []string { cols := []string{ "content_id", "type", "title", "sort_title", "original_title", "year", "genres", "content_rating", "runtime", "overview", "tagline", @@ -2439,7 +2468,53 @@ func itemColumns(alias string) string { for i, c := range cols { prefixed[i] = alias + "." + c } - return strings.Join(prefixed, ", ") + return prefixed +} + +// itemColumns returns the SELECT column list matching scanMediaItems. +// Mirrors catalog.browseItemColumns. +func itemColumns(alias string) string { + return strings.Join(itemColumnsList(alias), ", ") +} + +// itemColumnsLatestMangaPoster returns the same SELECT column list as +// itemColumns (identical order and aliases, so scanMediaItems is unchanged) but +// overrides poster_path/poster_thumbhash for type='manga' SERIES rows: a manga +// series card shows the cover of its latest-added volume/chapter (the linked +// manga_chapters row with the greatest created_at) instead of the AniList series +// cover, falling back to the series' own poster when no chapter cover exists. +// +// Strictly gated on mi.type = 'manga' so movies/TV/audiobooks/ebooks keep their +// own poster exactly. Used only by the recently-added / recently-released +// section builders; all other queries keep itemColumns. +func itemColumnsLatestMangaPoster(alias string) string { + cols := itemColumnsList(alias) + for i, c := range cols { + switch c { + case alias + ".poster_path": + cols[i] = mangaLatestVolumePosterExpr(alias, "poster_path") + case alias + ".poster_thumbhash": + cols[i] = mangaLatestVolumePosterExpr(alias, "poster_thumbhash") + } + } + return strings.Join(cols, ", ") +} + +// mangaLatestVolumePosterExpr emits the manga-gated CASE override for a single +// poster column, aliased back to the original column name so the scan order and +// column set are unchanged. +func mangaLatestVolumePosterExpr(alias, col string) string { + // NULLIF(...,'') on each operand: poster columns default to '' (empty + // string), not NULL, so a plain COALESCE would surface a cover-less latest + // chapter's empty poster instead of falling back to the series' own cover. + // Mirrors the episode poster expressions above; trailing '' keeps the THEN + // branch non-NULL. + return "CASE WHEN " + alias + ".type = 'manga' THEN COALESCE(NULLIF((" + + "SELECT c." + col + " FROM media_items c " + + "JOIN manga_chapters mc ON mc.chapter_content_id = c.content_id " + + "WHERE mc.series_content_id = " + alias + ".content_id " + + "ORDER BY c.created_at DESC, c.content_id DESC LIMIT 1), ''), " + + "NULLIF(" + alias + "." + col + ", ''), '') ELSE " + alias + "." + col + " END AS " + col } // scanMediaItems scans rows into MediaItem slices. Must match itemColumns order. @@ -2611,6 +2686,8 @@ func (f *Fetcher) fetchTrending(ctx context.Context, s ResolvedSection, libraryI argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + conditions = append(conditions, fmt.Sprintf("uwh.watched_at > NOW() - $%d::interval", argIdx)) args = append(args, interval) argIdx++ @@ -2738,6 +2815,8 @@ func (f *Fetcher) fetchNewToLibrary(ctx context.Context, s ResolvedSection, libr argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + conditions = append(conditions, fmt.Sprintf("mi.created_at > NOW() - ($%d || ' days')::interval", argIdx)) args = append(args, days) argIdx++ @@ -2787,6 +2866,8 @@ func (f *Fetcher) fetchMostWatched(ctx context.Context, s ResolvedSection, libra argIdx = newArgIdx catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx) + conditions = append(conditions, catalog.MangaChapterExclusionWhere("mi")) + conditions = append(conditions, fmt.Sprintf("uwh.watched_at > NOW() - $%d::interval", argIdx)) args = append(args, interval) argIdx++ @@ -3110,3 +3191,64 @@ func (f *Fetcher) fetchMoodCollection(ctx context.Context, s ResolvedSection, li } return items, len(items), nil } + +// mangaChapterSeriesMetaQuery resolves the owning manga series for chapter +// items (type='ebook' rows linked via manga_chapters) appearing on section +// cards, so continue-reading surfaces can show the series instead of the +// chapter's raw file title. +const mangaChapterSeriesMetaQuery = ` + SELECT mc.chapter_content_id, mc.series_content_id, si.title + FROM manga_chapters mc + JOIN media_items si ON si.content_id = mc.series_content_id + WHERE mc.chapter_content_id = ANY($1) +` + +// FetchMangaChapterSeriesMeta returns series linkage keyed by chapter content +// id. IDs that are not manga chapters simply have no entry. +func (f *Fetcher) FetchMangaChapterSeriesMeta(ctx context.Context, ids []string) (map[string]SectionItemMeta, error) { + meta := make(map[string]SectionItemMeta, len(ids)) + if f == nil || f.pool == nil || len(ids) == 0 { + return meta, nil + } + rows, err := f.pool.Query(ctx, mangaChapterSeriesMetaQuery, ids) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var chapterID, seriesID, seriesTitle string + if err := rows.Scan(&chapterID, &seriesID, &seriesTitle); err != nil { + return nil, err + } + id := seriesID + meta[chapterID] = SectionItemMeta{SeriesID: &id, SeriesTitle: seriesTitle} + } + return meta, rows.Err() +} + +// applyMangaChapterSeriesMeta resolves the owning manga series for any chapter +// (ebook) items in the set and merges SeriesID/SeriesTitle into the existing +// itemMeta, preserving the progress fields already populated there. Lets the +// shared series-collapse group multiple in-progress chapters of one manga. +func (f *Fetcher) applyMangaChapterSeriesMeta(ctx context.Context, items []*models.MediaItem, itemMeta map[string]SectionItemMeta) { + ids := make([]string, 0, len(items)) + for _, item := range items { + if item != nil && item.Type == "ebook" && strings.TrimSpace(item.ContentID) != "" { + ids = append(ids, item.ContentID) + } + } + if len(ids) == 0 { + return + } + seriesMeta, err := f.FetchMangaChapterSeriesMeta(ctx, ids) + if err != nil { + slog.Warn("continue-reading: manga series linkage lookup failed", "error", err) + return + } + for chapterID, sm := range seriesMeta { + m := itemMeta[chapterID] + m.SeriesID = sm.SeriesID + m.SeriesTitle = sm.SeriesTitle + itemMeta[chapterID] = m + } +} diff --git a/internal/sections/fetcher_continue_watching_test.go b/internal/sections/fetcher_continue_watching_test.go index 6efaeaf6..5a3b3608 100644 --- a/internal/sections/fetcher_continue_watching_test.go +++ b/internal/sections/fetcher_continue_watching_test.go @@ -230,3 +230,37 @@ func contentIDs(items []*models.MediaItem) []string { func intPtr(v int) *int { return &v } + +func TestCollapseContinueWatchingSeriesCandidatesCollapsesMangaChapters(t *testing.T) { + t.Parallel() + + seriesID := "manga-7" + older := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC) + newer := time.Date(2025, 3, 5, 12, 0, 0, 0, time.UTC) + + // Two in-progress chapters (ebook items) of one manga series, plus an + // unrelated ebook with no series — mirrors applyMangaChapterSeriesMeta's + // output feeding the shared collapse. + items := []*models.MediaItem{ + {ContentID: "ch-12", Type: "ebook", Title: "Series v12"}, + {ContentID: "ch-09", Type: "ebook", Title: "Series v09"}, + {ContentID: "book-x", Type: "ebook", Title: "A standalone ebook"}, + } + meta := map[string]SectionItemMeta{ + "ch-12": {SeriesID: &seriesID, ItemSource: "in_progress", SortTimestamp: newer}, + "ch-09": {SeriesID: &seriesID, ItemSource: "in_progress", SortTimestamp: older}, + } + + collapsed := collapseContinueWatchingSeriesCandidates(items, meta) + + gotIDs := contentIDs(collapsed) + wantIDs := []string{"ch-12", "book-x"} // most-recent chapter kept; standalone untouched + if len(gotIDs) != len(wantIDs) { + t.Fatalf("collapsed IDs = %v, want %v", gotIDs, wantIDs) + } + for i := range wantIDs { + if gotIDs[i] != wantIDs[i] { + t.Fatalf("collapsed IDs = %v, want %v", gotIDs, wantIDs) + } + } +} diff --git a/internal/sections/generated.go b/internal/sections/generated.go index 4eff2e79..d71a5246 100644 --- a/internal/sections/generated.go +++ b/internal/sections/generated.go @@ -34,6 +34,38 @@ func GeneratedHomeLibraryRecentConfig(libraryID int) json.RawMessage { return config } +// GeneratedHomeLibraryRecentConfigScoped builds the generated home "recent" +// config for a library while constraining results to a single media scope. +// This is required for mixed-type libraries (e.g. manga, which contains both +// type='manga' series and type='ebook' chapters) so the auto-generated home +// rows only surface the series and not the junk chapter filenames. It mirrors +// the modern QueryDefinition shape used by GeneratedHomeLibraryRecentEpisodesConfig +// (library_ids + media_scope) — note we intentionally avoid filter_library_id +// here, since that flat key routes the config through the legacy parser which +// drops media_scope. Library targeting comes from both library_ids and the +// generated_library_id metadata read by parseGeneratedHomeLibraryRecentConfig. +func GeneratedHomeLibraryRecentConfigScoped(libraryID int, mediaScope string) json.RawMessage { + config, err := json.Marshal(struct { + catalog.QueryDefinition + GeneratedLibraryID int `json:"generated_library_id"` + GeneratedSource string `json:"generated_source"` + }{ + QueryDefinition: catalog.QueryDefinition{ + LibraryIDs: []int{libraryID}, + MediaScope: mediaScope, + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "added_at", Order: "desc"}, + }.Normalize(), + GeneratedLibraryID: libraryID, + GeneratedSource: GeneratedHomeLibraryRecentSource, + }) + if err != nil { + return json.RawMessage(`{}`) + } + return config +} + func GeneratedHomeLibraryRecentEpisodesConfig(libraryID int) json.RawMessage { config, err := json.Marshal(struct { catalog.QueryDefinition diff --git a/internal/sections/manga_chapter_exclusion_test.go b/internal/sections/manga_chapter_exclusion_test.go new file mode 100644 index 00000000..84667c62 --- /dev/null +++ b/internal/sections/manga_chapter_exclusion_test.go @@ -0,0 +1,60 @@ +package sections + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// mangaChapterExclusionSQL is the predicate that library-listing section +// builders must carry so manga CHAPTER rows (type='ebook' linked into a manga +// series) never surface as standalone cards. +const mangaChapterExclusionSQL = "NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)" + +func TestRecentlyAddedQueriesExcludeMangaChapters(t *testing.T) { + t.Parallel() + + // Generic multi-library path. + generic, _ := buildRecentlyAddedQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{"filter_library_ids":[1,2],"filter_type":"movie"}`), + }, nil, nil, catalog.AccessFilter{}) + if !strings.Contains(generic, mangaChapterExclusionSQL) { + t.Fatalf("recently-added generic query missing manga-chapter exclusion:\n%s", generic) + } + + // Single-library fast path. + single, _ := buildRecentlyAddedQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{"filter_library_id":1,"filter_type":"movie"}`), + }, nil, []int{1, 2}, catalog.AccessFilter{}) + if !strings.Contains(single, mangaChapterExclusionSQL) { + t.Fatalf("recently-added single-library query missing manga-chapter exclusion:\n%s", single) + } +} + +func TestRecentlyReleasedQueryExcludesMangaChapters(t *testing.T) { + t.Parallel() + + query, _ := buildRecentlyReleasedQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{}`), + }, nil, nil, catalog.AccessFilter{}) + if !strings.Contains(query, mangaChapterExclusionSQL) { + t.Fatalf("recently-released query missing manga-chapter exclusion:\n%s", query) + } +} + +func TestRandomQueryExcludesMangaChapters(t *testing.T) { + t.Parallel() + + query, _, _ := buildRandomQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{}`), + }, nil, nil, catalog.AccessFilter{}) + if !strings.Contains(query, mangaChapterExclusionSQL) { + t.Fatalf("random query missing manga-chapter exclusion:\n%s", query) + } +} diff --git a/internal/sections/manga_latest_volume_poster_test.go b/internal/sections/manga_latest_volume_poster_test.go new file mode 100644 index 00000000..5b327fcd --- /dev/null +++ b/internal/sections/manga_latest_volume_poster_test.go @@ -0,0 +1,62 @@ +package sections + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// The recently-added/released section cards for a manga SERIES must show the +// cover of the latest-added volume/chapter (greatest created_at) instead of +// the AniList series cover. The override is implemented as a manga-gated +// (type='manga') CASE that pulls the newest linked chapter's poster, falling +// back to the series' own poster. Non-manga rows must keep mi.poster_path +// exactly. +func assertMangaPosterOverride(t *testing.T, label, query string) { + t.Helper() + for _, frag := range []string{ + "CASE WHEN mi.type = 'manga'", + "manga_chapters mc ON mc.chapter_content_id = c.content_id", + "mc.series_content_id = mi.content_id", + "ORDER BY c.created_at DESC", + "AS poster_path", + "AS poster_thumbhash", + // Poster columns default to '' (not NULL), so the override must NULLIF + // each operand or a cover-less latest chapter blanks the series card. + "COALESCE(NULLIF(", + "NULLIF(mi.poster_path, '')", + "NULLIF(mi.poster_thumbhash, '')", + } { + if !strings.Contains(query, frag) { + t.Fatalf("%s query missing manga poster-override fragment %q:\n%s", label, frag, query) + } + } +} + +func TestRecentlyAddedQueriesUseLatestMangaVolumePoster(t *testing.T) { + t.Parallel() + + generic, _ := buildRecentlyAddedQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{"filter_library_ids":[1,2]}`), + }, nil, nil, catalog.AccessFilter{}) + assertMangaPosterOverride(t, "recently-added generic", generic) + + single, _ := buildRecentlyAddedQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{"filter_library_id":1}`), + }, nil, []int{1, 2}, catalog.AccessFilter{}) + assertMangaPosterOverride(t, "recently-added single-library", single) +} + +func TestRecentlyReleasedQueryUsesLatestMangaVolumePoster(t *testing.T) { + t.Parallel() + + query, _ := buildRecentlyReleasedQuery(ResolvedSection{ + ItemLimit: 12, + Config: json.RawMessage(`{}`), + }, nil, nil, catalog.AccessFilter{}) + assertMangaPosterOverride(t, "recently-released", query) +} diff --git a/internal/taskmanager/tasks/sync_manga_metadata.go b/internal/taskmanager/tasks/sync_manga_metadata.go new file mode 100644 index 00000000..1cb69835 --- /dev/null +++ b/internal/taskmanager/tasks/sync_manga_metadata.go @@ -0,0 +1,56 @@ +package tasks + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +type mangaMetadataEnricher interface { + Run(ctx context.Context) (int, error) +} + +// SyncMangaMetadataTask runs the periodic manga enrichment sweep. +// It calls manga.Enricher.Run() which selects unenriched manga media_items, +// resolves the per-folder metadata-provider chain at content_level='manga', +// and writes results back to the database. +type SyncMangaMetadataTask struct { + enricher mangaMetadataEnricher +} + +// NewSyncMangaMetadataTask constructs the task. +func NewSyncMangaMetadataTask(enricher mangaMetadataEnricher) *SyncMangaMetadataTask { + return &SyncMangaMetadataTask{enricher: enricher} +} + +func (t *SyncMangaMetadataTask) Key() string { return "sync_manga_metadata" } +func (t *SyncMangaMetadataTask) Name() string { return "Sync Manga Metadata" } +func (t *SyncMangaMetadataTask) Description() string { + return "Fetches metadata (cover art, overview, authors) for manga that have not yet been enriched" +} +func (t *SyncMangaMetadataTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategoryMetadata +} +func (t *SyncMangaMetadataTask) IsHidden() bool { return false } + +func (t *SyncMangaMetadataTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000}, + } +} + +func (t *SyncMangaMetadataTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + progress.Report(0, "Scanning for unenriched manga") + + enriched, err := t.enricher.Run(ctx) + if err != nil { + return fmt.Errorf("manga metadata sync: %w", err) + } + + result, _ := json.Marshal(map[string]int{"items_enriched": enriched}) + progress.SetResultData(result) + progress.Report(100, fmt.Sprintf("Manga metadata sync complete (%d items enriched)", enriched)) + return nil +} diff --git a/migrations/sql/20260610193238_manga_chapters.sql b/migrations/sql/20260610193238_manga_chapters.sql new file mode 100644 index 00000000..a1931a59 --- /dev/null +++ b/migrations/sql/20260610193238_manga_chapters.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE manga_chapters ( + chapter_content_id TEXT PRIMARY KEY REFERENCES media_items(content_id) ON DELETE CASCADE, + series_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + chapter_index NUMERIC, + volume TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX manga_chapters_series ON manga_chapters (series_content_id, chapter_index NULLS LAST); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS manga_chapters; +-- +goose StatementEnd diff --git a/migrations/sql/20260611003847_manga_enrichment_state.sql b/migrations/sql/20260611003847_manga_enrichment_state.sql new file mode 100644 index 00000000..18ee154c --- /dev/null +++ b/migrations/sql/20260611003847_manga_enrichment_state.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Dedicated failure accounting for the manga enrichment sweep. Mirrors +-- ebook_enrichment_state: tracks per-item failure counts independently from +-- media_items.refresh_failures so the enrichment sweep and the metadata +-- refresh-debt system do not fight over a shared counter. +CREATE TABLE manga_enrichment_state ( + content_id text PRIMARY KEY REFERENCES media_items(content_id) ON DELETE CASCADE, + failures integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE manga_enrichment_state; diff --git a/migrations/sql/20260616140000_manga_chapters_series_volume_index.sql b/migrations/sql/20260616140000_manga_chapters_series_volume_index.sql new file mode 100644 index 00000000..ffd38235 --- /dev/null +++ b/migrations/sql/20260616140000_manga_chapters_series_volume_index.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +-- The browse manga count-chip subqueries filter manga_chapters by +-- series_content_id and read/aggregate `volume` (chapter_count = volume IS NULL, +-- volume_count = count(DISTINCT volume)). The existing +-- manga_chapters_series (series_content_id, chapter_index) index doesn't include +-- volume, so count(DISTINCT volume) does a heap fetch per chapter row. Add a +-- covering (series_content_id, volume) index so both count subqueries are +-- index-only. +CREATE INDEX IF NOT EXISTS idx_manga_chapters_series_volume +ON public.manga_chapters USING btree (series_content_id, volume); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_manga_chapters_series_volume; +-- +goose StatementEnd diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 909792ed..b018c545 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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... */ diff --git a/web/src/components/ContinueWatchingCard.tsx b/web/src/components/ContinueWatchingCard.tsx index 97a90599..2938873d 100644 --- a/web/src/components/ContinueWatchingCard.tsx +++ b/web/src/components/ContinueWatchingCard.tsx @@ -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 (
- +
{imageSrc ? ( {episodeMeta && ( {episodeMeta} @@ -313,7 +322,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
{timeLeftLabel}
) : ( {timeLeftLabel} diff --git a/web/src/components/FilterRuleEditor.tsx b/web/src/components/FilterRuleEditor.tsx index 96e127a4..9fc593a8 100644 --- a/web/src/components/FilterRuleEditor.tsx +++ b/web/src/components/FilterRuleEditor.tsx @@ -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) { diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx index a7f90dc3..301a9b47 100644 --- a/web/src/components/GlobalSearch.tsx +++ b/web/src/components/GlobalSearch.tsx @@ -34,6 +34,8 @@ function typeLabel(type: BrowseItem["type"]): string { return "Ebook"; case "audiobook": return "Audiobook"; + case "manga": + return "Manga"; default: return type; } diff --git a/web/src/components/ItemCard.test.tsx b/web/src/components/ItemCard.test.tsx index 5d7f2f35..e06e67ff 100644 --- a/web/src/components/ItemCard.test.tsx +++ b/web/src/components/ItemCard.test.tsx @@ -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: { diff --git a/web/src/components/ItemCard.tsx b/web/src/components/ItemCard.tsx index 9fdf9c51..e16c2e10 100644 --- a/web/src/components/ItemCard.tsx +++ b/web/src/components/ItemCard.tsx @@ -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 (
@@ -206,6 +253,19 @@ export default function ItemCard({ {item.status === "matched" && overlayPrefs && ( )} + {mangaStatus && ( + + {mangaStatus.label} + + )} + {mangaCountLabel && ( + + + {mangaCountLabel} + + )}
{selectionMode && onToggleSelect && ( diff --git a/web/src/components/MangaFilesDialog.tsx b/web/src/components/MangaFilesDialog.tsx new file mode 100644 index 00000000..75f19985 --- /dev/null +++ b/web/src/components/MangaFilesDialog.tsx @@ -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 ( + + + + + {title ? `${title} — Files` : "Files"} + + + {files.length > 0 + ? `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatFileSize(totalBytes)}` + : "Local files backing this series."} + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +

+ Couldn't load file details. Try again later. +

+ ) : ( +
+ {(data?.folder_paths?.length ?? 0) > 0 && ( +
+ {data?.folder_paths?.map((path) => ( +
+ + {path} +
+ ))} +
+ )} + {files.length === 0 ? ( +

No files found.

+ ) : ( +
    + {files.map((file) => ( +
  • + + {fileRowLabel(file)} + + + {file.file_name} + + + {file.file_size > 0 ? formatFileSize(file.file_size) : ""} + +
  • + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/web/src/components/MediaItemMenu.tsx b/web/src/components/MediaItemMenu.tsx index 639d16a9..9f4d4bfd 100644 --- a/web/src/components/MediaItemMenu.tsx +++ b/web/src/components/MediaItemMenu.tsx @@ -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" && ( + + )} ); } diff --git a/web/src/components/admin/libraries/libraryTypes.ts b/web/src/components/admin/libraries/libraryTypes.ts index bf226b52..3998eb0a 100644 --- a/web/src/components/admin/libraries/libraryTypes.ts +++ b/web/src/components/admin/libraries/libraryTypes.ts @@ -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; diff --git a/web/src/components/admin/libraries/useLibraryForm.ts b/web/src/components/admin/libraries/useLibraryForm.ts index d454b4e1..e7cab4f5 100644 --- a/web/src/components/admin/libraries/useLibraryForm.ts +++ b/web/src/components/admin/libraries/useLibraryForm.ts @@ -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: diff --git a/web/src/components/catalog/CatalogFilterBar.tsx b/web/src/components/catalog/CatalogFilterBar.tsx index 654d5ea7..641ba926 100644 --- a/web/src/components/catalog/CatalogFilterBar.tsx +++ b/web/src/components/catalog/CatalogFilterBar.tsx @@ -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)); diff --git a/web/src/components/collections/CollectionGuidedRulesEditor.tsx b/web/src/components/collections/CollectionGuidedRulesEditor.tsx index 777690a0..06f74be1 100644 --- a/web/src/components/collections/CollectionGuidedRulesEditor.tsx +++ b/web/src/components/collections/CollectionGuidedRulesEditor.tsx @@ -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({
diff --git a/web/src/hooks/queries/catalogRead.ts b/web/src/hooks/queries/catalogRead.ts index 50c030db..5211111a 100644 --- a/web/src/hooks/queries/catalogRead.ts +++ b/web/src/hooks/queries/catalogRead.ts @@ -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 { + return api(`/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), diff --git a/web/src/lib/mangaChapters.test.ts b/web/src/lib/mangaChapters.test.ts new file mode 100644 index 00000000..918ef06e --- /dev/null +++ b/web/src/lib/mangaChapters.test.ts @@ -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 { + 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); + }); +}); diff --git a/web/src/lib/mangaChapters.ts b/web/src/lib/mangaChapters.ts new file mode 100644 index 00000000..1cafc1b2 --- /dev/null +++ b/web/src/lib/mangaChapters.ts @@ -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 " 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(); + 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; +} diff --git a/web/src/lib/mediaNavigation.ts b/web/src/lib/mediaNavigation.ts index cf9653d7..c895e486 100644 --- a/web/src/lib/mediaNavigation.ts +++ b/web/src/lib/mediaNavigation.ts @@ -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) { @@ -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 }); } diff --git a/web/src/lib/querySortOptions.ts b/web/src/lib/querySortOptions.ts index 608b0b1f..e810a24c 100644 --- a/web/src/lib/querySortOptions.ts +++ b/web/src/lib/querySortOptions.ts @@ -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; }); } diff --git a/web/src/pages/EbookReader.test.tsx b/web/src/pages/EbookReader.test.tsx index 36999b63..7aa81d09 100644 --- a/web/src/pages/EbookReader.test.tsx +++ b/web/src/pages/EbookReader.test.tsx @@ -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( + + + } /> + + , + ); + }); + + // 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({ diff --git a/web/src/pages/EbookReader.tsx b/web/src/pages/EbookReader.tsx index c390eeae..f8ad2ab1 100644 --- a/web/src/pages/EbookReader.tsx +++ b/web/src/pages/EbookReader.tsx @@ -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(null); const [loadedFile, setLoadedFile] = useState(null); const [readerProgress, setReaderProgress] = useState(null); const [toc, setToc] = useState([]); const [panelOpen, setPanelOpen] = useState(true); + const comicPanelInitRef = useRef(false); + useEffect(() => { + if (isComicFormat && !comicPanelInitRef.current) { + comicPanelInitRef.current = true; + setPanelOpen(false); + } + }, [isComicFormat]); const [panel, setPanel] = useState("toc"); const [readerSettings, setReaderSettings] = useState(() => 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() {
-
+ {nextChapterHref && nextChapter && ( + + )} {progressLabel && (
{progressLabel} @@ -579,15 +661,17 @@ export default function EbookReader() { > - + {!isComicFormat && ( + + )} - ); - })} + {active && } + + ); + })} +
-
-
- - Read aloud -
-
- - - -
- - + )} + {!isComicFormat && ( + <> +
+ + Read aloud +
+
+ + + +
+ + + + )}
- - updateReaderSettings({ fontSize })} - /> + {!isComicFormat && ( + + )} + {!isComicFormat && ( + updateReaderSettings({ fontSize })} + /> + )} updateReaderSettings({ fontBrightness })} /> - updateReaderSettings({ lineHeight })} - /> + {!isComicFormat && ( + updateReaderSettings({ lineHeight })} + /> + )} updateReaderSettings({ margin })} /> - {readerSettings.flow !== "scrolled" && ( + {!isComicFormat && readerSettings.flow !== "scrolled" && ( )}
- + {!isComicFormat && ( + + )} - + {!isComicFormat && ( + + )} {readerSettings.readingRuler && ( )}
- + {!isComicFormat && ( + + )} {readerSettings.flow !== "scrolled" && (
); } diff --git a/web/src/pages/ItemDetail/DetailHero.tsx b/web/src/pages/ItemDetail/DetailHero.tsx index 0c113243..d41c4367 100644 --- a/web/src/pages/ItemDetail/DetailHero.tsx +++ b/web/src/pages/ItemDetail/DetailHero.tsx @@ -261,30 +261,27 @@ export default function DetailHero({ )} - {/* Crew line replaces genres when provided */} - {crewLine ? ( -
{crewLine}
- ) : ( - genres && - genres.length > 0 && ( -
- {genres.map((genre) => - genreHref ? ( - - {genre} - - ) : ( - - {genre} - - ), - )} -
- ) + {/* Crew line and genre chips render independently: pages that + fold genres into their crew line simply omit the genres prop. */} + {crewLine &&
{crewLine}
} + {genres && genres.length > 0 && ( +
+ {genres.map((genre) => + genreHref ? ( + + {genre} + + ) : ( + + {genre} + + ), + )} +
)} {actions && ( diff --git a/web/src/pages/ItemDetail/MangaContent.test.tsx b/web/src/pages/ItemDetail/MangaContent.test.tsx new file mode 100644 index 00000000..f6870c14 --- /dev/null +++ b/web/src/pages/ItemDetail/MangaContent.test.tsx @@ -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 }) => ( +
+

{title}

+ {actions} +
+ ), +})); + +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( + + + , + ); + + // 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( + + + , + ); + + // 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( + + + , + ); + + // 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + // 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( + + + , + ); + + // "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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(screen.getByRole("button", { name: /More actions/i })).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/ItemDetail/MangaContent.tsx b/web/src/pages/ItemDetail/MangaContent.tsx new file mode 100644 index 00000000..eac18f62 --- /dev/null +++ b/web/src/pages/ItemDetail/MangaContent.tsx @@ -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(null); + useEffect(() => { + setReadOverride(null); + }, [chapter.read]); + const markedRead = readOverride ?? chapter.read ?? false; + + const [downloadOpen, setDownloadOpen] = useState(false); + const [downloadVersions, setDownloadVersions] = useState(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 ( +
+ + {chapter.poster_url ? ( + + ) : ( + + )} + + {label} + + {markedRead && ( + + + Read + + )} + {progressPct != null && ( + + + + + {progressPct}% + + )} + +
+ + {canDownload && ( + + )} +
+ {canDownload && downloadVersions && ( + + )} +
+ ); +} + +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 ( +
+ } + context="Manga" + studioLabel={publisher} + backdropUrl={item.backdrop_url} + backdropThumbhash={item.backdrop_thumbhash} + posterUrl={item.poster_url} + posterThumbhash={item.poster_thumbhash} + metadata={ + + } + scoreRow={ + + } + overview={item.overview} + crewLine={} + genres={item.genres} + genreHref={(genre) => genreHref(genre, libraryId)} + actions={ +
+ {cta && ( + + )} + + + + + + setFilesOpen(true)}> + + View Details + + {isAdmin && ( + <> + + setRefreshOpen(true)} + > + {refreshMetadataMutation.isPending && ( + + )} + Refresh Metadata + + + )} + + +
+ } + /> + +
+ {resume && flattenMangaList(entries).length > 10 && ( +
+ +
+ )} + {entries.length === 0 ? ( +

+ No chapters found. Chapters appear here once the library scan completes. +

+ ) : ( +
    + {entries.map((entry) => + entry.kind === "section" ? ( + + ) : ( +
  • + +
  • + ), + )} +
+ )} +
+ + + { + setRefreshOpen(false); + refreshMetadataMutation.mutate({ item, mode }); + }} + isPending={refreshMetadataMutation.isPending} + /> +
+ ); +} + +// 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; + seriesContentId: string; + libraryId?: number; +}) { + const allRead = entry.chapters.every((chapter) => chapter.read === true); + const [open, setOpen] = useState(!allRead); + + return ( +
  • + + {open && ( +
      + {entry.chapters.map((chapter) => ( +
    • + +
    • + ))} +
    + )} +
  • + ); +} + +// flattenFirst returns the first readable unit of the series (used as the +// re-read target once everything is read). +function flattenFirst(entries: ReturnType) { + 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 }; +} diff --git a/web/src/pages/ItemDetail/components/HeroCrewLine.tsx b/web/src/pages/ItemDetail/components/HeroCrewLine.tsx index b6891712..4618f5a7 100644 --- a/web/src/pages/ItemDetail/components/HeroCrewLine.tsx +++ b/web/src/pages/ItemDetail/components/HeroCrewLine.tsx @@ -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 (
    + {hasAuthors && ( + <> + By + + + )} + {hasAuthors && (hasDirectors || hasWriters || hasGenres) && ( + · + )} {hasDirectors && ( <> {jobLabel} diff --git a/web/src/pages/ItemDetail/components/MetadataBadges.tsx b/web/src/pages/ItemDetail/components/MetadataBadges.tsx index cb5426ce..47480e38 100644 --- a/web/src/pages/ItemDetail/components/MetadataBadges.tsx +++ b/web/src/pages/ItemDetail/components/MetadataBadges.tsx @@ -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"} )} + {volumeCount != null && volumeCount > 0 && ( + + {volumeCount} {volumeCount === 1 ? "Volume" : "Volumes"} + + )} + {chapterCount != null && chapterCount > 0 && ( + + {chapterCount} {chapterCount === 1 ? "Chapter" : "Chapters"} + + )} {status && ( {status} diff --git a/web/src/pages/ItemDetail/index.tsx b/web/src/pages/ItemDetail/index.tsx index 48a1c773..88f3037b 100644 --- a/web/src/pages/ItemDetail/index.tsx +++ b/web/src/pages/ItemDetail/index.tsx @@ -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 ; + case "manga": + return ; case "podcast": return ; default: diff --git a/web/src/pages/ItemDetail/watchedState.test.ts b/web/src/pages/ItemDetail/watchedState.test.ts index c216a542..40a73bfb 100644 --- a/web/src/pages/ItemDetail/watchedState.test.ts +++ b/web/src/pages/ItemDetail/watchedState.test.ts @@ -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", () => { diff --git a/web/src/pages/ItemDetail/watchedState.ts b/web/src/pages/ItemDetail/watchedState.ts index 1ac9c132..b661c643 100644 --- a/web/src/pages/ItemDetail/watchedState.ts +++ b/web/src/pages/ItemDetail/watchedState.ts @@ -31,6 +31,7 @@ export function getWatchedActionLabel(item: Pick, 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"; diff --git a/web/src/pages/LibraryBrowse.tsx b/web/src/pages/LibraryBrowse.tsx index 90499ded..d96b8e1e 100644 --- a/web/src/pages/LibraryBrowse.tsx +++ b/web/src/pages/LibraryBrowse.tsx @@ -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, diff --git a/web/src/pages/libraryPageSearchParams.test.ts b/web/src/pages/libraryPageSearchParams.test.ts index f137e521..9fc98547 100644 --- a/web/src/pages/libraryPageSearchParams.test.ts +++ b/web/src/pages/libraryPageSearchParams.test.ts @@ -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", () => { diff --git a/web/src/pages/libraryPageSearchParams.ts b/web/src/pages/libraryPageSearchParams.ts index 065d3a03..954b5ecd 100644 --- a/web/src/pages/libraryPageSearchParams.ts +++ b/web/src/pages/libraryPageSearchParams.ts @@ -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"