feat: review and dismiss duplicate books

Adds the per-library review screen, a muted note in the upload tray for a book
that may already be held, and the remote functions behind them. The tray note
is deliberately weaker than the file-level one, which means something stronger.
This commit is contained in:
2026-08-15 21:55:20 -04:00
parent 5047277845
commit fc6b97bf38
8 changed files with 465 additions and 12 deletions
+43
View File
@@ -8,8 +8,10 @@ import {
deleteBooksSchema, deleteBooksSchema,
editBookMetadataSchema, editBookMetadataSchema,
updateBookProgressSchema, updateBookProgressSchema,
duplicateDismissalSchema,
type Book, type Book,
type BooksUploadResult, type BooksUploadResult,
type DuplicateBookGroup,
bookFilesUpload bookFilesUpload
} from '$lib/schema/index'; } from '$lib/schema/index';
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common'; import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
@@ -153,6 +155,47 @@ export const deleteBookFiles = command(deleteBookFilesSchema, async ({ book_id,
} }
}); });
/**
* Books already in the library that look like copies of one another.
*
* Metadata only, so every group is a question rather than a verdict — which is why
* the screen it feeds offers a way to disagree.
*/
export const listDuplicateBooks = query(
stringCoerce,
async (libraryId): Promise<DuplicateBookGroup[]> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/books/duplicate-books?library_id=${libraryId}`);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
}
);
/** Record that two books are not the same book, so the pair stops being proposed. */
export const dismissDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post('/books/duplicate-books/dismissals', data);
if (!response.ok) error(response.status, detailOf(await response.text()));
});
/** Undo a dismissal, so the pair is proposed again. */
export const restoreDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(
`/books/duplicate-books/dismissals?${params.toString()}`
);
if (!response.ok) error(response.status, detailOf(await response.text()));
});
export const updateBookProgress = command( export const updateBookProgress = command(
updateBookProgressSchema, updateBookProgressSchema,
async ({ book_ids, ...data }) => { async ({ book_ids, ...data }) => {
@@ -7,7 +7,7 @@
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte'; import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte'; import { CopyCheck, House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
const libraryState = getLibraryState(); const libraryState = getLibraryState();
const bookshelfState = getBookshelfState(); const bookshelfState = getBookshelfState();
@@ -33,13 +33,22 @@
title: 'Home', title: 'Home',
icon: House, icon: House,
routeId: '/(root)/(library)/library/[libraryId]', routeId: '/(root)/(library)/library/[libraryId]',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') }) path: (id?: number) =>
resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
}, },
{ {
title: 'Library', title: 'Library',
icon: LibraryBig, icon: LibraryBig,
routeId: '/(root)/(library)/library/[libraryId]/view', routeId: '/(root)/(library)/library/[libraryId]/view',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') }) path: (id?: number) =>
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
},
{
title: 'Duplicates',
icon: CopyCheck,
routeId: '/(root)/(library)/library/[libraryId]/duplicates',
path: (id?: number) =>
resolve('/(root)/(library)/library/[libraryId]/duplicates', { libraryId: String(id ?? '') })
}, },
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] } { title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
]; ];
@@ -7,7 +7,7 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Spinner } from '$lib/components/ui/spinner/index'; import { Spinner } from '$lib/components/ui/spinner/index';
import { getUploadQueueState } from '$lib/state/upload-queue.svelte'; import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
import type { DuplicateFile } from '$lib/schema'; import type { DuplicateBook, DuplicateFile } from '$lib/schema';
import { formatFileSize } from '$lib/utils'; import { formatFileSize } from '$lib/utils';
const queue = getUploadQueueState(); const queue = getUploadQueueState();
@@ -49,6 +49,20 @@
function noteTarget(duplicates: DuplicateFile[]) { function noteTarget(duplicates: DuplicateFile[]) {
return duplicates.length === 1 ? duplicates[0].book_id : null; return duplicates.length === 1 ? duplicates[0].book_id : null;
} }
/**
* What the reader needs to know about a book that went in and may already be here.
*
* Worded weaker than the file-level "Already in …" on purpose. That one means the
* library holds these exact bytes; this one means the metadata agrees, which a
* second edition, a translation and a re-scan all do. Nothing was refused.
*/
function possibleNote(candidates: DuplicateBook[]) {
if (candidates.length > 1)
return `Might already be in your library, ${candidates.length} times`;
return `Might already be in your library — ${candidates[0].title}`;
}
</script> </script>
{#if queue.total > 0} {#if queue.total > 0}
@@ -109,6 +123,7 @@
{#each queue.jobs as job (job.id)} {#each queue.jobs as job (job.id)}
{@const duplicates = job.duplicates ?? []} {@const duplicates = job.duplicates ?? []}
{@const target = noteTarget(duplicates)} {@const target = noteTarget(duplicates)}
{@const possible = job.possibleDuplicates ?? []}
<li class="flex min-w-0 items-center gap-2 px-3 py-2"> <li class="flex min-w-0 items-center gap-2 px-3 py-2">
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-sm" title={job.label}>{job.label}</span> <span class="block truncate text-sm" title={job.label}>{job.label}</span>
@@ -136,6 +151,26 @@
<span class="truncate">{duplicateNote(duplicates)}</span> <span class="truncate">{duplicateNote(duplicates)}</span>
{/if} {/if}
</span> </span>
{:else if possible.length > 0}
<!--
A guess, not a fact, so it says so and stops there: the book was
stored, and the library's duplicates screen is where a reader
decides what to do about it.
-->
<span class="block min-w-0 text-[10px] text-muted-foreground">
{#if possible.length === 1}
<a
href={resolve('/(root)/(library)/book/[bookId]', {
bookId: String(possible[0].book_id)
})}
class="truncate underline underline-offset-2"
>
{possibleNote(possible)}
</a>
{:else}
<span class="truncate">{possibleNote(possible)}</span>
{/if}
</span>
{:else} {:else}
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums"> <span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
{formatFileSize(job.size)} {formatFileSize(job.size)}
+22
View File
@@ -15,6 +15,27 @@ export type BookProgress = components['schemas']['BookProgressRead'];
export type DuplicateFile = components['schemas']['DuplicateFileRead']; export type DuplicateFile = components['schemas']['DuplicateFileRead'];
export type BooksUploadResult = components['schemas']['BooksUploadResult']; export type BooksUploadResult = components['schemas']['BooksUploadResult'];
/**
* A stored book that may be the same book as another one.
*
* Weaker than `DuplicateFile`, and deliberately so: that one means the library holds
* these exact bytes, this one means the metadata agrees. A second edition and a
* translation both look like this, so nothing is ever refused on the strength of it.
*/
export type DuplicateBook = components['schemas']['DuplicateBookRead'];
/** A book that was imported, together with what it might be a second copy of. */
export type PossibleDuplicate = components['schemas']['PossibleDuplicateRead'];
/** Books the library holds that all look like copies of one book. */
export type DuplicateBookGroup = components['schemas']['DuplicateBookGroupRead'];
/** Mirrors DuplicateDismissal in backend/src/chitai/schemas/book.py */
export const duplicateDismissalSchema = z.object({
book_a_id: z.coerce.number(),
book_b_id: z.coerce.number()
});
export const bookQuerySchema = commonQuerySchema.extend({ export const bookQuerySchema = commonQuerySchema.extend({
libraries: stringArrayCoerce, libraries: stringArrayCoerce,
authors: stringArrayCoerce, authors: stringArrayCoerce,
@@ -106,4 +127,5 @@ export type BookQuery = z.infer<typeof bookQuerySchema>;
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>; export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
export type DeleteBook = z.infer<typeof deleteBooksSchema>; export type DeleteBook = z.infer<typeof deleteBooksSchema>;
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>; export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
export type DuplicateDismissal = z.infer<typeof duplicateDismissalSchema>;
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>; export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
+174 -4
View File
@@ -22,7 +22,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/books/duplicates": { "/books/duplicate-files": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -31,8 +31,8 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
/** CheckDuplicates */ /** CheckDuplicateFiles */
post: operations["BooksDuplicatesCheckDuplicates"]; post: operations["BooksDuplicateFilesCheckDuplicateFiles"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -75,6 +75,24 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/books/duplicate-books/dismissals": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** DismissDuplicateBooks */
post: operations["BooksDuplicateBooksDismissalsDismissDuplicateBooks"];
/** RestoreDuplicateBooks */
delete: operations["BooksDuplicateBooksDismissalsRestoreDuplicateBooks"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/books/{book_id}": { "/books/{book_id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -127,6 +145,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/books/duplicate-books": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** ListDuplicateBooks */
get: operations["BooksDuplicateBooksListDuplicateBooks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/books/progress": { "/books/progress": {
parameters: { parameters: {
query?: never; query?: never;
@@ -731,6 +766,25 @@ export interface components {
BooksUploadResult: { BooksUploadResult: {
created: components["schemas"]["BookRead"][]; created: components["schemas"]["BookRead"][];
skipped: components["schemas"]["DuplicateFileRead"][]; skipped: components["schemas"]["DuplicateFileRead"][];
possible_duplicates?: components["schemas"]["PossibleDuplicateRead"][];
};
/** DuplicateBookGroupRead */
DuplicateBookGroupRead: {
books: components["schemas"]["DuplicateBookRead"][];
};
/** DuplicateBookRead */
DuplicateBookRead: {
book_id: number;
title: string;
authors: string[];
library_id: number;
cover_image?: string | null;
matched_on: string[];
};
/** DuplicateDismissal */
DuplicateDismissal: {
book_a_id: number;
book_b_id: number;
}; };
/** DuplicateFileRead */ /** DuplicateFileRead */
DuplicateFileRead: { DuplicateFileRead: {
@@ -815,6 +869,12 @@ export interface components {
refresh_token?: string | null; refresh_token?: string | null;
expires_in?: number | null; expires_in?: number | null;
}; };
/** PossibleDuplicateRead */
PossibleDuplicateRead: {
book_id: number;
title: string;
candidates: components["schemas"]["DuplicateBookRead"][];
};
/** PublisherRead */ /** PublisherRead */
PublisherRead: { PublisherRead: {
id: number; id: number;
@@ -947,7 +1007,7 @@ export interface operations {
}; };
}; };
}; };
BooksDuplicatesCheckDuplicates: { BooksDuplicateFilesCheckDuplicateFiles: {
parameters: { parameters: {
query?: { query?: {
library_id?: number | null; library_id?: number | null;
@@ -1166,6 +1226,79 @@ export interface operations {
}; };
}; };
}; };
BooksDuplicateBooksDismissalsDismissDuplicateBooks: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DuplicateDismissal"];
};
};
responses: {
/** @description Request fulfilled, nothing follows */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad request syntax or unsupported method */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status_code: number;
detail: string;
extra?: null | {
[key: string]: unknown;
} | unknown[];
};
};
};
};
};
BooksDuplicateBooksDismissalsRestoreDuplicateBooks: {
parameters: {
query: {
book_a_id: number;
book_b_id: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Request fulfilled, nothing follows */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad request syntax or unsupported method */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status_code: number;
detail: string;
extra?: null | {
[key: string]: unknown;
} | unknown[];
};
};
};
};
};
BooksBookIdGetBookById: { BooksBookIdGetBookById: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1328,6 +1461,43 @@ export interface operations {
}; };
}; };
}; };
BooksDuplicateBooksListDuplicateBooks: {
parameters: {
query?: {
library_id?: number | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Request fulfilled, document follows */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DuplicateBookGroupRead"][];
};
};
/** @description Bad request syntax or unsupported method */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status_code: number;
detail: string;
extra?: null | {
[key: string]: unknown;
} | unknown[];
};
};
};
};
};
BooksProgressSetBookProgressBatch: { BooksProgressSetBookProgressBatch: {
parameters: { parameters: {
query: { query: {
+19 -4
View File
@@ -1,7 +1,7 @@
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import { invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import type { Book, BooksUploadResult, DuplicateFile } from '$lib/schema'; import type { Book, BooksUploadResult, DuplicateBook, DuplicateFile } from '$lib/schema';
export type UploadStatus = 'queued' | 'uploading' | 'done' | 'skipped' | 'failed'; export type UploadStatus = 'queued' | 'uploading' | 'done' | 'skipped' | 'failed';
@@ -21,6 +21,12 @@ export interface UploadJob {
* and still carries these, since the reader asked for those files too. * and still carries these, since the reader asked for those files too.
*/ */
duplicates?: DuplicateFile[]; duplicates?: DuplicateFile[];
/**
* Books already in the library that the one this job created might be a second
* copy of. Much weaker than `duplicates`: the bytes are new and only the metadata
* agrees, which a second edition and a translation both do. The book was stored.
*/
possibleDuplicates?: DuplicateBook[];
} }
export interface UploadSummary { export interface UploadSummary {
@@ -89,10 +95,17 @@ export class UploadQueueState {
* Whether the run left something the reader still has to see. * Whether the run left something the reader still has to see.
* *
* A skipped book is not a failure, but it is the only place that says the file * A skipped book is not a failure, but it is the only place that says the file
* was already here — and the only place to override it from. * was already here — and the only place to override it from. A book that may
* already be in the library counts too: it is a note the reader has to actually
* read, and a tray that clears itself after six seconds is one they never will.
*/ */
readonly needsAttention = $derived( readonly needsAttention = $derived(
this.jobs.some((job) => job.status === 'failed' || (job.duplicates?.length ?? 0) > 0) this.jobs.some(
(job) =>
job.status === 'failed' ||
(job.duplicates?.length ?? 0) > 0 ||
(job.possibleDuplicates?.length ?? 0) > 0
)
); );
#running = false; #running = false;
@@ -212,7 +225,9 @@ export class UploadQueueState {
this.#patch(index, { this.#patch(index, {
status: result.created.length === 0 ? 'skipped' : 'done', status: result.created.length === 0 ? 'skipped' : 'done',
book, book,
duplicates: result.skipped duplicates: result.skipped,
// One job is one book, so it has at most one set of candidates.
possibleDuplicates: result.possible_duplicates?.[0]?.candidates
}); });
} catch (error) { } catch (error) {
// One bad book must not take the rest of the queue with it. // One bad book must not take the rest of the queue with it.
@@ -0,0 +1,10 @@
import { listDuplicateBooks } from '$lib/api/book.remote.js';
export async function load({ params, depends }) {
// Dismissing a group re-runs this, so the card leaves the screen.
depends('app:duplicate-books');
return {
groups: await listDuplicateBooks(params.libraryId)
};
}
@@ -0,0 +1,149 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { invalidate } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { CopyCheck, Fingerprint, Type } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Empty from '$lib/components/ui/empty/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import BookImage from '$lib/components/view/book-image.svelte';
import { dismissDuplicateBooks } from '$lib/api/book.remote';
import type { DuplicateBookGroup } from '$lib/schema';
let { data }: { data: { groups: DuplicateBookGroup[] } } = $props();
// Which groups are being dismissed, so a slow round trip cannot be started twice.
let dismissing = $state<number[]>([]);
/** A group is named by its lowest book id, which the backend orders it by. */
function keyOf(group: DuplicateBookGroup) {
return group.books[0].book_id;
}
function reasonLabel(reason: string) {
if (reason === 'identifier') return 'Same identifier';
if (reason === 'title-author') return 'Same title and author';
return reason;
}
/**
* Dismiss every pairing in a group at once.
*
* A group is held together pair by pair, so saying "these are not duplicates"
* about three books means saying it about all three pairs — dismissing only the
* first would leave the rest of the group standing and the screen unchanged.
*/
async function notDuplicates(group: DuplicateBookGroup) {
const key = keyOf(group);
if (dismissing.includes(key)) return;
dismissing = [...dismissing, key];
const ids = group.books.map((book) => book.book_id);
const pairs = ids.flatMap((a, index) =>
ids.slice(index + 1).map((b) => ({ book_a_id: a, book_b_id: b }))
);
try {
await Promise.all(pairs.map((pair) => dismissDuplicateBooks(pair)));
await invalidate('app:duplicate-books');
toast.success('Marked as different books');
} catch {
toast.error('Could not mark these as different books');
} finally {
dismissing = dismissing.filter((id) => id !== key);
}
}
</script>
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4">
<div>
<h2 class="text-lg font-semibold">Possible duplicates</h2>
<p class="text-sm text-muted-foreground">
Books whose metadata matches. A second edition, a translation and a different scan of one book
all look like this, so nothing here has been changed or removed — this is a list to read, not
a problem to fix.
</p>
</div>
{#if data.groups.length === 0}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<CopyCheck />
</Empty.Media>
<Empty.Title>Nothing looks duplicated</Empty.Title>
<Empty.Description>
No two books in this library share an identifier, or a title and an author.
</Empty.Description>
</Empty.Header>
</Empty.Root>
{:else}
{#each data.groups as group (keyOf(group))}
{@const busy = dismissing.includes(keyOf(group))}
<Card.Root>
<Card.Header>
<Card.Title>{group.books.length} books look like the same book</Card.Title>
<Card.Action>
<Button
variant="outline"
size="sm"
disabled={busy}
onclick={() => notDuplicates(group)}
>
Not duplicates
</Button>
</Card.Action>
</Card.Header>
<Card.Content>
<ul class="flex flex-wrap gap-4">
{#each group.books as book (book.book_id)}
<li class="w-36">
<a
href={resolve('/(root)/(library)/book/[bookId]', {
bookId: String(book.book_id)
})}
class="group flex flex-col gap-2"
>
<span
class="block aspect-9/12 w-full overflow-hidden rounded-sm bg-muted shadow-md transition-all group-hover:brightness-75"
>
{#if book.cover_image}
<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
{/if}
</span>
<span class="line-clamp-2 font-serif text-sm group-hover:underline">
{book.title}
</span>
</a>
<p class="line-clamp-2 text-xs text-muted-foreground">
{book.authors.join(', ')}
</p>
<!-- Why this book is in the group, so the reader can judge the
evidence rather than take the grouping on trust. -->
<p class="mt-1 flex flex-wrap gap-1">
{#each book.matched_on as reason (reason)}
<Badge variant="secondary" class="gap-1 text-[10px] font-normal">
{#if reason === 'identifier'}
<Fingerprint class="size-3" />
{:else}
<Type class="size-3" />
{/if}
{reasonLabel(reason)}
</Badge>
{/each}
</p>
</li>
{/each}
</ul>
</Card.Content>
</Card.Root>
{/each}
{/if}
</div>