Refactor shelves UI and jobs

This commit is contained in:
Rob Walsh
2026-02-27 15:46:10 -07:00
parent cfe780c6f0
commit 41d45d1210
12 changed files with 511 additions and 619 deletions
+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,
};
}
@@ -1,46 +0,0 @@
/**
* Component: Sync Hardcover Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing Hardcover lists.
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncHardcoverShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific list (used for immediate sync on add) */
shelfId?: string;
/** Max Audible lookups per list. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncHardcoverShelves(
payload: SyncHardcoverShelvesPayload,
): Promise<any> {
const { jobId, shelfId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncHardcoverShelves');
logger.info(
shelfId
? `Starting immediate Hardcover sync for list ${shelfId}...`
: 'Starting scheduled Hardcover lists sync...',
);
const { processHardcoverShelves } =
await import('../services/hardcover-sync.service');
const stats = await processHardcoverShelves(logger, {
shelfId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
logger.info('Hardcover sync complete', { stats });
return {
success: true,
message: shelfId ? 'Hardcover list synced' : 'Hardcover lists 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,
};
}
+15 -54
View File
@@ -26,8 +26,7 @@ export type JobType =
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'sync_hardcover_shelves'
| 'sync_reading_shelves'
| 'send_notification'
// Ebook-specific job types
| 'search_ebook'
@@ -106,15 +105,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface SyncGoodreadsShelvesPayload extends JobPayload {
scheduledJobId?: string;
shelfId?: string;
maxLookupsPerShelf?: number;
}
export interface SyncHardcoverShelvesPayload extends JobPayload {
export interface SyncShelvesPayload extends JobPayload {
scheduledJobId?: string;
shelfId?: string;
shelfType?: 'goodreads' | 'hardcover';
maxLookupsPerShelf?: number;
}
@@ -447,30 +441,16 @@ export class JobQueueService {
);
this.queue.process(
'sync_goodreads_shelves',
'sync_reading_shelves',
1,
async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
const { processSyncGoodreadsShelves } =
await import('../processors/sync-goodreads-shelves.processor');
async (job: BullJob<SyncShelvesPayload>) => {
const { processSyncShelves } =
await import('../processors/sync-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(
job,
'sync_goodreads_shelves',
'sync_reading_shelves',
);
return await processSyncGoodreadsShelves(payloadWithJobId);
},
);
this.queue.process(
'sync_hardcover_shelves',
1,
async (job: BullJob<SyncHardcoverShelvesPayload>) => {
const { processSyncHardcoverShelves } =
await import('../processors/sync-hardcover-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(
job,
'sync_hardcover_shelves',
);
return await processSyncHardcoverShelves(payloadWithJobId);
return await processSyncShelves(payloadWithJobId);
},
);
@@ -875,41 +855,22 @@ export class JobQueueService {
}
/**
* Add sync Goodreads shelves job
* Add sync reading shelves job
*/
async addSyncGoodreadsShelvesJob(
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,
{
priority: 7,
},
);
}
/**
* Add sync Hardcover shelves job
*/
async addSyncHardcoverShelvesJob(
scheduledJobId?: string,
shelfId?: string,
maxLookupsPerShelf?: number,
): Promise<string> {
return await this.addJob(
'sync_hardcover_shelves',
{
scheduledJobId,
shelfId,
maxLookupsPerShelf,
} as SyncHardcoverShelvesPayload,
} as SyncShelvesPayload,
{
priority: 7,
},
+41 -26
View File
@@ -18,8 +18,7 @@ export type ScheduledJobType =
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'sync_hardcover_shelves';
| 'sync_reading_shelves';
export interface ScheduledJob {
id: string;
@@ -68,6 +67,9 @@ export class SchedulerService {
});
}
// Clean up deprecated scheduled jobs
await this.cleanupDeprecatedJobs();
// Create default jobs if they don't exist
await this.ensureDefaultJobs();
@@ -136,15 +138,8 @@ export class SchedulerService {
payload: {},
},
{
name: 'Sync Goodreads Shelves',
type: 'sync_goodreads_shelves' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default
payload: {},
},
{
name: 'Sync Hardcover Lists',
type: 'sync_hardcover_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: {},
@@ -187,6 +182,36 @@ export class SchedulerService {
}
}
/**
* Remove any old jobs that are no longer supported
*/
private async cleanupDeprecatedJobs(): Promise<void> {
try {
const deprecatedTypes = [
'sync_goodreads_shelves',
'sync_hardcover_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
*/
@@ -374,11 +399,8 @@ export class SchedulerService {
case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job);
break;
case 'sync_goodreads_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job);
break;
case 'sync_hardcover_shelves':
bullJobId = await this.triggerSyncHardcoverShelves(job);
case 'sync_reading_shelves':
bullJobId = await this.triggerSyncShelves(job);
break;
default:
throw new Error(`Unknown job type: ${job.type}`);
@@ -663,17 +685,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);
}
/**
* Trigger Hardcover lists sync
*/
private async triggerSyncHardcoverShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncHardcoverShelvesJob(job.id);
private async triggerSyncShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncShelvesJob(job.id);
}
}