This commit is contained in:
kikootwo
2026-03-05 11:31:49 -05:00
41 changed files with 3654 additions and 710 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* Component: Shelf Hook Factory
* Documentation: documentation/frontend/components.md
*
* Generic hook factory for shelf CRUD operations. Each provider (Goodreads,
* Hardcover, etc.) calls this with its API endpoint to get fully typed hooks
* without duplicating the SWR/fetch/mutate boilerplate.
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
/**
* Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint.
*/
function revalidate(endpoint: string) {
mutate((key) => typeof key === 'string' && key.includes(endpoint));
mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves'));
}
/**
* Creates a set of hooks for a shelf provider endpoint.
*
* Returns:
* - useList: SWR-based hook to list shelves
* - useAdd: Hook returning { addShelf(body), isLoading, error }
* - useDelete: Hook returning { deleteShelf(id), isLoading, error }
* - useUpdate: Hook returning { updateShelf(id, body), isLoading, error }
*/
export function createShelfHooks<TShelf>(endpoint: string) {
function useList() {
const { accessToken } = useAuth();
const key = accessToken ? endpoint : null;
const { data, error, isLoading } = useSWR(key, fetcher, {
refreshInterval: 30000,
});
return {
shelves: (data?.shelves || []) as TShelf[],
isLoading,
error,
};
}
function useAdd() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (body: Record<string, unknown>) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
revalidate(endpoint);
return data.shelf as TShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
function useDelete() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
revalidate(endpoint);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
function useUpdate() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateShelf = async (shelfId: string, body: Record<string, unknown>) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to update shelf');
}
revalidate(endpoint);
return data.shelf as TShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { updateShelf, isLoading, error };
}
return { useList, useAdd, useDelete, useUpdate };
}
+13 -93
View File
@@ -5,17 +5,9 @@
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { createShelfHooks, ShelfBook } from './createShelfHooks';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export type { ShelfBook };
export interface GoodreadsShelf {
id: string;
@@ -27,101 +19,29 @@ export interface GoodreadsShelf {
books: ShelfBook[];
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
const { useList, useAdd, useDelete, useUpdate } =
createShelfHooks<GoodreadsShelf>('/api/user/goodreads-shelves');
export function useGoodreadsShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{ refreshInterval: 30000 }
);
return {
shelves: (data?.shelves || []) as GoodreadsShelf[],
isLoading,
error,
};
}
export const useGoodreadsShelves = useList;
export function useAddGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { addShelf: addGeneric, isLoading, error } = useAdd();
const addShelf = async (rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
return addGeneric({ rssUrl });
};
return { addShelf, isLoading, error };
}
export function useDeleteGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
export const useDeleteGoodreadsShelf = useDelete;
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
export function useUpdateGoodreadsShelf() {
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
const updateShelf = async (shelfId: string, rssUrl: string) => {
return updateGeneric(shelfId, { rssUrl });
};
return { deleteShelf, isLoading, error };
return { updateShelf, isLoading, error };
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Component: Hardcover Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { createShelfHooks, ShelfBook } from './createShelfHooks';
export type { ShelfBook };
export interface HardcoverShelf {
id: string;
name: string;
listId: string;
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const { useList, useAdd, useDelete, useUpdate } =
createShelfHooks<HardcoverShelf>('/api/user/hardcover-shelves');
export const useHardcoverShelves = useList;
export function useAddHardcoverShelf() {
const { addShelf: addGeneric, isLoading, error } = useAdd();
const addShelf = async (apiToken: string, listId: string) => {
return addGeneric({ apiToken, listId });
};
return { addShelf, isLoading, error };
}
export const useDeleteHardcoverShelf = useDelete;
export function useUpdateHardcoverShelf() {
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
const updateShelf = async (
shelfId: string,
updates: { listId?: string; apiToken?: string },
) => {
return updateGeneric(shelfId, updates);
};
return { updateShelf, isLoading, error };
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Component: Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
import { ShelfBook } from './useGoodreadsShelves';
export interface GenericShelf {
id: string;
type: 'goodreads' | 'hardcover';
name: string;
sourceId: string; // Either rssUrl or listId
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
export function useShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/shelves' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 30000,
});
return {
shelves: (data?.shelves || []) as GenericShelf[],
isLoading,
error,
};
}
@@ -1,42 +0,0 @@
/**
* Component: Sync Goodreads Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing Goodreads shelf RSS feeds.
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncGoodreadsShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific shelf (used for immediate sync on add) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
const { jobId, shelfId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
logger.info(shelfId
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
: 'Starting scheduled Goodreads shelves sync...'
);
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
const stats = await processGoodreadsShelves(logger, {
shelfId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
logger.info('Goodreads sync complete', { stats });
return {
success: true,
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
...stats,
};
}
@@ -0,0 +1,96 @@
/**
* Component: Sync Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing all reading shelves (Goodreads, Hardcover).
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific shelf (used for immediate sync on add) */
shelfId?: string;
/** The type of shelf, if shelfId is specified */
shelfType?: 'goodreads' | 'hardcover';
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncShelves(
payload: SyncShelvesPayload,
): Promise<any> {
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
const stats = {
shelvesProcessed: 0,
booksFound: 0,
lookupsPerformed: 0,
requestsCreated: 0,
errors: 0,
};
logger.info(
shelfId
? `Starting immediate ${shelfType} sync for list ${shelfId}...`
: 'Starting scheduled shelves sync...',
);
const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads';
const shouldSyncHardcover = !shelfType || shelfType === 'hardcover';
if (shouldSyncGoodreads) {
try {
const { processGoodreadsShelves } =
await import('../services/goodreads-sync.service');
const grStats = await processGoodreadsShelves(logger, {
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
stats.shelvesProcessed += grStats.shelvesProcessed;
stats.booksFound += grStats.booksFound;
stats.lookupsPerformed += grStats.lookupsPerformed;
stats.requestsCreated += grStats.requestsCreated;
stats.errors += grStats.errors;
} catch (error) {
logger.error('Goodreads sync failed', {
error: error instanceof Error ? error.message : String(error),
});
stats.errors++;
}
}
if (shouldSyncHardcover) {
try {
const { processHardcoverShelves } =
await import('../services/hardcover-sync.service');
const hcStats = await processHardcoverShelves(logger, {
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
stats.shelvesProcessed += hcStats.shelvesProcessed;
stats.booksFound += hcStats.booksFound;
stats.lookupsPerformed += hcStats.lookupsPerformed;
stats.requestsCreated += hcStats.requestsCreated;
stats.errors += hcStats.errors;
} catch (error) {
logger.error('Hardcover sync failed', {
error: error instanceof Error ? error.message : String(error),
});
stats.errors++;
}
}
logger.info('Shelves sync complete', { stats });
return {
success: true,
message: shelfId ? `${shelfType} list synced` : 'Reading shelves synced',
...stats,
};
}
+86 -284
View File
@@ -2,36 +2,29 @@
* Component: Goodreads Shelf Sync Service
* Documentation: documentation/backend/services/goodreads-sync.md
*
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
* and creates requests via the shared request-creator service.
* Fetches Goodreads shelf RSS feeds and delegates book processing
* to the shared shelf-sync-core service.
*/
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
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 {
ShelfBook,
ShelfSyncStats,
ShelfSyncOptions,
createEmptyStats,
resolveMaxLookups,
processShelfBooks,
} from '@/lib/services/shelf-sync-core.service';
const logger = RMABLogger.create('GoodreadsSync');
/** 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;
interface GoodreadsRssBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/**
* Parse a Goodreads RSS feed XML into structured book data.
*/
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
function parseGoodreadsRss(xml: string): { shelfName: string; books: ShelfBook[] } {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
@@ -46,65 +39,84 @@ function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRs
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
// Normalize items to array
let items = channel.item;
if (!items) return { shelfName, books: [] };
if (!Array.isArray(items)) items = [items];
const books: GoodreadsRssBook[] = [];
const books: ShelfBook[] = [];
for (const item of items) {
const bookId = item.book_id?.toString();
if (!bookId) continue;
const title = (item.title || '').toString().trim();
const authorName = (item.author_name || '').toString().trim();
// Goodreads RSS has book_image_url or book_medium_image_url
const author = (item.author_name || '').toString().trim();
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
if (title && authorName) {
books.push({ bookId, title, author: authorName, coverUrl });
if (title && author) {
books.push({ bookId, title, author, coverUrl });
}
}
return { shelfName, books };
}
/** Max items Goodreads returns per RSS page */
const GOODREADS_PAGE_SIZE = 100;
/** Safety cap to avoid infinite loops */
const MAX_PAGES = 50;
/**
* Fetch and validate a Goodreads RSS URL.
* Returns the parsed shelf name and books if valid.
* Automatically paginates (sort=title, page=1,2,...) when a page returns 100 items.
* Deduplicates by bookId across pages.
*/
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
const response = await axios.get(rssUrl, { timeout: 15000 });
return parseGoodreadsRss(response.data);
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: ShelfBook[] }> {
const url = new URL(rssUrl);
url.searchParams.set('sort', 'title');
let shelfName = 'Goodreads Shelf';
const seenIds = new Set<string>();
const allBooks: ShelfBook[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
url.searchParams.set('page', page.toString());
const response = await axios.get(url.toString(), { timeout: 15000 });
const parsed = parseGoodreadsRss(response.data);
if (page === 1) {
shelfName = parsed.shelfName;
}
for (const book of parsed.books) {
if (!seenIds.has(book.bookId)) {
seenIds.add(book.bookId);
allBooks.push(book);
}
}
if (parsed.books.length < GOODREADS_PAGE_SIZE) break;
}
return { shelfName, books: allBooks };
}
export interface GoodreadsSyncStats {
shelvesProcessed: number;
booksFound: number;
lookupsPerformed: number;
requestsCreated: number;
errors: number;
}
export interface GoodreadsSyncOptions {
/** Process only this shelf ID (for immediate single-shelf sync) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
maxLookupsPerShelf?: number;
}
// Re-export types that downstream consumers expect
export type { ShelfSyncStats as GoodreadsSyncStats };
export type { ShelfSyncOptions as GoodreadsSyncOptions };
/**
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
* Called from the dedicated sync_goodreads_shelves processor.
* Called from the unified sync_reading_shelves processor.
*/
export async function processGoodreadsShelves(
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
options: GoodreadsSyncOptions = {}
): Promise<GoodreadsSyncStats> {
options: ShelfSyncOptions = {}
): Promise<ShelfSyncStats> {
const log = jobLogger || logger;
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
const stats = createEmptyStats();
const maxLookups = resolveMaxLookups(options);
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.goodreadsShelf.findMany({
@@ -121,7 +133,32 @@ export async function processGoodreadsShelves(
for (const shelf of shelves) {
try {
await processShelf(shelf, stats, log, maxLookups);
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
let rssData: { shelfName: string; books: ShelfBook[] };
try {
rssData = await fetchAndValidateRss(shelf.rssUrl);
} catch (error) {
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
stats.errors++;
continue;
}
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`);
const bookData = await processShelfBooks(
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
);
await prisma.goodreadsShelf.update({
where: { id: shelf.id },
data: {
lastSyncAt: new Date(),
bookCount: rssData.books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
stats.shelvesProcessed++;
} catch (error) {
stats.errors++;
@@ -132,238 +169,3 @@ export async function processGoodreadsShelves(
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
return stats;
}
async function processShelf(
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
stats: GoodreadsSyncStats,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
maxLookups: number
) {
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
try {
rssData = await fetchAndValidateRss(shelf.rssUrl);
} catch (error) {
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
return;
}
const books = rssData.books;
stats.booksFound += books.length;
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
let lookupsThisCycle = 0;
const unlimitedLookups = maxLookups === 0;
for (const book of books) {
// Look up existing mapping
let mapping = await prisma.goodreadsBookMapping.findUnique({
where: { goodreadsBookId: book.bookId },
});
if (!mapping) {
// No mapping exists — perform Audible lookup if under cap
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
continue; // Will be resolved in a future cycle
}
mapping = await performAudibleLookup(book, log);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If lookup found an ASIN, fall through to create request immediately
if (!mapping?.audibleAsin) {
continue;
}
}
// Mapping exists with noMatch — check if we should retry
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(book, log, mapping.id);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If retry found an ASIN, fall through to create request
if (!mapping?.audibleAsin) {
continue;
}
} else {
continue; // Still no match, skip
}
} else {
continue;
}
}
// Mapping has ASIN — try to create request
if (mapping.audibleAsin) {
try {
const result = await createRequestForUser(shelf.user.id, {
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})`);
}
// If not success, it's already available/requested/duplicate — silently skip
} catch (error) {
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Collect enriched book data (coverUrl + ASIN) for display
const bookIds = books.map(b => b.bookId);
const mappings = bookIds.length > 0
? await prisma.goodreadsBookMapping.findMany({
where: { goodreadsBookId: { in: bookIds } },
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
})
: [];
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
// Look up AudibleCache records for high-quality cached cover URLs
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;
})
);
const bookData = books
.map(b => {
const mapping = mappingsByBookId.get(b.bookId);
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
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);
// Update shelf metadata
await prisma.goodreadsShelf.update({
where: { id: shelf.id },
data: {
lastSyncAt: new Date(),
bookCount: books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
}
async function performAudibleLookup(
book: GoodreadsRssBook,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
existingMappingId?: string
): Promise<any> {
const audibleService = getAudibleService();
try {
// Try full Goodreads title first, then fall back to stripped title
// (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results)
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})`);
// Use clean Audible/Audnexus metadata instead of Goodreads data
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
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.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...data },
});
}
// No match found
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.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...noMatchData },
});
} catch (error) {
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
// Still create/update mapping so we don't retry every cycle
const errorData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...errorData },
});
}
}
+327
View File
@@ -0,0 +1,327 @@
/**
* Component: Hardcover API Service
* Documentation: documentation/backend/services/hardcover-sync.md
*
* GraphQL queries and API communication with the Hardcover platform.
* Exports fetchHardcoverList for use by the sync orchestration layer.
*/
import axios from 'axios';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('HardcoverAPI');
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
export interface HardcoverApiBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */
interface HardcoverBookNode {
id?: number;
title?: string;
cached_image?: string | { url?: string };
image?: { url?: string };
contributions?: Array<{ author?: { name?: string } }>;
}
/** Shape of a list object returned from the Hardcover GraphQL API */
interface HardcoverListData {
name?: string;
list_books?: Array<{ book?: HardcoverBookNode }>;
}
const PAGE_SIZE = 100;
const MAX_PAGES = 50;
/** Extract HardcoverApiBook[] from an array of book-containing items */
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
const books: HardcoverApiBook[] = [];
for (const item of items) {
const book = item.book;
if (!book || !book.id) continue;
const authorName =
book.contributions?.[0]?.author?.name || 'Unknown Author';
const cachedImg = book.cached_image;
const coverUrl =
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
book.image?.url ||
undefined;
books.push({
bookId: book.id.toString(),
title: book.title || 'Unknown Title',
author: authorName,
coverUrl,
});
}
return books;
}
/**
* Fetch a Hardcover List using their GraphQL API.
* This handles both 'status_id' user_books or 'list_id' list_books queries.
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
*/
export async function fetchHardcoverList(
apiToken: string,
listIdStr: string,
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
// Check if it's a status list
const isStatus = listIdStr.startsWith('status-');
if (isStatus) {
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
const query = `
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
me {
user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) {
book {
id
title
contributions {
author {
name
}
}
cached_image
image {
url
}
}
}
}
}
`;
// Map status numbers to names
const statusNames: Record<number, string> = {
1: 'Want to Read',
2: 'Currently Reading',
3: 'Read',
4: 'Did Not Finish',
};
const listName = statusNames[statusId] || `Status ${statusId}`;
const allBooks: HardcoverApiBook[] = [];
let offset = 0;
let page = 0;
// Paginate until fewer results than PAGE_SIZE are returned
while (++page <= MAX_PAGES) {
const response = await axios.post(
HARDCOVER_API_URL,
{ query, variables: { statusId, limit: PAGE_SIZE, offset } },
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
if (response.data?.errors) {
throw new Error(
`Hardcover API Error: ${response.data.errors[0]?.message}`,
);
}
const userBooks: Array<{ book?: HardcoverBookNode }> =
response.data?.data?.me?.[0]?.user_books || [];
const pageBooks = extractBooks(userBooks);
allBooks.push(...pageBooks);
if (userBooks.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return { listName, books: allBooks };
} else {
// Custom list query
// - URL with @username → query that user's lists by slug
// - Bare slug (no username) → query authenticated user's lists via `me`
// - Numeric ID → query globally (IDs are unique)
const isIntId = /^\d+$/.test(listIdStr);
let extractedSlug = listIdStr;
let extractedUsername: string | null = null;
if (!isIntId) {
try {
if (listIdStr.includes('hardcover.app')) {
const url = new URL(
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
);
const parts = url.pathname.split('/').filter(Boolean);
// URL format: /@username/lists/slug
if (parts.length > 0) {
extractedSlug = parts[parts.length - 1];
}
const userPart = parts.find((p) => p.startsWith('@'));
if (userPart) {
extractedUsername = userPart.slice(1);
}
}
} catch (e) {
// use extractedSlug as-is
}
}
const listBookFields = `
name
list_books(limit: $limit, offset: $offset, order_by: {id: desc}) {
book {
id title cached_image image { url }
contributions { author { name } }
}
}
`;
// Numeric ID: globally unique, query the lists table directly
const queryById = `
query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) {
lists(where: {id: {_eq: $listId}}, limit: 1) {
${listBookFields}
}
}
`;
// Slug with username: query through the users table to scope to that user
const queryByUserSlug = `
query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) {
users(where: {username: {_eq: $username}}, limit: 1) {
lists(where: {slug: {_eq: $slug}}, limit: 1) {
${listBookFields}
}
}
}
`;
// Bare slug (no username): scope to the authenticated user via `me`
const queryByMySlug = `
query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) {
me {
lists(where: {slug: {_eq: $slug}}, limit: 1) {
${listBookFields}
}
}
}
`;
let activeQuery: string;
let baseVariables: Record<string, unknown>;
if (isIntId) {
activeQuery = queryById;
baseVariables = { listId: parseInt(listIdStr, 10) };
} else if (extractedUsername) {
activeQuery = queryByUserSlug;
baseVariables = { username: extractedUsername, slug: extractedSlug };
} else {
activeQuery = queryByMySlug;
baseVariables = { slug: extractedSlug };
}
// First request to discover list metadata and first page of books
const firstResponse = await axios.post(
HARDCOVER_API_URL,
{
query: activeQuery,
variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 },
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
if (firstResponse.data?.errors) {
throw new Error(
`Hardcover API Error: ${firstResponse.data.errors[0]?.message}`,
);
}
// Extract lists array from the response based on which query was used
let listsData: HardcoverListData[];
if (isIntId) {
listsData = firstResponse.data?.data?.lists || [];
} else if (extractedUsername) {
const users = firstResponse.data?.data?.users || [];
listsData = users[0]?.lists || [];
} else {
listsData = firstResponse.data?.data?.me?.[0]?.lists || [];
}
if (listsData.length === 0) {
let identifier: string;
if (isIntId) {
identifier = `ID "${listIdStr}"`;
} else if (extractedUsername) {
identifier = `slug "${extractedSlug}" for user @${extractedUsername}`;
} else {
identifier = `slug "${extractedSlug}" in your Hardcover account`;
}
throw new Error(`Could not find a list with ${identifier}`);
}
const listName = listsData[0].name || 'Hardcover List';
const firstPageItems = listsData[0].list_books || [];
const allBooks = extractBooks(firstPageItems);
// Paginate if first page was full
if (firstPageItems.length >= PAGE_SIZE) {
let offset = PAGE_SIZE;
let page = 1; // first page already fetched
while (++page <= MAX_PAGES) {
const pageResponse = await axios.post(
HARDCOVER_API_URL,
{
query: activeQuery,
variables: { ...baseVariables, limit: PAGE_SIZE, offset },
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
if (pageResponse.data?.errors) {
logger.warn('Hardcover pagination interrupted by API error', {
errors: pageResponse.data.errors,
offset,
});
break;
}
let pageListsData: HardcoverListData[];
if (isIntId) {
pageListsData = pageResponse.data?.data?.lists || [];
} else if (extractedUsername) {
const users = pageResponse.data?.data?.users || [];
pageListsData = users[0]?.lists || [];
} else {
pageListsData = pageResponse.data?.data?.me?.[0]?.lists || [];
}
const pageItems = pageListsData[0]?.list_books || [];
allBooks.push(...extractBooks(pageItems));
if (pageItems.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
}
return { listName, books: allBooks };
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Component: Hardcover Shelf Sync Service
* Documentation: documentation/backend/services/hardcover-sync.md
*
* Fetches Hardcover lists via GraphQL API and delegates book processing
* to the shared shelf-sync-core service.
*/
import { prisma } from '@/lib/db';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
import {
ShelfSyncStats,
ShelfSyncOptions,
createEmptyStats,
resolveMaxLookups,
processShelfBooks,
} from '@/lib/services/shelf-sync-core.service';
export { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
export type { HardcoverApiBook } from '@/lib/services/hardcover-api.service';
const logger = RMABLogger.create('HardcoverSync');
// Re-export types that downstream consumers expect
export type { ShelfSyncStats as HardcoverSyncStats };
export type { ShelfSyncOptions as HardcoverSyncOptions };
/**
* Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests.
* Called from the unified sync_reading_shelves processor.
*/
export async function processHardcoverShelves(
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
options: ShelfSyncOptions = {},
): Promise<ShelfSyncStats> {
const log = jobLogger || logger;
const stats = createEmptyStats();
const maxLookups = resolveMaxLookups(options);
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.hardcoverShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
});
if (shelves.length === 0) {
log.info(
options.shelfId
? 'Hardcover list not found'
: 'No Hardcover lists configured, skipping',
);
return stats;
}
log.info(
`Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`,
);
for (const shelf of shelves) {
try {
log.info(`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`);
const encryptionService = getEncryptionService();
let decryptedToken = shelf.apiToken;
try {
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
decryptedToken = encryptionService.decrypt(shelf.apiToken);
}
} catch (err) {
log.error(`Failed to decrypt API token for user ${shelf.user.plexUsername}`);
stats.errors++;
continue;
}
let fetchedData: { listName: string; books: HardcoverApiBook[] };
try {
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
} catch (error) {
log.error(
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
);
stats.errors++;
continue;
}
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`);
const bookData = await processShelfBooks(
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
);
const finalListName =
fetchedData.listName !== 'Hardcover List'
? fetchedData.listName
: shelf.name;
await prisma.hardcoverShelf.update({
where: { id: shelf.id },
data: {
name: finalListName,
lastSyncAt: new Date(),
bookCount: fetchedData.books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
stats.shelvesProcessed++;
} catch (error) {
stats.errors++;
log.error(
`Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
log.info(
`Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`,
);
return stats;
}
+12 -10
View File
@@ -26,7 +26,7 @@ export type JobType =
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'sync_reading_shelves'
| 'check_watched_lists'
| 'send_notification'
// Ebook-specific job types
@@ -108,9 +108,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface SyncGoodreadsShelvesPayload extends JobPayload {
export interface SyncShelvesPayload extends JobPayload {
scheduledJobId?: string;
shelfId?: string;
shelfType?: 'goodreads' | 'hardcover';
maxLookupsPerShelf?: number;
}
@@ -389,10 +390,10 @@ export class JobQueueService {
return await processCleanupSeededTorrents(payloadWithJobId);
});
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
return await processSyncGoodreadsShelves(payloadWithJobId);
this.queue.process('sync_reading_shelves', 1, async (job: BullJob<SyncShelvesPayload>) => {
const { processSyncShelves } = await import('../processors/sync-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves');
return await processSyncShelves(payloadWithJobId);
});
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
@@ -767,16 +768,17 @@ export class JobQueueService {
}
/**
* Add sync Goodreads shelves job
* Add sync reading shelves job
*/
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
return await this.addJob(
'sync_goodreads_shelves',
'sync_reading_shelves',
{
scheduledJobId,
shelfId,
shelfType,
maxLookupsPerShelf,
} as SyncGoodreadsShelvesPayload,
} as SyncShelvesPayload,
{
priority: 7,
}
+36 -8
View File
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Scheduler');
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists';
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists';
export interface ScheduledJob {
id: string;
@@ -59,6 +59,9 @@ export class SchedulerService {
});
}
// Clean up deprecated scheduled jobs
await this.cleanupDeprecatedJobs();
// Create default jobs if they don't exist
await this.ensureDefaultJobs();
@@ -127,8 +130,8 @@ export class SchedulerService {
payload: {},
},
{
name: 'Sync Goodreads Shelves',
type: 'sync_goodreads_shelves' as ScheduledJobType,
name: 'Sync Reading Shelves',
type: 'sync_reading_shelves' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default
payload: {},
@@ -174,6 +177,31 @@ export class SchedulerService {
}
}
/**
* Remove any old jobs that are no longer supported
*/
private async cleanupDeprecatedJobs(): Promise<void> {
try {
const deprecatedTypes = ['sync_goodreads_shelves'];
const obsoleteJobs = await prisma.scheduledJob.findMany({
where: { type: { in: deprecatedTypes } },
});
for (const job of obsoleteJobs) {
if (job.enabled) {
await this.unscheduleJob(job);
}
await prisma.scheduledJob.delete({ where: { id: job.id } });
logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`);
}
} catch (error) {
logger.error('Failed to cleanup deprecated scheduled jobs', {
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Schedule all enabled jobs
*/
@@ -357,8 +385,8 @@ export class SchedulerService {
case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job);
break;
case 'sync_goodreads_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job);
case 'sync_reading_shelves':
bullJobId = await this.triggerSyncShelves(job);
break;
case 'check_watched_lists':
bullJobId = await this.triggerCheckWatchedLists(job);
@@ -632,10 +660,10 @@ export class SchedulerService {
}
/**
* Trigger Goodreads shelves sync
* Trigger Reading shelves sync
*/
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
private async triggerSyncShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncShelvesJob(job.id);
}
/**
+274
View File
@@ -0,0 +1,274 @@
/**
* 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 },
});
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Component: Shelf Helpers
* Documentation: documentation/frontend/components.md
*/
/**
* Parse a JSON string of cover/book data into a typed array.
* Returns an empty array on parse failure (graceful degradation).
*/
export function processBooks(
coverUrls: string | null,
): { coverUrl: string; asin: string | null; title: string; author: string }[] {
if (!coverUrls) return [];
let parsed: unknown;
try {
parsed = JSON.parse(coverUrls);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
return parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}