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) + } +}