mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
338331d006
Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
275 lines
8.7 KiB
TypeScript
275 lines
8.7 KiB
TypeScript
/**
|
|
* Component: Shelf Sync Core Service
|
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
|
*
|
|
* Shared logic for all shelf providers: Audible lookup, noMatch retry,
|
|
* request creation, cover enrichment, and shelf metadata updates.
|
|
* Provider-specific services (Goodreads, Hardcover) call into this core.
|
|
*/
|
|
|
|
import { prisma } from '@/lib/db';
|
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
|
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
import { BookMapping } from '@/generated/prisma';
|
|
|
|
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
|
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
|
|
|
/** Days before retrying a noMatch book */
|
|
const NO_MATCH_RETRY_DAYS = 7;
|
|
|
|
/** Provider-agnostic book from any shelf source */
|
|
export interface ShelfBook {
|
|
bookId: string;
|
|
title: string;
|
|
author: string;
|
|
coverUrl?: string;
|
|
}
|
|
|
|
/** Sync stats shared across all providers */
|
|
export interface ShelfSyncStats {
|
|
shelvesProcessed: number;
|
|
booksFound: number;
|
|
lookupsPerformed: number;
|
|
requestsCreated: number;
|
|
errors: number;
|
|
}
|
|
|
|
/** Common sync options */
|
|
export interface ShelfSyncOptions {
|
|
shelfId?: string;
|
|
maxLookupsPerShelf?: number;
|
|
}
|
|
|
|
type LoggerType = ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>;
|
|
|
|
export function createEmptyStats(): ShelfSyncStats {
|
|
return { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
|
}
|
|
|
|
export function mergeStats(target: ShelfSyncStats, source: ShelfSyncStats): void {
|
|
target.shelvesProcessed += source.shelvesProcessed;
|
|
target.booksFound += source.booksFound;
|
|
target.lookupsPerformed += source.lookupsPerformed;
|
|
target.requestsCreated += source.requestsCreated;
|
|
target.errors += source.errors;
|
|
}
|
|
|
|
export function resolveMaxLookups(options: ShelfSyncOptions): number {
|
|
return options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
|
}
|
|
|
|
/**
|
|
* Process a list of books from any provider: resolve to ASINs, create requests,
|
|
* enrich covers, and return book data for shelf metadata.
|
|
*/
|
|
export async function processShelfBooks(
|
|
provider: string,
|
|
books: ShelfBook[],
|
|
userId: string,
|
|
shelfId: string,
|
|
stats: ShelfSyncStats,
|
|
log: LoggerType,
|
|
maxLookups: number,
|
|
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
|
stats.booksFound += books.length;
|
|
|
|
let lookupsThisCycle = 0;
|
|
const unlimitedLookups = maxLookups === 0;
|
|
|
|
for (const book of books) {
|
|
let mapping = await prisma.bookMapping.findUnique({
|
|
where: { provider_externalBookId: { provider, externalBookId: book.bookId } },
|
|
});
|
|
|
|
if (!mapping) {
|
|
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue;
|
|
|
|
mapping = await performAudibleLookup(provider, book, log);
|
|
lookupsThisCycle++;
|
|
stats.lookupsPerformed++;
|
|
|
|
if (!mapping?.audibleAsin) continue;
|
|
}
|
|
|
|
if (mapping.noMatch) {
|
|
if (mapping.lastSearchAt) {
|
|
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
|
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
|
mapping = await performAudibleLookup(provider, book, log, mapping.id);
|
|
lookupsThisCycle++;
|
|
stats.lookupsPerformed++;
|
|
|
|
if (!mapping?.audibleAsin) continue;
|
|
} else {
|
|
continue;
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (mapping.audibleAsin) {
|
|
try {
|
|
const result = await createRequestForUser(userId, {
|
|
asin: mapping.audibleAsin,
|
|
title: mapping.title,
|
|
author: mapping.author,
|
|
coverArtUrl: mapping.coverUrl || undefined,
|
|
});
|
|
|
|
if (result.success) {
|
|
stats.requestsCreated++;
|
|
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
|
}
|
|
} catch (error) {
|
|
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return enrichBookCovers(provider, books);
|
|
}
|
|
|
|
/**
|
|
* Enrich book list with cached cover URLs from AudibleCache.
|
|
* Returns up to 8 books with the best available cover URL.
|
|
*/
|
|
async function enrichBookCovers(
|
|
provider: string,
|
|
books: ShelfBook[],
|
|
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
|
const bookIds = books.map(b => b.bookId);
|
|
const mappings = bookIds.length > 0
|
|
? await prisma.bookMapping.findMany({
|
|
where: { provider, externalBookId: { in: bookIds } },
|
|
select: { externalBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
|
})
|
|
: [];
|
|
const mappingsByBookId = new Map(mappings.map(m => [m.externalBookId, m]));
|
|
|
|
const matchedAsins = mappings
|
|
.map(m => m.audibleAsin)
|
|
.filter((asin): asin is string => !!asin);
|
|
const cachedCovers = matchedAsins.length > 0
|
|
? await prisma.audibleCache.findMany({
|
|
where: { asin: { in: matchedAsins } },
|
|
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
|
})
|
|
: [];
|
|
const coverByAsin = new Map(
|
|
cachedCovers
|
|
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
|
.map(c => {
|
|
let coverUrl = c.coverArtUrl || '';
|
|
if (c.cachedCoverPath) {
|
|
const filename = c.cachedCoverPath.split('/').pop();
|
|
coverUrl = `/api/cache/thumbnails/${filename}`;
|
|
}
|
|
return [c.asin, coverUrl] as const;
|
|
})
|
|
);
|
|
|
|
return books
|
|
.map(b => {
|
|
const mapping = mappingsByBookId.get(b.bookId);
|
|
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
|
if (!coverUrl) return null;
|
|
return {
|
|
coverUrl,
|
|
asin: mapping?.audibleAsin || null,
|
|
title: mapping?.title || b.title,
|
|
author: mapping?.author || b.author,
|
|
};
|
|
})
|
|
.filter((b): b is NonNullable<typeof b> => b !== null)
|
|
.slice(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Search Audible for a book, persist the result to the unified BookMapping table.
|
|
*/
|
|
async function performAudibleLookup(
|
|
provider: string,
|
|
book: ShelfBook,
|
|
log: LoggerType,
|
|
existingMappingId?: string,
|
|
): Promise<BookMapping | null> {
|
|
const audibleService = getAudibleService();
|
|
|
|
try {
|
|
const fullQuery = `${book.title} ${book.author}`;
|
|
log.info(`Searching Audible for: "${fullQuery}"`);
|
|
|
|
let searchResult = await audibleService.search(fullQuery);
|
|
let firstResult = searchResult.results[0];
|
|
|
|
if (!firstResult?.asin) {
|
|
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
|
|
if (cleanTitle !== book.title) {
|
|
const cleanQuery = `${cleanTitle} ${book.author}`;
|
|
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
|
|
searchResult = await audibleService.search(cleanQuery);
|
|
firstResult = searchResult.results[0];
|
|
}
|
|
}
|
|
|
|
if (firstResult?.asin) {
|
|
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
|
|
|
|
const data = {
|
|
title: firstResult.title,
|
|
author: firstResult.author,
|
|
audibleAsin: firstResult.asin,
|
|
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
|
noMatch: false,
|
|
lastSearchAt: new Date(),
|
|
};
|
|
|
|
if (existingMappingId) {
|
|
return prisma.bookMapping.update({ where: { id: existingMappingId }, data });
|
|
}
|
|
return prisma.bookMapping.create({
|
|
data: { provider, externalBookId: book.bookId, ...data },
|
|
});
|
|
}
|
|
|
|
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
|
|
|
const noMatchData = {
|
|
title: book.title,
|
|
author: book.author,
|
|
coverUrl: book.coverUrl || null,
|
|
noMatch: true,
|
|
lastSearchAt: new Date(),
|
|
audibleAsin: null,
|
|
};
|
|
|
|
if (existingMappingId) {
|
|
return prisma.bookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
|
}
|
|
return prisma.bookMapping.create({
|
|
data: { provider, externalBookId: book.bookId, ...noMatchData },
|
|
});
|
|
} catch (error) {
|
|
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
const errorData = {
|
|
title: book.title,
|
|
author: book.author,
|
|
coverUrl: book.coverUrl || null,
|
|
noMatch: true,
|
|
lastSearchAt: new Date(),
|
|
};
|
|
|
|
if (existingMappingId) {
|
|
return prisma.bookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
|
}
|
|
return prisma.bookMapping.create({
|
|
data: { provider, externalBookId: book.bookId, ...errorData },
|
|
});
|
|
}
|
|
}
|