Compare commits

...

4 Commits

Author SHA1 Message Date
kikootwo 01b59fae9d Bump package version to 1.1.3
Update package.json version from 1.1.2 to 1.1.3. No other changes in this diff; version increment for the next release/patch.
2026-03-05 17:14:45 -05:00
kikootwo 137e2b5607 Propagate and use customSearchTerms for ebooks
Persist and apply customSearchTerms across ebook workflows and searches. Updated admin search-terms PATCH to enqueue addSearchEbookJob for ebook requests. Included customSearchTerms when creating ebook request records in audiobooks/[asin]/fetch-ebook, audiobooks/[asin]/select-ebook and requests/[id]/fetch-ebook. Reworked requests/[id]/select-ebook to handle being passed either an audiobook or ebook request (resolve parent audiobook, reuse existing ebook request if present) and to propagate parent.customSearchTerms when creating new ebook requests. Modified search-ebook.processor to read customSearchTerms from the request record, use it as the effective search title (with logging), and pass the modified audiobook title into Anna's Archive and indexer searches so custom terms are honored.
2026-03-05 17:14:26 -05:00
kikootwo f09931f352 Bump package version to 1.1.2
Update package.json version from 1.1.1 to 1.1.2 to mark a new patch release.
2026-03-05 16:46:09 -05:00
kikootwo 5b4aa3fa15 Add data-migration tracking; prevent subtitle dedup
Track and run run-once SQL data migrations: entrypoint now checks _data_migrations before executing each prisma data-migration file, records successful runs, and skips already-applied scripts. Adds a Prisma DataMigration model mapped to _data_migrations and a new reset-works-table.sql migration to clear work tables for a dedup rebuild. Also improves dedup logic: extractSubtitle and subtitle-compatibility checks are added so series entries like "Series: Book A" vs "Series: Book B" are not collapsed, with accompanying unit tests for extraction and behavior.
2026-03-05 16:45:56 -05:00
12 changed files with 191 additions and 29 deletions
+17 -3
View File
@@ -403,12 +403,26 @@ echo "🔄 Running Prisma migrations..."
cd /app cd /app
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..." su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
# Run data migrations (idempotent SQL scripts that prisma db push doesn't handle) # Run data migrations (run-once SQL scripts tracked in _data_migrations table)
echo "🔄 Running data migrations..." echo "🔄 Running data migrations..."
for sql_file in /app/prisma/data-migrations/*.sql; do for sql_file in /app/prisma/data-migrations/*.sql; do
if [ -f "$sql_file" ]; then if [ -f "$sql_file" ]; then
echo " Running $(basename "$sql_file")..." migration_name=$(basename "$sql_file")
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'" || echo "⚠️ Data migration $(basename "$sql_file") may have failed, continuing..."
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
if [ "$already_run" = "1" ]; then
echo " Skipping $migration_name (already executed)"
continue
fi
echo " Running $migration_name..."
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
echo "$migration_name completed"
else
echo "⚠️ Data migration $migration_name failed, will retry on next start"
fi
fi fi
done done
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.1.1", "version": "1.1.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,7 @@
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
-- because subtitle stripping collapsed them to the same base title.
-- The works table auto-rebuilds from dedup logic as users browse.
DELETE FROM work_asins;
DELETE FROM works;
+12
View File
@@ -718,3 +718,15 @@ model AudibleCacheCategory {
@@index([categoryId, rank]) @@index([categoryId, rank])
@@map("audible_cache_categories") @@map("audible_cache_categories")
} }
// ============================================================================
// DATA MIGRATION TRACKING
// Tracks which data migration SQL scripts have been executed (run-once).
// ============================================================================
model DataMigration {
name String @id
executedAt DateTime @default(now()) @map("executed_at")
@@map("_data_migrations")
}
@@ -100,15 +100,21 @@ export async function PATCH(
}, },
}); });
// Queue search job // Queue search job based on request type
const { getJobQueueService } = await import('@/lib/services/job-queue.service'); const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, { const audiobookData = {
id: existingRequest.audiobook.id, id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title, title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author, author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined, asin: existingRequest.audiobook.audibleAsin || undefined,
}); };
if (existingRequest.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
searchTriggered = true; searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id }); logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
@@ -260,6 +260,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, // Link to parent if exists parentRequestId: availableRequest?.id || null, // Link to parent if exists
status: 'awaiting_approval', status: 'awaiting_approval',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, parentRequestId: availableRequest?.id || null,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval', status: 'awaiting_approval',
progress: 0, progress: 0,
selectedTorrent: selectedEbook as any, selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`); logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, parentRequestId: availableRequest?.id || null,
status: 'searching', status: 'searching',
progress: 0, progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
}, },
}); });
logger.info(`Created new ebook request ${ebookRequest.id}`); logger.info(`Created new ebook request ${ebookRequest.id}`);
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId, parentRequestId,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
}, },
}); });
@@ -52,17 +52,32 @@ export async function POST(
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 }); return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
} }
// Get the parent audiobook request // Get the request - could be an audiobook request or an existing ebook request
const parentRequest = await prisma.request.findUnique({ const foundRequest = await prisma.request.findUnique({
where: { id: parentRequestId }, where: { id: parentRequestId },
include: { audiobook: true }, include: { audiobook: true },
}); });
if (!parentRequest) { if (!foundRequest) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 }); return NextResponse.json({ error: 'Request not found' }, { status: 404 });
} }
if (parentRequest.type !== 'audiobook') { // If this is an ebook request, find the parent audiobook request
let parentRequest;
if (foundRequest.type === 'ebook') {
if (!foundRequest.parentRequestId) {
return NextResponse.json({ error: 'Ebook request has no parent audiobook request' }, { status: 400 });
}
parentRequest = await prisma.request.findUnique({
where: { id: foundRequest.parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
return NextResponse.json({ error: 'Parent audiobook request not found' }, { status: 404 });
}
} else if (foundRequest.type === 'audiobook') {
parentRequest = foundRequest;
} else {
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 }); return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
} }
@@ -74,9 +89,12 @@ export async function POST(
} }
// Check for existing ebook request // Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({ // If we were given an ebook request ID directly, use that; otherwise search by parent
let ebookRequest = foundRequest.type === 'ebook'
? foundRequest
: await prisma.request.findFirst({
where: { where: {
parentRequestId, parentRequestId: parentRequest.id,
type: 'ebook', type: 'ebook',
deletedAt: null, deletedAt: null,
}, },
@@ -109,9 +127,10 @@ export async function POST(
userId: parentRequest.userId, userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId, audiobookId: parentRequest.audiobookId,
type: 'ebook', type: 'ebook',
parentRequestId, parentRequestId: parentRequest.id,
status: 'searching', status: 'searching',
progress: 0, progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
}, },
}); });
logger.info(`Created new ebook request ${ebookRequest.id}`); logger.info(`Created new ebook request ${ebookRequest.id}`);
+13 -4
View File
@@ -36,16 +36,25 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`); logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`);
try { try {
// Update request status to searching // Update request status to searching and fetch custom search terms
await prisma.request.update({ const requestRecord = await prisma.request.update({
where: { id: requestId }, where: { id: requestId },
data: { data: {
status: 'searching', status: 'searching',
searchAttempts: { increment: 1 }, searchAttempts: { increment: 1 },
updatedAt: new Date(), updatedAt: new Date(),
}, },
select: { customSearchTerms: true },
}); });
// Use custom search terms if set, otherwise use audiobook title
const effectiveSearchTitle = requestRecord?.customSearchTerms || audiobook.title;
const searchAudiobook = { ...audiobook, title: effectiveSearchTitle };
if (requestRecord?.customSearchTerms) {
logger.info(`Using custom search terms: "${effectiveSearchTitle}" (original: "${audiobook.title}")`);
}
// Get ebook configuration // Get ebook configuration
const configService = getConfigService(); const configService = getConfigService();
const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub'; const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub';
@@ -62,7 +71,7 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
// ========== STEP 1: Try Anna's Archive (if enabled) ========== // ========== STEP 1: Try Anna's Archive (if enabled) ==========
if (annasArchiveEnabled) { if (annasArchiveEnabled) {
logger.info(`Searching Anna's Archive...`); logger.info(`Searching Anna's Archive...`);
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger); annasArchiveResult = await searchAnnasArchive(searchAudiobook, preferredFormat, logger);
if (annasArchiveResult) { if (annasArchiveResult) {
logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`); logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`);
@@ -74,7 +83,7 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
// ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ========== // ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ==========
if (!annasArchiveResult && indexerSearchEnabled) { if (!annasArchiveResult && indexerSearchEnabled) {
logger.info(`Searching indexers...`); logger.info(`Searching indexers...`);
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger); indexerResult = await searchIndexers(requestId, searchAudiobook, preferredFormat, logger);
if (indexerResult) { if (indexerResult) {
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`); logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
+48 -6
View File
@@ -19,7 +19,7 @@ import type { AudibleAudiobook } from '../integrations/audible.service';
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */ /** Patterns in parentheses or brackets to strip (edition markers, format labels) */
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi; const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
/** Trailing subtitle after colon or long dash */ /** Trailing subtitle after colon or long dash (used for extraction, not blind stripping) */
const SUBTITLE_RE = /\s*[:]\s+.+$/; const SUBTITLE_RE = /\s*[:]\s+.+$/;
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/; const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
@@ -44,6 +44,44 @@ export function normalizeTitle(title: string): string {
return t.replace(/\s+/g, ' ').trim(); return t.replace(/\s+/g, ' ').trim();
} }
/**
* Extract the subtitle portion from a title (part after colon or long dash).
* Returns empty string if no subtitle found.
* Used to prevent false dedup of series books like "Series: Book A" vs "Series: Book B".
*/
export function extractSubtitle(title: string): string {
let t = title.toLowerCase();
// Remove parenthesized/bracketed edition markers first (same as normalizeTitle)
t = t.replace(EDITION_PAREN_RE, '');
// Remove trailing descriptors
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
t = t.replace(/\s+/g, ' ').trim();
// Try colon subtitle
const colonMatch = t.match(/\s*[:]\s+(.+)$/);
if (colonMatch) return colonMatch[1].trim();
// Try long dash subtitle
const dashMatch = t.match(/\s+[-\u2013\u2014]\s+(.+)$/);
if (dashMatch) return dashMatch[1].trim();
return '';
}
/**
* Check if two titles' subtitles are compatible for dedup purposes.
* - Both have no subtitle → compatible
* - One has a subtitle, other doesn't → compatible (re-listing with/without subtitle)
* - Both have the SAME subtitle → compatible
* - Both have DIFFERENT subtitles → NOT compatible (different books, e.g. series entries)
*/
function areSubtitlesCompatible(titleA: string, titleB: string): boolean {
const subA = extractSubtitle(titleA);
const subB = extractSubtitle(titleB);
if (!subA || !subB) return true; // one or both missing → compatible
return subA === subB;
}
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */ /** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
function normalizeNarrator(narrator?: string): string { function normalizeNarrator(narrator?: string): string {
const raw = (narrator || '').toLowerCase().trim(); const raw = (narrator || '').toLowerCase().trim();
@@ -152,16 +190,20 @@ export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): Deduplic
continue; continue;
} }
// Within a title+narrator group, further split by duration compatibility. // Within a title+narrator group, further split by duration AND subtitle
// Build sub-groups where all members are duration-compatible with the // compatibility. Build sub-groups where all members are compatible with
// representative (first member). A book joins the first compatible sub-group. // the representative (first member). A book joins the first compatible sub-group.
// This prevents false dedup of series entries like "Series: Book A" vs "Series: Book B".
const subGroups: AudibleAudiobook[][] = []; const subGroups: AudibleAudiobook[][] = [];
for (const book of group) { for (const book of group) {
let placed = false; let placed = false;
for (const sg of subGroups) { for (const sg of subGroups) {
// Check compatibility against the representative (first member) // Check both duration and subtitle compatibility against the representative
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) { if (
areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes) &&
areSubtitlesCompatible(sg[0].title, book.title)
) {
sg.push(book); sg.push(book);
placed = true; placed = true;
break; break;
@@ -8,6 +8,7 @@ import {
deduplicateAudiobooks, deduplicateAudiobooks,
deduplicateAndCollectGroups, deduplicateAndCollectGroups,
normalizeTitle, normalizeTitle,
extractSubtitle,
areDurationsCompatible, areDurationsCompatible,
} from '@/lib/utils/deduplicate-audiobooks'; } from '@/lib/utils/deduplicate-audiobooks';
import type { AudibleAudiobook } from '@/lib/integrations/audible.service'; import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
@@ -92,6 +93,32 @@ describe('normalizeTitle', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// extractSubtitle
// ---------------------------------------------------------------------------
describe('extractSubtitle', () => {
it('extracts subtitle after colon', () => {
expect(extractSubtitle('Eden\'s Gate: The Reborn')).toBe('the reborn');
});
it('extracts subtitle after long dash', () => {
expect(extractSubtitle('Eden\'s Gate \u2014 The Reborn')).toBe('the reborn');
});
it('returns empty for title without subtitle', () => {
expect(extractSubtitle('The Black Prism')).toBe('');
});
it('strips edition markers before extracting', () => {
expect(extractSubtitle('The Hobbit (Unabridged): Extended')).toBe('extended');
});
it('returns empty string for empty input', () => {
expect(extractSubtitle('')).toBe('');
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// areDurationsCompatible // areDurationsCompatible
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -302,6 +329,27 @@ describe('deduplicateAudiobooks', () => {
expect(deduplicateAudiobooks(books)).toHaveLength(1); expect(deduplicateAudiobooks(books)).toHaveLength(1);
}); });
it('does NOT collapse series entries with different subtitles (Eden\'s Gate bug)', () => {
// Series format: "Series Name: Book Title" — different books, NOT duplicates
const books = [
makeBook({ asin: 'A1', title: 'Eden\'s Gate: The Reborn', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 510 }),
makeBook({ asin: 'A2', title: 'Eden\'s Gate: The Spartan', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 540 }),
makeBook({ asin: 'A3', title: 'Eden\'s Gate: The Sapper', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 600 }),
];
const result = deduplicateAudiobooks(books);
expect(result).toHaveLength(3); // All 3 are different books!
});
it('still collapses when one has subtitle and other does not', () => {
// Same book re-listed: "The Black Prism: Lightbringer, Book 1" vs "The Black Prism"
const books = [
makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
];
const result = deduplicateAudiobooks(books);
expect(result).toHaveLength(1);
});
it('does not collapse empty-narrator with named narrator', () => { it('does not collapse empty-narrator with named narrator', () => {
const books = [ const books = [
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }), makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),