From d590eda22afc84abac1fdff1773576adb33c2b9e Mon Sep 17 00:00:00 2001 From: fluxis Date: Thu, 2 Jul 2026 14:08:02 -0400 Subject: [PATCH] fix(libraries): exclude manga chapters from admin Unmatched queue (#275) * fix(libraries): exclude manga chapters from admin Unmatched queue Manga chapter rows are internal sub-units resolved through their series and intentionally stay 'pending' with type='ebook', so they flooded the admin Unmatched Items view even after the series matched successfully. Apply the shared MangaChapterExclusionWhere guard to both the count and list queries, matching every other catalog listing surface. Fixes #204 Co-Authored-By: Claude Fable 5 * test(libraries): scope unmatched-queue regression test with a unique per-run tag The regression test seeded fixed literal titles and asserted an exact total for q=Unmatched+Test, which is collision-prone against a shared SILO_TEST_DATABASE_URL database: leftover or concurrently seeded rows matching the literal query would skew the count and flake the test. Embed a unique per-run tag (issue204-) in every seeded title and scope the search query to that tag (URL-encoded), so the exact total assertion only ever sees this run's rows. Assertions are otherwise unchanged: the manga chapter stays excluded, the plain pending ebook stays present, and total == 1. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- internal/api/handlers/libraries.go | 11 +- .../api/handlers/libraries_unmatched_test.go | 104 ++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 internal/api/handlers/libraries_unmatched_test.go diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 96b902eb..e342c73b 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -2515,10 +2515,15 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http )` } + // Manga chapters carry their series' match state; the chapter rows + // themselves stay 'pending' and are resolved through the manga series, + // so they must not surface as actionable unmatched items here. + mangaChapterGuard := ` AND ` + catalog.MangaChapterExclusionWhere("mi") + countSQL := ` SELECT COUNT(*) FROM media_items mi - WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + mangaChapterGuard countSQL += filter var total int @@ -2541,10 +2546,10 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http WHERE mil.content_id = mi.content_id LIMIT 1 ) lib ON true - WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s + WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s%s ORDER BY mi.title ASC, mi.content_id ASC LIMIT $%d OFFSET $%d - `, filter, len(filterArgs)+1, len(filterArgs)+2) + `, mangaChapterGuard, filter, len(filterArgs)+1, len(filterArgs)+2) rows, err := h.pool.Query(r.Context(), listSQL, listArgs...) if err != nil { diff --git a/internal/api/handlers/libraries_unmatched_test.go b/internal/api/handlers/libraries_unmatched_test.go new file mode 100644 index 00000000..5d5d308c --- /dev/null +++ b/internal/api/handlers/libraries_unmatched_test.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestHandleListUnmatchedItemsExcludesMangaChapters verifies that manga +// chapter rows (type='ebook' items linked into a series via manga_chapters) +// do not appear in the admin Unmatched queue, while genuinely pending items +// still do. Regression test for issue #204. +func TestHandleListUnmatchedItemsExcludesMangaChapters(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set SILO_TEST_DATABASE_URL to run DB-backed unmatched items handler test") + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect db: %v", err) + } + t.Cleanup(pool.Close) + + suffix := time.Now().UnixNano() + // Embed a unique per-run tag in every seeded title and scope the search + // query to it, so concurrent or leftover rows in a shared test database + // cannot skew the exact-total assertion below. + tag := fmt.Sprintf("issue204-%d", suffix) + seriesID := fmt.Sprintf("manga-series-%d", suffix) + chapterID := fmt.Sprintf("manga-chapter-%d", suffix) + pendingID := fmt.Sprintf("pending-ebook-%d", suffix) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, + []string{chapterID, seriesID, pendingID}) + }) + + for _, row := range []struct { + id, typ, title, status string + }{ + {seriesID, "manga", tag + " Series", "matched"}, + {chapterID, "ebook", tag + " Series c001", "pending"}, + {pendingID, "ebook", tag + " Plain Ebook", "pending"}, + } { + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, status, genres) + VALUES ($1, $2, $3, $4, '{}'::text[]) + `, row.id, row.typ, row.title, row.status); err != nil { + t.Fatalf("seed media item %s: %v", row.id, err) + } + } + if _, err := pool.Exec(ctx, ` + INSERT INTO manga_chapters (chapter_content_id, series_content_id, chapter_index) + VALUES ($1, $2, 1) + `, chapterID, seriesID); err != nil { + t.Fatalf("seed manga chapter link: %v", err) + } + + h := NewLibraryHandler(nil, nil, nil, pool, nil) + req := httptest.NewRequest(http.MethodGet, "/libraries/unmatched-items?q="+url.QueryEscape(tag), nil) + rec := httptest.NewRecorder() + h.HandleListUnmatchedItems(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } + var resp struct { + Items []struct { + ContentID string `json:"content_id"` + } `json:"items"` + Total int `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + var sawChapter, sawPending bool + for _, item := range resp.Items { + switch item.ContentID { + case chapterID: + sawChapter = true + case pendingID: + sawPending = true + } + } + if !sawPending { + t.Errorf("pending ebook %s missing from unmatched list", pendingID) + } + if sawChapter { + t.Errorf("manga chapter %s should be excluded from unmatched list", chapterID) + } + if resp.Total != 1 { + t.Errorf("total = %d, want 1 (chapter must be excluded from the count too)", resp.Total) + } +}