feat: possible-duplicates view — doujin-set grouping endpoint and app page #89
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/duplicate-doujins-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds the "possible duplicates" view: the backend groups doujins that share identical page images into doujin sets, and the app shows those sets with a preview of a shared image and a link to each member, so the user can decide which copy to keep.
What's in
Backend —
GET /api/doujins/duplicatessharedImageCount, not one row per shared page (which is what the per-hashGET /api/images/duplicatesfrom #73 yields). Sets are exact: an image shared by A, B and C counts under {A,B,C}, not under {A,B} (documented and pinned).sampleImageFileIdplussampleImage/sampleThumbnaillinks. PaginatedCollectionResponse,.Producesper ADR 0021, static route mapped before/{id:guid}.(sha256, doujin)pairs are loaded and grouped in memory (grouping by a set is not expressible in SQL; the pair count is bounded by pages whose content appears in more than one doujin). The per-hash use case now shares the sameDuplicateContentQuerieshelper instead of a second copy of the join.App — Library toolbar → "Find duplicates" →
/library/duplicates/library(static child declared before:doujinId), so back returns to the library and the rail keeps Library highlighted.sampleThumbnaillink, id-built path as fallback), "N shared images · M doujins", and a numbered chip per member (oldest first — two copies usually share a title) that pushes the detail page, so back returns to the list for the next comparison. Refresh, "Load more" (page size 25), empty/error/retry states, inline error under loaded cards.DuplicatesState+ actions/reducer/epics (createDuplicatesEpicsregistered increateAppEpic); the reducer drops a load-more append that raced a refresh (same rule as the library reducer).DoujinRepository.listDuplicateDoujins, freezed models mirroring the DTO, assistant route-table entry (the navigate allowlist already matched/library/<segment>).Verification
dotnet test: 459/459 (+3 TestHost integration tests through REST → use case → EF → SQLite: set grouping with a within-doujin repeat excluded; ordering + pagination +nextlink + out-of-range page keeps totals; empty. +4 use-case unit tests over in-memory SQLite: pre-dedup multi-row hash (one shared image, oldest row previews, oldest doujin first); overlapping sets {A,B}/{A,B,C}/{A,C} stay three groups; null-hash rows form no group in either view; equal-count groups page deterministically). Fixture guards: two "different" JPEG fills can quantize to identical bytes (seeds 10/11 did), so the tests assert the hashes differ; the sample test uploads the ordinally larger hash first so it pins hash order, not insertion order.flutter analyzeclean;flutter test: 681/681 (+reducer incl. the stale-append and refresh-in-flight guards; epics through the real store incl. a failing page 2 and the shared page size; page widget over the real store + a local router mirroring the production nesting: render with link precedence and numbered chips, push/pop navigation, empty, error + retry, in-flight refresh and load-more states, failed load-more with inline error and retry, load more, refresh; repository wire test over a fake dio adapter; model parse; route-order and toolbar-button cases inapp_router_test; assistant allowlist + prompt pins).diotraffic and thumbnails are covered only by unit/wire tests), and timing on a large library (the in-memory grouping bound is reasoned, not measured).Follow-ups (also going into
docs/BACKLOG.mdvia the companion PR)GET /api/images/duplicatesstays now that the app uses the doujin-set view.🤖 Generated with Claude Code
- Reducer drops a load-more append that raced a refresh (reproduced by review: page 2 vanished, page 3 landed on top of a fresh page 1). - Doc comments say what SharedImageCount is: images shared by exactly this set; both orderings use the ordinal comparer. - App: sampleThumbnail link wins over the id-built path; members numbered oldest-first so same-titled copies stay apart; Load more disabled during a refresh; DuplicateDoujinRef → DuplicateDoujin (DTO-name convention). - Tests that previously could not fail now can: out-of-range page keeps totals; overlapping sets {A,B}/{A,B,C}/{A,C} stay three groups (kills a first-member key mutant); null-hash rows form no group in either view; equal-count groups page deterministically; the sample is the first hash in hash order even when uploaded second; load-more failure keeps groups and shows the inline error; refresh/load-more in-flight states; both epics use the same page size; toolbar button reaches the page; assistant allowlist and prompt pin the route; README lists the feature. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>Flutter Coverage
Total: 78.9% (7253 of 9196)
Summary
Summary
Coverage
DoujinManager.ApplicationCore - 86.7%
DoujinManager.Infrastructure - 95.1%
on
pshot
Series
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator
DoujinManager.RestAdapter - 86.8%
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
DoujinManager.Server - 28.7%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A doujin-set grouping view — not one row per shared page like the per-hash endpoint from #73, but one row per distinct set of doujins. Grouping by a set isn't even expressible in SQL, so the pairs ride into memory and get folded there~ And the exact-set semantics — an image shared by A, B and C belongs to {A,B,C}, never to {A,B} — documented and pinned by a test with three overlapping sets. That's the kind of contract-writing that makes my wings flutter ♪
I read the full diff (all 2,961 lines, generated code included — a girl must be thorough), traced the sibling endpoints, and rebuilt everything locally. Verdict below~
Verdict: ✅ Looks good to me~
No blocking issues. Not one. I looked — hard. The things I probed:
string.Join("|", member Guids)as the group key made me squint, so I checked: Guid strings are fixed-width lowercase with no|inside, so the encoding is collision-free, and ordinal comparison over fixed-width members is exactly the documented deterministic tie-break. TheEqualSharedCounts_PageDeterministically_ByMemberIdstest walks all three pages at pageSize=1 — no repeats, no skips.sampleByHash[g.Hashes[0]]cannot KeyNotFound — every hash inpairscame from anImageFilesrow with non-nullSha256, so a row always exists for the sample lookup. And the pre-dedup multi-row test (two rows, same hash, cross-wired to the doujins so "oldest doujin" and "oldest image row" are pinned independently!) proves the oldest-row selection. That fixture construction is genuinely clever, fufu~DuplicateContentQueries.DistinctPairswhereSha256 != nullfilters before the join reaches either view; the pinned test asserts both views stay clean. A dropped guard would 500 on a null dictionary key — and the PR says a mutation probe turned exactly that test red. Good girl energy.duplicateschild declared before:doujinId, pinned byapp_router_testassertingDetailPagefindsNothing on/library/duplicates. The navigate-tool allowlist already matched/library/<segment>(verified atnavigate_tool.dart:10), so the assistant route-table entry needed no allowlist change — correct claim.append && !isLoadingMore), load-more gate in the reducer as single source of truth, refresh-in-flight ignore: same shape as the library reducer from #63, each arm with its own directional test.Clamped(),PageInfo,BuildCollectionLinks("/api/doujins/duplicates", ...),EmptyActionsfor a read-only collection) mirrors/api/images/duplicatesline for line; the per-hash use case now sharesDuplicateContentQueriesinstead of carrying a second copy of the join — DRY in the right direction, andListDuplicateImagesUseCasekept its own pagination-in-SQL shape, which is correct since it is expressible in SQL.5195fc0:ListDuplicateDoujinsUseCase100% line / 100% branch,duplicates_page.dart112/112,duplicates_reducer.dart21/21,duplicates_epics.dart23/23. Every new branch is exercised by a test that can go red.💡 Little ideas (non-blocking)~
duplicates_actions.dartshows 50% line coverage — that's the const-constructor instrumentation artifact every sibling actions file shares (library_actions42.9%), nothing to fix. Noting it only so nobody chases the number later~ ♡✅ What I liked~
Merge it when you're ready, scarlet~ fufu~ ♡
Automated review by Jibril · 2026-09-05
CI/CD: passed for head
5195fc0(coverage bots 6943/6944) · Local checks: dotnet 459/459 (ApplicationCore 16 + Infrastructure 77 + Integration 1 + RestAdapter 365) · flutter analyze 0 issues · flutter test 96/96 across all 8 PR-touched test files (full local suite was thrashed by shared-container thread limits — CI's full suite is green and covers it)