mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01b59fae9d | |||
| 137e2b5607 | |||
| f09931f352 | |||
| 5b4aa3fa15 |
@@ -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
@@ -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;
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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)})`);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user