mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
cb9f1b81bc
Introduce full support for Audible series exploration: API routes, frontend pages, components, hooks, and integrations. Key changes: - Prisma: add Audiobook.seriesAsin for linking audiobooks to series detail pages. - Backend: add /api/series/search and /api/series/[asin] routes that require auth; scrape Audible series data and enrich books with library availability. - Integrations/services: add audible-series integration and update request/HTTP services to support the workflow. - Frontend: add /series and /series/[asin] pages, new components (SeriesCard, SeriesGrid, SeriesDetailCard, SimilarSeriesRow) and wire them to a new useSeries hook; update AudiobookDetailsModal to show/link series; add Series link to Header. - Misc: extend audiobook types with series fields and add seriesLabels to language-config for scraping. These changes enable users to search for series, view series metadata and books, and navigate between audiobook and series detail pages.
272 lines
8.5 KiB
TypeScript
272 lines
8.5 KiB
TypeScript
/**
|
|
* Component: Request Creator Service
|
|
* Documentation: documentation/backend/services/requests.md
|
|
*
|
|
* Shared request-creation logic used by both the API route and Goodreads sync.
|
|
* Encapsulates: duplicate detection, library check, Audnexus enrichment,
|
|
* audiobook record creation, approval flow, notification queuing, and search job triggering.
|
|
*/
|
|
|
|
import { prisma } from '@/lib/db';
|
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
|
|
const logger = RMABLogger.create('RequestCreator');
|
|
|
|
export interface CreateRequestInput {
|
|
asin: string;
|
|
title: string;
|
|
author: string;
|
|
narrator?: string;
|
|
description?: string;
|
|
coverArtUrl?: string;
|
|
}
|
|
|
|
export interface CreateRequestOptions {
|
|
skipAutoSearch?: boolean;
|
|
}
|
|
|
|
export type CreateRequestResult =
|
|
| { success: true; request: any }
|
|
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
|
|
|
|
/**
|
|
* Create a request for a user, with full duplicate detection, library checks,
|
|
* Audnexus enrichment, approval flow, notifications, and search job triggering.
|
|
*/
|
|
export async function createRequestForUser(
|
|
userId: string,
|
|
audiobook: CreateRequestInput,
|
|
options: CreateRequestOptions = {}
|
|
): Promise<CreateRequestResult> {
|
|
const { skipAutoSearch = false } = options;
|
|
|
|
// Check for existing active request (downloaded/available) for this ASIN
|
|
const existingActiveRequest = await prisma.request.findFirst({
|
|
where: {
|
|
audiobook: { audibleAsin: audiobook.asin },
|
|
type: 'audiobook',
|
|
status: { in: ['downloaded', 'available'] },
|
|
deletedAt: null,
|
|
},
|
|
});
|
|
|
|
if (existingActiveRequest) {
|
|
const status = existingActiveRequest.status;
|
|
return {
|
|
success: false,
|
|
reason: status === 'available' ? 'already_available' : 'being_processed',
|
|
message: status === 'available'
|
|
? 'This audiobook is already available in your library'
|
|
: 'This audiobook is being processed and will be available soon',
|
|
};
|
|
}
|
|
|
|
// Check if audiobook is already in Plex/ABS library
|
|
const plexMatch = await findPlexMatch({
|
|
asin: audiobook.asin,
|
|
title: audiobook.title,
|
|
author: audiobook.author,
|
|
narrator: audiobook.narrator,
|
|
});
|
|
|
|
if (plexMatch) {
|
|
return {
|
|
success: false,
|
|
reason: 'already_available',
|
|
message: 'This audiobook is already available in your library',
|
|
};
|
|
}
|
|
|
|
// Fetch full details from Audnexus for year/series
|
|
let year: number | undefined;
|
|
let series: string | undefined;
|
|
let seriesPart: string | undefined;
|
|
let seriesAsin: string | undefined;
|
|
try {
|
|
const audibleService = getAudibleService();
|
|
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
|
|
|
if (audnexusData?.releaseDate) {
|
|
try {
|
|
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
|
if (!isNaN(releaseYear)) {
|
|
year = releaseYear;
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
if (audnexusData?.series) series = audnexusData.series;
|
|
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
|
|
if (audnexusData?.seriesAsin) seriesAsin = audnexusData.seriesAsin;
|
|
} catch (error) {
|
|
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
|
|
// Find or create audiobook record
|
|
let audiobookRecord = await prisma.audiobook.findFirst({
|
|
where: { audibleAsin: audiobook.asin },
|
|
});
|
|
|
|
if (!audiobookRecord) {
|
|
audiobookRecord = await prisma.audiobook.create({
|
|
data: {
|
|
audibleAsin: audiobook.asin,
|
|
title: audiobook.title,
|
|
author: audiobook.author,
|
|
narrator: audiobook.narrator,
|
|
description: audiobook.description,
|
|
coverArtUrl: audiobook.coverArtUrl,
|
|
year,
|
|
series,
|
|
seriesPart,
|
|
seriesAsin,
|
|
status: 'requested',
|
|
},
|
|
});
|
|
logger.debug(`Created audiobook ${audiobookRecord.id} for ASIN ${audiobook.asin}`);
|
|
} else {
|
|
// Update existing record with clean metadata (e.g. Audnexus title replacing Goodreads title)
|
|
const updates: Record<string, any> = {};
|
|
if (audiobook.title && audiobook.title !== audiobookRecord.title) updates.title = audiobook.title;
|
|
if (audiobook.author && audiobook.author !== audiobookRecord.author) updates.author = audiobook.author;
|
|
if (audiobook.coverArtUrl && !audiobookRecord.coverArtUrl) updates.coverArtUrl = audiobook.coverArtUrl;
|
|
if (year) updates.year = year;
|
|
if (series) updates.series = series;
|
|
if (seriesPart) updates.seriesPart = seriesPart;
|
|
if (seriesAsin) updates.seriesAsin = seriesAsin;
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
audiobookRecord = await prisma.audiobook.update({
|
|
where: { id: audiobookRecord.id },
|
|
data: updates,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check if user already has an active request for this audiobook
|
|
const existingRequest = await prisma.request.findFirst({
|
|
where: {
|
|
userId,
|
|
audiobookId: audiobookRecord.id,
|
|
type: 'audiobook',
|
|
deletedAt: null,
|
|
},
|
|
});
|
|
|
|
if (existingRequest) {
|
|
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
|
|
if (!canReRequest) {
|
|
return {
|
|
success: false,
|
|
reason: 'duplicate',
|
|
message: 'You have already requested this audiobook',
|
|
};
|
|
}
|
|
// Delete existing failed/warn/cancelled request
|
|
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
|
|
await prisma.request.delete({ where: { id: existingRequest.id } });
|
|
}
|
|
|
|
// Check ANY user's active request for same audiobook (avoid duplicate processing)
|
|
const anyActiveRequest = await prisma.request.findFirst({
|
|
where: {
|
|
audiobookId: audiobookRecord.id,
|
|
type: 'audiobook',
|
|
status: { notIn: ['failed', 'warn', 'cancelled', 'available', 'downloaded'] },
|
|
deletedAt: null,
|
|
},
|
|
});
|
|
|
|
if (anyActiveRequest && anyActiveRequest.userId !== userId) {
|
|
return {
|
|
success: false,
|
|
reason: 'being_processed',
|
|
message: 'This audiobook is already being requested by another user',
|
|
};
|
|
}
|
|
|
|
// Determine if approval is needed
|
|
let needsApproval = false;
|
|
let shouldTriggerSearch = !skipAutoSearch;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { role: true, autoApproveRequests: true, plexUsername: true },
|
|
});
|
|
|
|
if (!user) {
|
|
return { success: false, reason: 'user_not_found', message: 'User not found' };
|
|
}
|
|
|
|
if (user.role === 'admin') {
|
|
needsApproval = false;
|
|
} else {
|
|
if (user.autoApproveRequests === true) {
|
|
needsApproval = false;
|
|
} else if (user.autoApproveRequests === false) {
|
|
needsApproval = true;
|
|
} else {
|
|
const globalConfig = await prisma.configuration.findUnique({
|
|
where: { key: 'auto_approve_requests' },
|
|
});
|
|
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
|
needsApproval = !globalAutoApprove;
|
|
}
|
|
}
|
|
|
|
let initialStatus: string;
|
|
if (needsApproval) {
|
|
initialStatus = 'awaiting_approval';
|
|
shouldTriggerSearch = false;
|
|
} else if (skipAutoSearch) {
|
|
initialStatus = 'awaiting_search';
|
|
} else {
|
|
initialStatus = 'pending';
|
|
}
|
|
|
|
// Create request
|
|
const newRequest = await prisma.request.create({
|
|
data: {
|
|
userId,
|
|
audiobookId: audiobookRecord.id,
|
|
status: initialStatus,
|
|
type: 'audiobook',
|
|
progress: 0,
|
|
},
|
|
include: {
|
|
audiobook: true,
|
|
user: { select: { id: true, plexUsername: true } },
|
|
},
|
|
});
|
|
|
|
const jobQueue = getJobQueueService();
|
|
|
|
// Send notification
|
|
const notificationType = initialStatus === 'awaiting_approval' ? 'request_pending_approval' : 'request_approved';
|
|
await jobQueue.addNotificationJob(
|
|
notificationType,
|
|
newRequest.id,
|
|
audiobookRecord.title,
|
|
audiobookRecord.author,
|
|
user.plexUsername || 'Unknown User'
|
|
).catch((error) => {
|
|
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
|
});
|
|
|
|
// Trigger search job
|
|
if (shouldTriggerSearch) {
|
|
await jobQueue.addSearchJob(newRequest.id, {
|
|
id: audiobookRecord.id,
|
|
title: audiobookRecord.title,
|
|
author: audiobookRecord.author,
|
|
asin: audiobookRecord.audibleAsin || undefined,
|
|
});
|
|
}
|
|
|
|
return { success: true, request: newRequest };
|
|
}
|