Compare commits

..

6 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
kikootwo 3e2221ad5b Bump package version to 1.1.1
Update package.json version from 1.1.0 to 1.1.1 to reflect a patch release.
2026-03-05 15:03:29 -05:00
kikootwo 859a331012 Run data migrations; use search title for ranking
Add an entrypoint step to execute idempotent SQL data migrations (prisma db execute) from prisma/data-migrations/*.sql so fixes that prisma db push doesn't handle are applied on startup. Add normalize-local-usernames.sql to normalize local users' plex_username and plex_id to lowercase. Update interactive search and search-indexers processor to prefer the user-provided/custom search title (searchTitle / effectiveSearchTitle) when ranking torrents and adjust debug logs to show the ranking title alongside the audiobook title/author for clearer diagnostics.
2026-03-05 15:02:59 -05:00
15 changed files with 210 additions and 31 deletions
+23
View File
@@ -403,6 +403,29 @@ echo "🔄 Running Prisma migrations..."
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..."
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
echo "🔄 Running data migrations..."
for sql_file in /app/prisma/data-migrations/*.sql; do
if [ -f "$sql_file" ]; then
migration_name=$(basename "$sql_file")
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
done
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "🔧 Stopping temporary PostgreSQL instance..."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.1.0",
"version": "1.1.3",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,7 @@
-- Normalize existing local usernames to lowercase (idempotent - safe to run multiple times)
-- Only affects local auth users, not Plex/OIDC users
UPDATE users SET plex_username = LOWER(plex_username)
WHERE auth_provider = 'local' AND deleted_at IS NULL AND plex_username != LOWER(plex_username);
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7))
WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%' AND plex_id != LOWER(plex_id);
@@ -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])
@@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 jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
const audiobookData = {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
};
if (existingRequest.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
searchTriggered = true;
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
status: 'awaiting_approval',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'pending',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval',
progress: 0,
selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'searching',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId,
status: 'pending',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
@@ -196,10 +196,10 @@ export async function POST(
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query)
// Use searchTitle for ranking so custom search terms and search bar overrides are respected
// requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
title: searchTitle,
author: requestRecord.audiobook.author,
durationMinutes,
}, {
@@ -218,7 +218,7 @@ export async function POST(
const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
logger.debug('==================== RANKING DEBUG ====================');
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug('Search parameters', { searchTitle, rankingTitle: searchTitle, audiobookTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => {
+31 -12
View File
@@ -52,17 +52,32 @@ export async function POST(
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
}
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
// Get the request - could be an audiobook request or an existing ebook request
const foundRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
if (!foundRequest) {
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 });
}
@@ -74,13 +89,16 @@ export async function POST(
}
// Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
// 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: {
parentRequestId: parentRequest.id,
type: 'ebook',
deletedAt: null,
},
});
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
return NextResponse.json({
@@ -109,9 +127,10 @@ export async function POST(
userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId,
type: 'ebook',
parentRequestId,
parentRequestId: parentRequest.id,
status: 'searching',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
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}"`);
try {
// Update request status to searching
await prisma.request.update({
// Update request status to searching and fetch custom search terms
const requestRecord = await prisma.request.update({
where: { id: requestId },
data: {
status: 'searching',
searchAttempts: { increment: 1 },
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
const configService = getConfigService();
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) ==========
if (annasArchiveEnabled) {
logger.info(`Searching Anna's Archive...`);
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger);
annasArchiveResult = await searchAnnasArchive(searchAudiobook, preferredFormat, logger);
if (annasArchiveResult) {
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) ==========
if (!annasArchiveResult && indexerSearchEnabled) {
logger.info(`Searching indexers...`);
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger);
indexerResult = await searchIndexers(requestId, searchAudiobook, preferredFormat, logger);
if (indexerResult) {
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
@@ -166,9 +166,10 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
// Use effectiveSearchTitle so custom search terms are respected for ranking
// requireAuthor: true (default) - strict filtering for automatic selection
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
title: effectiveSearchTitle,
author: audiobook.author,
durationMinutes,
}, {
@@ -228,7 +229,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Log top 3 results with detailed breakdown
const top3 = filteredResults.slice(0, 3);
logger.info(`==================== RANKING DEBUG ====================`);
logger.info(`Requested Title: "${audiobook.title}"`);
logger.info(`Ranking Title: "${effectiveSearchTitle}"${effectiveSearchTitle !== audiobook.title ? ` (audiobook: "${audiobook.title}")` : ''}`);
logger.info(`Requested Author: "${audiobook.author}"`);
logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
logger.info(`--------------------------------------------------------`);
+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) */
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 LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
@@ -44,6 +44,44 @@ export function normalizeTitle(title: string): string {
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. */
function normalizeNarrator(narrator?: string): string {
const raw = (narrator || '').toLowerCase().trim();
@@ -152,16 +190,20 @@ export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): Deduplic
continue;
}
// Within a title+narrator group, further split by duration compatibility.
// Build sub-groups where all members are duration-compatible with the
// representative (first member). A book joins the first compatible sub-group.
// Within a title+narrator group, further split by duration AND subtitle
// compatibility. Build sub-groups where all members are compatible with
// 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[][] = [];
for (const book of group) {
let placed = false;
for (const sg of subGroups) {
// Check compatibility against the representative (first member)
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
// Check both duration and subtitle compatibility against the representative
if (
areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes) &&
areSubtitlesCompatible(sg[0].title, book.title)
) {
sg.push(book);
placed = true;
break;
@@ -8,6 +8,7 @@ import {
deduplicateAudiobooks,
deduplicateAndCollectGroups,
normalizeTitle,
extractSubtitle,
areDurationsCompatible,
} from '@/lib/utils/deduplicate-audiobooks';
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
// ---------------------------------------------------------------------------
@@ -302,6 +329,27 @@ describe('deduplicateAudiobooks', () => {
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', () => {
const books = [
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),