feat(books): rebuild duplicate books as a ledger

Sort groups by reclaimable space, pre-pick a keeper with a rule that states
its reasoning, and expand any row into a field-by-field comparison that shows
only what differs. Persist "not duplicates" so a judgement survives a rescan,
and review every copy before deleting. Reading progress no longer renders 0%.
This commit is contained in:
neon
2026-09-20 20:11:34 +00:00
parent 0007a9d84f
commit c239cfa2dd
57 changed files with 24922 additions and 1139 deletions
@@ -0,0 +1,21 @@
CREATE TABLE "book_duplicate_dismissals" (
"user_id" integer NOT NULL,
"book_id_a" integer NOT NULL,
"book_id_b" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_duplicate_dismissals_user_id_book_id_a_book_id_b_pk" PRIMARY KEY("user_id","book_id_a","book_id_b"),
CONSTRAINT "book_duplicate_dismissals_order_chk" CHECK ("book_duplicate_dismissals"."book_id_a" < "book_duplicate_dismissals"."book_id_b")
);
--> statement-breakpoint
ALTER TABLE "book_duplicate_groups" ADD COLUMN "confidence" smallint DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "book_duplicate_groups" ADD COLUMN "member_bytes_total" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "book_duplicate_groups" ADD COLUMN "member_bytes_max" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "book_duplicate_scans" ADD COLUMN "total_extra_copies" integer;--> statement-breakpoint
ALTER TABLE "book_duplicate_scans" ADD COLUMN "total_reclaimable_bytes" bigint;--> statement-breakpoint
ALTER TABLE "book_duplicate_dismissals" ADD CONSTRAINT "book_duplicate_dismissals_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_duplicate_dismissals" ADD CONSTRAINT "book_duplicate_dismissals_book_id_a_books_id_fk" FOREIGN KEY ("book_id_a") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_duplicate_dismissals" ADD CONSTRAINT "book_duplicate_dismissals_book_id_b_books_id_fk" FOREIGN KEY ("book_id_b") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "book_duplicate_dismissals_user_created_idx" ON "book_duplicate_dismissals" USING btree ("user_id","created_at" desc);--> statement-breakpoint
CREATE INDEX "book_duplicate_groups_scan_reclaimable_idx" ON "book_duplicate_groups" USING btree ("scan_id",("member_bytes_total" - "member_bytes_max") desc);--> statement-breakpoint
CREATE INDEX "book_duplicate_groups_scan_confidence_idx" ON "book_duplicate_groups" USING btree ("scan_id","confidence");--> statement-breakpoint
ALTER TABLE "book_duplicate_groups" ADD CONSTRAINT "book_duplicate_groups_confidence_chk" CHECK ("book_duplicate_groups"."confidence" between 1 and 4);
File diff suppressed because it is too large Load Diff
@@ -659,6 +659,13 @@
"when": 1789366529464,
"tag": "0093_add_device_auth_sessions",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1789930465236,
"tag": "0094_add_book_duplicate_dismissals_and_sort_keys",
"breakpoints": true
}
]
}
+49 -1
View File
@@ -1,4 +1,18 @@
import { check, index, integer, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import {
bigint,
check,
index,
integer,
pgTable,
primaryKey,
real,
serial,
smallint,
text,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -18,6 +32,8 @@ export const bookDuplicateScans = pgTable(
processedBooks: integer('processed_books').notNull().default(0),
totalBooks: integer('total_books'),
totalGroups: integer('total_groups'),
totalExtraCopies: integer('total_extra_copies'),
totalReclaimableBytes: bigint('total_reclaimable_bytes', { mode: 'number' }),
errorCode: varchar('error_code', { length: 50 }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
@@ -62,11 +78,42 @@ export const bookDuplicateGroups = pgTable(
reasons: text('reasons').array().notNull(),
maxTitleSimilarity: real('max_title_similarity'),
memberCount: integer('member_count').notNull(),
confidence: smallint('confidence').notNull().default(1),
memberBytesTotal: bigint('member_bytes_total', { mode: 'number' }).notNull().default(0),
memberBytesMax: bigint('member_bytes_max', { mode: 'number' }).notNull().default(0),
},
(t) => [
uniqueIndex('book_duplicate_groups_scan_root_uidx').on(t.scanId, t.rootBookId),
index('book_duplicate_groups_scan_id_idx').on(t.scanId, t.id),
index('book_duplicate_groups_scan_member_count_idx').on(t.scanId, t.memberCount),
index('book_duplicate_groups_scan_reclaimable_idx').on(t.scanId, sql`(${t.memberBytesTotal} - ${t.memberBytesMax}) desc`),
index('book_duplicate_groups_scan_confidence_idx').on(t.scanId, t.confidence),
check('book_duplicate_groups_confidence_chk', sql`${t.confidence} between 1 and 4`),
],
);
/**
* A pair the user has judged not to be duplicates. Stored per user and outside any scan so the
* judgement survives a rescan; the scan deletes matching pairs before groups are formed.
*/
export const bookDuplicateDismissals = pgTable(
'book_duplicate_dismissals',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookIdA: integer('book_id_a')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
bookIdB: integer('book_id_b')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.userId, t.bookIdA, t.bookIdB] }),
index('book_duplicate_dismissals_user_created_idx').on(t.userId, sql`${t.createdAt} desc`),
check('book_duplicate_dismissals_order_chk', sql`${t.bookIdA} < ${t.bookIdB}`),
],
);
@@ -112,3 +159,4 @@ export const bookDuplicateGroupMembers = pgTable(
);
export type BookDuplicateScanRow = typeof bookDuplicateScans.$inferSelect;
export type BookDuplicateDismissalRow = typeof bookDuplicateDismissals.$inferSelect;
@@ -1,11 +1,11 @@
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookDuplicatesService } from './book-duplicates.service';
import { CreateBookDuplicateScanDto, ListBookDuplicateGroupsDto } from './dto/book-duplicate.dto';
import { CreateBookDuplicateDismissalDto, CreateBookDuplicateScanDto, ListBookDuplicateGroupsDto } from './dto/book-duplicate.dto';
@Controller('book-duplicates')
@RequirePermission(Permission.LibraryDeleteBooks)
@@ -31,4 +31,25 @@ export class BookDuplicatesController {
getGroups(@Param('scanId', ParseIntPipe) scanId: number, @Query() dto: ListBookDuplicateGroupsDto, @CurrentUser() user: RequestUser) {
return this.service.getGroups(scanId, dto, user);
}
@Get('dismissals')
listDismissals(@CurrentUser() user: RequestUser) {
return this.service.listDismissals(user);
}
@Post('dismissals')
@HttpCode(HttpStatus.NO_CONTENT)
dismissGroup(@Body() dto: CreateBookDuplicateDismissalDto, @CurrentUser() user: RequestUser) {
return this.service.dismissGroup(dto, user);
}
@Delete('dismissals/:bookIdA/:bookIdB')
@HttpCode(HttpStatus.NO_CONTENT)
restoreDismissal(
@Param('bookIdA', ParseIntPipe) bookIdA: number,
@Param('bookIdB', ParseIntPipe) bookIdB: number,
@CurrentUser() user: RequestUser,
) {
return this.service.restoreDismissal(bookIdA, bookIdB, user);
}
}
@@ -22,6 +22,7 @@ function queryBuilder<T>(result: T) {
'returning',
'set',
'innerJoin',
'leftJoin',
'groupBy',
'having',
'onConflictDoNothing',
@@ -217,10 +218,40 @@ describe('BookDuplicatesRepository', () => {
const repo = new BookDuplicatesRepository(db as never);
const user = { id: 7, isSuperuser: true, contentFilters: EMPTY_CONTENT_FILTER_RULES } as never;
await expect(repo.findGroups(9, 1, 20, [2], user, 'isbn')).resolves.toEqual({ groups, total: 1 });
await expect(repo.findGroups(9, 1, 20, [2], user, 'isbn', 'reclaimable', 'desc')).resolves.toEqual({ groups, total: 1 });
await expect(repo.findPairs([])).resolves.toEqual([]);
await expect(repo.findPairs([5])).resolves.toEqual([{ groupId: 5 }]);
await expect(repo.findCandidatePreviews([], [2], user)).resolves.toEqual([]);
await expect(repo.findCandidatePreviews([5], [], user)).resolves.toEqual([]);
});
it('orders by reclaimable bytes, copies, confidence or title', async () => {
const user = { id: 7, isSuperuser: true, contentFilters: EMPTY_CONTENT_FILTER_RULES } as never;
const orderings: Record<string, string> = {};
for (const sortBy of ['reclaimable', 'copies', 'confidence', 'title'] as const) {
const db = mockDb({ selectResults: [[], [{ count: 0 }], []] });
const repo = new BookDuplicatesRepository(db as never);
await repo.findGroups(9, 1, 20, [2], user, undefined, sortBy, 'desc');
const builder = (db.select as ReturnType<typeof vi.fn>).mock.results.at(-1)!.value as Record<string, ReturnType<typeof vi.fn>>;
orderings[sortBy] = builder.orderBy.mock.calls[0]!.map((clause) => renderSql(clause).sql).join(' | ');
}
expect(orderings.reclaimable).toContain('member_bytes_total');
expect(orderings.reclaimable).toContain('member_bytes_max');
expect(orderings.copies).toContain('member_count');
expect(orderings.confidence).toContain('confidence');
expect(orderings.title).toContain('lower(coalesce(');
});
it('deletes dismissed pairs for the scanning user before groups are formed', async () => {
const execute = vi.fn().mockResolvedValue({ rowCount: 3 });
const repo = new BookDuplicatesRepository({ execute } as never);
await expect(repo.deleteDismissedPairs(9, 7)).resolves.toBe(3);
const query = renderSql(execute.mock.calls[0]![0]).sql.replace(/\s+/g, ' ');
expect(query).toContain('DELETE FROM "book_duplicate_pairs"');
expect(query).toContain('"book_duplicate_dismissals"');
expect(query).toContain('dismissal.book_id_a = pair.book_id_a');
});
});
@@ -1,13 +1,15 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, desc, eq, inArray, ne, sql, type SQL } from 'drizzle-orm';
import { and, asc, count, desc, eq, inArray, ne, sql, type SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { BookDuplicateMatchReason } from '@bookorbit/types';
import type { BookDuplicateGroupSort, BookDuplicateMatchReason, BookDuplicateSortOrder } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import type { RequestUser } from '../../common/types/request-user';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
bookDuplicateDismissals,
bookDuplicateGroupMembers,
bookDuplicateGroups,
bookDuplicatePairs,
@@ -407,11 +409,40 @@ export class BookDuplicatesRepository {
CROSS JOIN LATERAL unnest(pair.reasons) AS reason
WHERE pair.scan_id = ${scanId}
GROUP BY component.root_id
), member_bytes AS (
SELECT
component.root_id,
component.book_id,
COALESCE((
SELECT sum(member_file.size_bytes)
FROM ${bookFiles} member_file
WHERE member_file.book_id = component.book_id AND member_file.role = 'content'
), 0)::bigint AS bytes
FROM tmp_book_duplicate_components component
), byte_stats AS (
SELECT root_id, sum(bytes)::bigint AS total_bytes, max(bytes)::bigint AS max_bytes
FROM member_bytes
GROUP BY root_id
)
INSERT INTO ${bookDuplicateGroups} (scan_id, root_book_id, reasons, max_title_similarity, member_count)
SELECT CAST(${scanId} AS integer), member_counts.root_id, reason_stats.reasons, reason_stats.max_title_similarity, member_counts.member_count
INSERT INTO ${bookDuplicateGroups}
(scan_id, root_book_id, reasons, max_title_similarity, member_count, confidence, member_bytes_total, member_bytes_max)
SELECT
CAST(${scanId} AS integer),
member_counts.root_id,
reason_stats.reasons,
reason_stats.max_title_similarity,
member_counts.member_count,
CASE
WHEN 'file_hash' = ANY(reason_stats.reasons) THEN 4
WHEN 'isbn' = ANY(reason_stats.reasons) THEN 3
WHEN 'exact_metadata' = ANY(reason_stats.reasons) THEN 2
ELSE 1
END,
byte_stats.total_bytes,
byte_stats.max_bytes
FROM member_counts
JOIN reason_stats USING (root_id)
JOIN byte_stats USING (root_id)
`);
await tx.execute(sql`
@@ -451,7 +482,16 @@ export class BookDuplicatesRepository {
});
}
async findGroups(scanId: number, page: number, pageSize: number, libraryIds: number[], user: RequestUser, reason?: BookDuplicateMatchReason) {
async findGroups(
scanId: number,
page: number,
pageSize: number,
libraryIds: number[],
user: RequestUser,
reason: BookDuplicateMatchReason | undefined,
sortBy: BookDuplicateGroupSort,
order: BookDuplicateSortOrder,
) {
const accessibleGroupIds = this.db
.select({ groupId: bookDuplicateGroupMembers.groupId })
.from(bookDuplicateGroupMembers)
@@ -463,15 +503,113 @@ export class BookDuplicatesRepository {
const where = and(eq(bookDuplicateGroups.scanId, scanId), inArray(bookDuplicateGroups.id, accessibleGroupIds), reasonFilter)!;
const [countRow] = await this.db.select({ count: count() }).from(bookDuplicateGroups).where(where);
const groups = await this.db
.select()
.select({
id: bookDuplicateGroups.id,
reasons: bookDuplicateGroups.reasons,
maxTitleSimilarity: bookDuplicateGroups.maxTitleSimilarity,
memberCount: bookDuplicateGroups.memberCount,
memberBytesTotal: bookDuplicateGroups.memberBytesTotal,
memberBytesMax: bookDuplicateGroups.memberBytesMax,
})
.from(bookDuplicateGroups)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookDuplicateGroups.rootBookId))
.where(where)
.orderBy(desc(bookDuplicateGroups.memberCount), bookDuplicateGroups.id)
.orderBy(...this.groupOrderBy(sortBy, order))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { groups, total: countRow?.count ?? 0 };
}
private groupOrderBy(sortBy: BookDuplicateGroupSort, order: BookDuplicateSortOrder): SQL[] {
const direction = order === 'asc' ? asc : desc;
const reclaimable = sql`(${bookDuplicateGroups.memberBytesTotal} - ${bookDuplicateGroups.memberBytesMax})`;
const tiebreak = sql`${bookDuplicateGroups.id} asc`;
switch (sortBy) {
case 'copies':
return [direction(bookDuplicateGroups.memberCount), desc(reclaimable), tiebreak];
case 'confidence':
return [direction(bookDuplicateGroups.confidence), desc(reclaimable), tiebreak];
case 'title':
return [direction(sql`lower(coalesce(${bookMetadata.title}, ''))`), tiebreak];
default:
return [direction(reclaimable), desc(bookDuplicateGroups.memberCount), tiebreak];
}
}
/**
* Pairs the user has already judged, removed before groups are formed so a dismissal survives
* a rescan and does not just hide a group that keeps coming back.
*/
async deleteDismissedPairs(scanId: number, userId: number): Promise<number> {
const result = await this.db.execute(sql`
DELETE FROM ${bookDuplicatePairs} pair
USING ${bookDuplicateDismissals} dismissal
WHERE pair.scan_id = ${scanId}
AND dismissal.user_id = ${userId}
AND dismissal.book_id_a = pair.book_id_a
AND dismissal.book_id_b = pair.book_id_b
`);
return result.rowCount ?? 0;
}
async computeScanTotals(scanId: number): Promise<{ totalExtraCopies: number; totalReclaimableBytes: number }> {
const [row] = await this.db
.select({
extraCopies: sql<string>`COALESCE(sum(${bookDuplicateGroups.memberCount} - 1), 0)`,
reclaimable: sql<string>`COALESCE(sum(${bookDuplicateGroups.memberBytesTotal} - ${bookDuplicateGroups.memberBytesMax}), 0)`,
})
.from(bookDuplicateGroups)
.where(eq(bookDuplicateGroups.scanId, scanId));
return { totalExtraCopies: Number(row?.extraCopies ?? 0), totalReclaimableBytes: Number(row?.reclaimable ?? 0) };
}
async findGroupMemberIds(scanId: number, groupId: number): Promise<number[]> {
const rows = await this.db
.select({ bookId: bookDuplicateGroupMembers.bookId })
.from(bookDuplicateGroupMembers)
.where(and(eq(bookDuplicateGroupMembers.scanId, scanId), eq(bookDuplicateGroupMembers.groupId, groupId)));
return rows.map((row) => row.bookId);
}
async insertDismissals(userId: number, pairs: { bookIdA: number; bookIdB: number }[]): Promise<void> {
if (pairs.length === 0) return;
await this.db
.insert(bookDuplicateDismissals)
.values(pairs.map((pair) => ({ userId, bookIdA: pair.bookIdA, bookIdB: pair.bookIdB })))
.onConflictDoNothing();
}
async deleteDismissal(userId: number, bookIdA: number, bookIdB: number): Promise<number> {
const result = await this.db
.delete(bookDuplicateDismissals)
.where(
and(eq(bookDuplicateDismissals.userId, userId), eq(bookDuplicateDismissals.bookIdA, bookIdA), eq(bookDuplicateDismissals.bookIdB, bookIdB)),
);
return result.rowCount ?? 0;
}
async listDismissals(
userId: number,
limit: number,
): Promise<{ bookIdA: number; bookIdB: number; titleA: string | null; titleB: string | null; createdAt: Date }[]> {
const metadataA = alias(bookMetadata, 'metadata_a');
const metadataB = alias(bookMetadata, 'metadata_b');
return this.db
.select({
bookIdA: bookDuplicateDismissals.bookIdA,
bookIdB: bookDuplicateDismissals.bookIdB,
titleA: metadataA.title,
titleB: metadataB.title,
createdAt: bookDuplicateDismissals.createdAt,
})
.from(bookDuplicateDismissals)
.leftJoin(metadataA, eq(metadataA.bookId, bookDuplicateDismissals.bookIdA))
.leftJoin(metadataB, eq(metadataB.bookId, bookDuplicateDismissals.bookIdB))
.where(eq(bookDuplicateDismissals.userId, userId))
.orderBy(desc(bookDuplicateDismissals.createdAt))
.limit(limit);
}
async findPairs(groupIds: number[]) {
if (groupIds.length === 0) return [];
return this.db.select().from(bookDuplicatePairs).where(inArray(bookDuplicatePairs.groupId, groupIds));
@@ -45,6 +45,12 @@ function setup(accessibleLibraryIds = [2]) {
createExactPairs: vi.fn().mockResolvedValue(undefined),
createFuzzyPairs: vi.fn().mockResolvedValue(undefined),
finalizeGroups: vi.fn().mockResolvedValue(0),
deleteDismissedPairs: vi.fn().mockResolvedValue(0),
computeScanTotals: vi.fn().mockResolvedValue({ totalExtraCopies: 0, totalReclaimableBytes: 0 }),
findGroupMemberIds: vi.fn().mockResolvedValue([]),
insertDismissals: vi.fn().mockResolvedValue(undefined),
deleteDismissal: vi.fn().mockResolvedValue(1),
listDismissals: vi.fn().mockResolvedValue([]),
deleteScanKeys: vi.fn().mockResolvedValue(undefined),
findGroups: vi.fn().mockResolvedValue({ groups: [], total: 0 }),
findPairs: vi.fn().mockResolvedValue([]),
@@ -108,7 +114,7 @@ describe('BookDuplicatesService', () => {
const { service, repo } = setup();
repo.findScan.mockResolvedValue(scanRow({ status: 'completed', totalBooks: 3, processedBooks: 3, totalGroups: 1 }));
repo.findGroups.mockResolvedValue({
groups: [{ id: 5, reasons: ['isbn'], maxTitleSimilarity: null }],
groups: [{ id: 5, reasons: ['isbn'], maxTitleSimilarity: null, memberCount: 2, memberBytesTotal: 900, memberBytesMax: 500 }],
total: 1,
});
repo.findPairs.mockResolvedValue([
@@ -139,10 +145,42 @@ describe('BookDuplicatesService', () => {
})),
);
const result = await service.getGroups(11, { page: 1, pageSize: 20 }, user);
const result = await service.getGroups(11, { page: 1, pageSize: 20, sortBy: 'reclaimable', order: 'desc' }, user);
expect(repo.findGroups).toHaveBeenCalledWith(11, 1, 20, [2], user, undefined);
expect(repo.findGroups).toHaveBeenCalledWith(11, 1, 20, [2], user, undefined, 'reclaimable', 'desc');
expect(result.groups[0]?.pairs).toEqual([{ bookIdA: 1, bookIdB: 2, reasons: ['isbn'], titleSimilarity: null }]);
expect(result.groups[0]?.reclaimableBytes).toBe(400);
});
it('dismisses every pair in a group so the judgement survives a rescan', async () => {
const { service, repo } = setup();
repo.findScan.mockResolvedValue(scanRow({ status: 'completed' }));
repo.findGroupMemberIds.mockResolvedValue([9, 4, 7]);
await service.dismissGroup({ scanId: 11, groupId: 5 }, user);
expect(repo.insertDismissals).toHaveBeenCalledWith(7, [
{ bookIdA: 4, bookIdB: 7 },
{ bookIdA: 4, bookIdB: 9 },
{ bookIdA: 7, bookIdB: 9 },
]);
});
it('refuses to dismiss a group that is not in the scan', async () => {
const { service, repo } = setup();
repo.findScan.mockResolvedValue(scanRow({ status: 'completed' }));
repo.findGroupMemberIds.mockResolvedValue([9]);
await expect(service.dismissGroup({ scanId: 11, groupId: 5 }, user)).rejects.toThrow(NotFoundException);
expect(repo.insertDismissals).not.toHaveBeenCalled();
});
it('normalises pair order when a dismissal is restored', async () => {
const { service, repo } = setup();
await service.restoreDismissal(9, 4, user);
expect(repo.deleteDismissal).toHaveBeenCalledWith(7, 4, 9);
});
it('cleans partial artifacts when a background scan fails', async () => {
@@ -169,6 +207,8 @@ describe('BookDuplicatesService', () => {
expect(repo.insertExactMetadataKeys).toHaveBeenCalledWith(11, [2], user);
expect(repo.createExactPairs).toHaveBeenCalledWith(11);
expect(repo.createFuzzyPairs).toHaveBeenCalledWith(11, [2], user, 85);
expect(repo.deleteDismissedPairs).toHaveBeenCalledWith(11, 7);
expect(repo.updateScan).toHaveBeenCalledWith(11, expect.objectContaining({ status: 'completed', totalExtraCopies: 0, totalReclaimableBytes: 0 }));
expect(repo.deleteScanKeys).toHaveBeenCalledWith(11);
});
@@ -176,7 +216,7 @@ describe('BookDuplicatesService', () => {
const { service, repo } = setup([3]);
repo.findScan.mockResolvedValue(scanRow({ status: 'completed' }));
await expect(service.getGroups(11, { page: 1, pageSize: 20 }, user)).rejects.toThrow(ForbiddenException);
await expect(service.getGroups(11, { page: 1, pageSize: 20, sortBy: 'reclaimable', order: 'desc' }, user)).rejects.toThrow(ForbiddenException);
expect(repo.findGroups).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException, OnApplicationBootstrap } from '@nestjs/common';
import type {
BookDuplicateCandidate,
BookDuplicateDismissal,
BookDuplicateGroupsResponse,
BookDuplicateMatchReason,
BookDuplicateScan,
@@ -15,9 +16,10 @@ import { parsePgTimestamptz } from '../../common/utils/pg-timestamp.utils';
import { LibraryService } from '../library/library.service';
import { canonicalizeIsbn, mediaFamilyForFormat } from './book-duplicate-normalize';
import { BookDuplicatesRepository } from './book-duplicates.repository';
import type { CreateBookDuplicateScanDto, ListBookDuplicateGroupsDto } from './dto/book-duplicate.dto';
import type { CreateBookDuplicateDismissalDto, CreateBookDuplicateScanDto, ListBookDuplicateGroupsDto } from './dto/book-duplicate.dto';
const ISBN_BATCH_SIZE = 500;
const DISMISSAL_LIST_LIMIT = 200;
@Injectable()
export class BookDuplicatesService implements OnApplicationBootstrap {
@@ -68,7 +70,7 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
if (scan.status !== 'completed') throw new ConflictException('Duplicate book scan is not complete');
await this.assertScopeStillAccessible(scan.libraryIds, user);
const { groups, total } = await this.repo.findGroups(scanId, dto.page, dto.pageSize, scan.libraryIds, user, dto.reason);
const { groups, total } = await this.repo.findGroups(scanId, dto.page, dto.pageSize, scan.libraryIds, user, dto.reason, dto.sortBy, dto.order);
const groupIds = groups.map((group) => group.id);
const [pairs, previews] = await Promise.all([this.repo.findPairs(groupIds), this.repo.findCandidatePreviews(groupIds, scan.libraryIds, user)]);
@@ -122,6 +124,7 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
id: group.id,
reasons: group.reasons as BookDuplicateMatchReason[],
maxTitleSimilarity: group.maxTitleSimilarity,
reclaimableBytes: Math.max(0, group.memberBytesTotal - group.memberBytesMax),
books: previewsByGroup.get(group.id) ?? [],
pairs: (pairsByGroup.get(group.id) ?? []).map((pair) => ({
bookIdA: pair.bookIdA,
@@ -137,6 +140,40 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
};
}
async dismissGroup(dto: CreateBookDuplicateDismissalDto, user: RequestUser): Promise<void> {
const scan = await this.findOwnedScan(dto.scanId, user);
await this.assertScopeStillAccessible(scan.libraryIds, user);
const bookIds = await this.repo.findGroupMemberIds(dto.scanId, dto.groupId);
if (bookIds.length < 2) throw new NotFoundException('Duplicate group not found');
const pairs: { bookIdA: number; bookIdB: number }[] = [];
const sorted = [...bookIds].sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) pairs.push({ bookIdA: sorted[i]!, bookIdB: sorted[j]! });
}
await this.repo.insertDismissals(user.id, pairs);
this.logger.log(
`[book_duplicates.dismiss] [end] scanId=${dto.scanId} groupId=${dto.groupId} userId=${user.id} pairCount=${pairs.length} - duplicate group dismissed`,
);
}
async listDismissals(user: RequestUser): Promise<BookDuplicateDismissal[]> {
const rows = await this.repo.listDismissals(user.id, DISMISSAL_LIST_LIMIT);
return rows.map((row) => ({
bookIdA: row.bookIdA,
bookIdB: row.bookIdB,
titleA: row.titleA,
titleB: row.titleB,
createdAt: row.createdAt.toISOString(),
}));
}
async restoreDismissal(bookIdA: number, bookIdB: number, user: RequestUser): Promise<void> {
const [low, high] = bookIdA < bookIdB ? [bookIdA, bookIdB] : [bookIdB, bookIdA];
const removed = await this.repo.deleteDismissal(user.id, low, high);
if (removed === 0) throw new NotFoundException('Dismissed pair not found');
}
private async drainQueue(): Promise<void> {
if (this.workerRunning) return;
this.workerRunning = true;
@@ -165,7 +202,14 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
await this.repo.updateScan(scanId, { status: 'running', totalBooks, processedBooks: 0, errorCode: null });
if (totalBooks === 0) {
await this.repo.updateScan(scanId, { status: 'completed', processedBooks: 0, totalGroups: 0, completedAt: new Date() });
await this.repo.updateScan(scanId, {
status: 'completed',
processedBooks: 0,
totalGroups: 0,
totalExtraCopies: 0,
totalReclaimableBytes: 0,
completedAt: new Date(),
});
await this.repo.deleteOlderScans(user.id, scanId);
this.logger.log(
`[${event}] [end] scanId=${scanId} userId=${user.id} totalBooks=0 totalGroups=0 durationMs=${Date.now() - startedAt} - duplicate scan completed`,
@@ -196,19 +240,24 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
await this.repo.createExactPairs(scanId);
await this.updateProgress(scanId, totalBooks, 65);
await this.repo.createFuzzyPairs(scanId, scan.libraryIds, user, scan.similarityPercent);
await this.updateProgress(scanId, totalBooks, 80);
const dismissedPairs = await this.repo.deleteDismissedPairs(scanId, user.id);
await this.updateProgress(scanId, totalBooks, 85);
const totalGroups = await this.repo.finalizeGroups(scanId);
const totals = await this.repo.computeScanTotals(scanId);
await this.repo.deleteScanKeys(scanId);
await this.repo.updateScan(scanId, {
status: 'completed',
processedBooks: totalBooks,
totalGroups,
totalExtraCopies: totals.totalExtraCopies,
totalReclaimableBytes: totals.totalReclaimableBytes,
completedAt: new Date(),
});
await this.repo.deleteOlderScans(user.id, scanId);
this.logger.log(
`[${event}] [end] scanId=${scanId} userId=${user.id} totalBooks=${totalBooks} totalGroups=${totalGroups} durationMs=${Date.now() - startedAt} - duplicate scan completed`,
`[${event}] [end] scanId=${scanId} userId=${user.id} totalBooks=${totalBooks} totalGroups=${totalGroups} dismissedPairs=${dismissedPairs} reclaimableBytes=${totals.totalReclaimableBytes} durationMs=${Date.now() - startedAt} - duplicate scan completed`,
);
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
@@ -247,6 +296,8 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
processedBooks: number;
totalBooks: number | null;
totalGroups: number | null;
totalExtraCopies: number | null;
totalReclaimableBytes: number | null;
errorCode: string | null;
createdAt: Date;
completedAt: Date | null;
@@ -269,6 +320,8 @@ export class BookDuplicatesService implements OnApplicationBootstrap {
totalBooks: scan.totalBooks,
progressPercent,
totalGroups: scan.totalGroups,
totalExtraCopies: scan.totalExtraCopies,
totalReclaimableBytes: scan.totalReclaimableBytes === null ? null : Number(scan.totalReclaimableBytes),
errorCode: scan.errorCode,
createdAt: scan.createdAt.toISOString(),
completedAt: scan.completedAt?.toISOString() ?? null,
@@ -1,6 +1,13 @@
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { BOOK_DUPLICATE_MATCH_REASONS, type BookDuplicateMatchReason } from '@bookorbit/types';
import {
BOOK_DUPLICATE_GROUP_SORTS,
BOOK_DUPLICATE_MATCH_REASONS,
BOOK_DUPLICATE_SORT_ORDERS,
type BookDuplicateGroupSort,
type BookDuplicateMatchReason,
type BookDuplicateSortOrder,
} from '@bookorbit/types';
export class CreateBookDuplicateScanDto {
@IsOptional()
@@ -31,4 +38,22 @@ export class ListBookDuplicateGroupsDto {
@IsOptional()
@IsIn(BOOK_DUPLICATE_MATCH_REASONS)
reason?: BookDuplicateMatchReason;
@IsOptional()
@IsIn(BOOK_DUPLICATE_GROUP_SORTS)
sortBy: BookDuplicateGroupSort = 'reclaimable';
@IsOptional()
@IsIn(BOOK_DUPLICATE_SORT_ORDERS)
order: BookDuplicateSortOrder = 'desc';
}
export class CreateBookDuplicateDismissalDto {
@IsInt()
@Min(1)
scanId!: number;
@IsInt()
@Min(1)
groupId!: number;
}
@@ -1,5 +1,5 @@
{
"totalRoutes": 595,
"totalRoutes": 598,
"byPermission": {
"(none)": 259,
"Permission.ManageMetadataConfig": 34,
@@ -17,7 +17,7 @@
"Permission.OpdsAccess": 4,
"Permission.ViewAuditLog": 1,
"Permission.ViewUserActivity": 6,
"Permission.LibraryDeleteBooks": 11,
"Permission.LibraryDeleteBooks": 14,
"Permission.LibraryDownload": 4,
"Permission.LibraryUpload": 2,
"Permission.ManageBookDock": 3,
@@ -1,4 +1,40 @@
[
{
"file": "server/src/modules/book-duplicates/book-duplicates.controller.ts",
"className": "BookDuplicatesController",
"methodName": "listDismissals",
"httpMethod": "GET",
"path": "/book-duplicates/dismissals",
"permissions": ["Permission.LibraryDeleteBooks"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-duplicates/book-duplicates.controller.ts",
"className": "BookDuplicatesController",
"methodName": "dismissGroup",
"httpMethod": "POST",
"path": "/book-duplicates/dismissals",
"permissions": ["Permission.LibraryDeleteBooks"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-duplicates/book-duplicates.controller.ts",
"className": "BookDuplicatesController",
"methodName": "restoreDismissal",
"httpMethod": "DELETE",
"path": "/book-duplicates/dismissals/:bookIdA/:bookIdB",
"permissions": ["Permission.LibraryDeleteBooks"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-dock/book-dock.controller.ts",
"className": "BookDockController",