mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user