mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add first-class ebook request support and UI
Implements first-class ebook requests with their own type, parent-child relationship to audiobook requests, and separate status flow. Updates database schema and migrations to support 'type' and 'parentRequestId' fields on requests. Adds processors and job types for ebook search and direct HTTP download from Anna's Archive, with FlareSolverr integration for Cloudflare bypass. Enhances admin UI tables and request actions to display and manage ebook requests, including orange badge and source links. Updates documentation to reflect new ebook support, configuration, and behavior.
This commit is contained in:
@@ -16,6 +16,7 @@ interface ActiveDownload {
|
||||
eta: number | null;
|
||||
user: string;
|
||||
startedAt: Date;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
}
|
||||
|
||||
interface ActiveDownloadsTableProps {
|
||||
@@ -77,7 +78,7 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Audiobook
|
||||
Request
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
User
|
||||
@@ -104,8 +105,21 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{download.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{download.title}
|
||||
</span>
|
||||
{download.type === 'ebook' && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{download.author}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface RecentRequest {
|
||||
title: string;
|
||||
author: string;
|
||||
status: string;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
user: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
@@ -237,7 +238,7 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Audiobook
|
||||
Request
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
User
|
||||
@@ -264,8 +265,21 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{request.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{request.title}
|
||||
</span>
|
||||
{request.type === 'ebook' && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
@@ -280,7 +294,9 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
|
||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
|
||||
{request.user}
|
||||
</td>
|
||||
<td className="px-6 py-4">{getStatusBadge(request.status)}</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(request.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
|
||||
</td>
|
||||
@@ -298,6 +314,7 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
status: request.status,
|
||||
type: request.type,
|
||||
torrentUrl: request.torrentUrl,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface RequestActionsDropdownProps {
|
||||
title: string;
|
||||
author: string;
|
||||
status: string;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
torrentUrl?: string | null;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
@@ -41,15 +42,41 @@ export function RequestActionsDropdown({
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
// Determine request type
|
||||
const isEbook = request.type === 'ebook';
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
// Only show "View Source" if we have a valid indexer page URL (not a magnet link)
|
||||
const canViewSource = !!request.torrentUrl &&
|
||||
!request.torrentUrl.startsWith('magnet:') &&
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
// For audiobooks, show indexer page URL (not magnet links)
|
||||
let viewSourceUrl: string | null = null;
|
||||
if (isEbook && request.torrentUrl) {
|
||||
// torrentUrl for ebooks is JSON array of slow download URLs
|
||||
// Extract MD5 from URL pattern: /slow_download/[md5]/...
|
||||
try {
|
||||
const urls = JSON.parse(request.torrentUrl);
|
||||
if (Array.isArray(urls) && urls.length > 0) {
|
||||
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
|
||||
if (md5Match) {
|
||||
viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
} else if (request.torrentUrl && !request.torrentUrl.startsWith('magnet:')) {
|
||||
viewSourceUrl = request.torrentUrl;
|
||||
}
|
||||
|
||||
const canViewSource = !!viewSourceUrl &&
|
||||
['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||
|
||||
// "Try to fetch Ebook" only for audiobook requests
|
||||
const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -166,9 +193,9 @@ export function RequestActionsDropdown({
|
||||
)}
|
||||
|
||||
{/* View Source */}
|
||||
{canViewSource && (
|
||||
{canViewSource && viewSourceUrl && (
|
||||
<a
|
||||
href={request.torrentUrl!}
|
||||
href={viewSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setIsOpen(false)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Get active downloads with related data
|
||||
// Get active downloads with related data (both audiobook and ebook)
|
||||
const activeDownloads = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
type: true, // 'audiobook' or 'ebook'
|
||||
progress: true,
|
||||
updatedAt: true,
|
||||
audiobook: {
|
||||
@@ -54,6 +55,8 @@ export async function GET(request: NextRequest) {
|
||||
torrentName: true,
|
||||
torrentHash: true,
|
||||
nzbId: true,
|
||||
downloadClient: true, // qbittorrent, sabnzbd, or direct
|
||||
torrentSizeBytes: true,
|
||||
startedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
@@ -75,19 +78,38 @@ export async function GET(request: NextRequest) {
|
||||
let speed = 0;
|
||||
let eta: number | null = null;
|
||||
|
||||
const downloadHistory = download.downloadHistory[0];
|
||||
const downloadClient = downloadHistory?.downloadClient;
|
||||
|
||||
try {
|
||||
if (clientType === 'qbittorrent') {
|
||||
if (downloadClient === 'direct') {
|
||||
// Direct HTTP download (ebooks) - estimate speed from progress and time elapsed
|
||||
const startedAt = downloadHistory?.startedAt || downloadHistory?.createdAt;
|
||||
const totalSize = downloadHistory?.torrentSizeBytes ? Number(downloadHistory.torrentSizeBytes) : 0;
|
||||
|
||||
if (startedAt && download.progress > 0 && totalSize > 0) {
|
||||
const elapsedMs = Date.now() - new Date(startedAt).getTime();
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const bytesDownloaded = (download.progress / 100) * totalSize;
|
||||
|
||||
if (elapsedSeconds > 0) {
|
||||
speed = Math.round(bytesDownloaded / elapsedSeconds);
|
||||
const remainingBytes = totalSize - bytesDownloaded;
|
||||
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
|
||||
}
|
||||
}
|
||||
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
|
||||
// Get torrent hash from download history
|
||||
const torrentHash = download.downloadHistory[0]?.torrentHash;
|
||||
const torrentHash = downloadHistory?.torrentHash;
|
||||
if (torrentHash) {
|
||||
const qbService = await getQBittorrentService();
|
||||
const torrentInfo = await qbService.getTorrent(torrentHash);
|
||||
speed = torrentInfo.dlspeed;
|
||||
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
|
||||
}
|
||||
} else if (clientType === 'sabnzbd') {
|
||||
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
|
||||
// Get NZB ID from download history
|
||||
const nzbId = download.downloadHistory[0]?.nzbId;
|
||||
const nzbId = downloadHistory?.nzbId;
|
||||
if (nzbId) {
|
||||
const sabnzbdService = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbdService.getNZB(nzbId);
|
||||
@@ -107,13 +129,14 @@ export async function GET(request: NextRequest) {
|
||||
title: download.audiobook.title,
|
||||
author: download.audiobook.author,
|
||||
status: download.status,
|
||||
type: download.type, // 'audiobook' or 'ebook'
|
||||
progress: download.progress,
|
||||
speed,
|
||||
eta,
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
torrentName: downloadHistory?.torrentName || null,
|
||||
downloadStatus: downloadHistory?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
startedAt: downloadHistory?.startedAt || downloadHistory?.createdAt || download.updatedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
status: request.status,
|
||||
type: request.type, // 'audiobook' or 'ebook'
|
||||
user: request.user.plexUsername,
|
||||
createdAt: request.createdAt,
|
||||
completedAt: request.completedAt,
|
||||
|
||||
@@ -61,13 +61,14 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
|
||||
|
||||
// First check: Is there an existing request in 'downloaded' or 'available' status?
|
||||
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
|
||||
// This catches the gap where files are organized but Plex hasn't scanned yet
|
||||
const existingActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: {
|
||||
audibleAsin: audiobook.asin,
|
||||
},
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -181,11 +182,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
// Check if user already has an active (non-deleted) audiobook request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
@@ -263,6 +265,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'awaiting_approval',
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
selectedTorrent: torrent as any, // Store the selected torrent for later
|
||||
},
|
||||
@@ -304,6 +307,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'downloading',
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -136,6 +136,8 @@ async function handler(req: AuthenticatedRequest) {
|
||||
where: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
|
||||
@@ -187,6 +189,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
status: initialStatus,
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
priority: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
* Component: Fetch E-book API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Triggers e-book download for a completed request
|
||||
* Creates an ebook request for a completed audiobook request
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { downloadEbook } from '@/lib/services/ebook-scraper';
|
||||
import { buildAudiobookPath } from '@/lib/utils/file-organizer';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.FetchEbook');
|
||||
@@ -23,7 +20,7 @@ export async function POST(
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { id: parentRequestId } = await params;
|
||||
|
||||
// Check if e-book sidecar is enabled
|
||||
const ebookEnabledConfig = await prisma.configuration.findUnique({
|
||||
@@ -37,118 +34,108 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Get the request with audiobook data
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
// Get the parent request with audiobook data
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
if (!parentRequest) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if request is in completed state
|
||||
if (!['downloaded', 'available'].includes(requestRecord.status)) {
|
||||
// Check if parent request is in completed state
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot fetch e-book for request in ${requestRecord.status} status` },
|
||||
{ error: `Cannot fetch e-book for request in ${parentRequest.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const audiobook = requestRecord.audiobook;
|
||||
|
||||
// Get configuration
|
||||
const [mediaDirConfig, templateConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'audiobook_path_template' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
|
||||
]);
|
||||
|
||||
const mediaDir = mediaDirConfig?.value || '/media/audiobooks';
|
||||
const template = templateConfig?.value || '{author}/{title} {asin}';
|
||||
const preferredFormat = formatConfig?.value || 'epub';
|
||||
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
|
||||
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
|
||||
|
||||
// Fetch year from audible cache if ASIN is available
|
||||
let year: number | undefined;
|
||||
if (audiobook.audibleAsin) {
|
||||
const audibleCache = await prisma.audibleCache.findUnique({
|
||||
where: { asin: audiobook.audibleAsin },
|
||||
select: { releaseDate: true },
|
||||
});
|
||||
if (audibleCache?.releaseDate) {
|
||||
year = new Date(audibleCache.releaseDate).getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
// Build target path using centralized function
|
||||
const targetPath = buildAudiobookPath(
|
||||
mediaDir,
|
||||
template,
|
||||
{
|
||||
author: audiobook.author,
|
||||
title: audiobook.title,
|
||||
narrator: audiobook.narrator || undefined,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
year,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('Fetch e-book request', {
|
||||
requestId: id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
targetPath,
|
||||
format: preferredFormat,
|
||||
baseUrl,
|
||||
flaresolverr: flaresolverrUrl || 'none'
|
||||
// Check if an ebook request already exists for this parent
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if target directory exists
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
logger.debug(`Target directory not found: ${targetPath}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook directory not found. Was the audiobook properly organized?' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (existingEbookRequest) {
|
||||
// Check status - if failed/pending, we can retry
|
||||
if (['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
// Reset and retry
|
||||
await prisma.request.update({
|
||||
where: { id: existingEbookRequest.id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Download e-book
|
||||
const result = await downloadEbook(
|
||||
audiobook.audibleAsin || '',
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
targetPath,
|
||||
preferredFormat,
|
||||
baseUrl,
|
||||
undefined, // No logger in API context
|
||||
flaresolverrUrl
|
||||
);
|
||||
// Trigger search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(existingEbookRequest.id, {
|
||||
id: parentRequest.audiobook.id,
|
||||
title: parentRequest.audiobook.title,
|
||||
author: parentRequest.audiobook.author,
|
||||
asin: parentRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'} for "${audiobook.title}"`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'}`,
|
||||
format: result.format,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`E-book download failed for "${audiobook.title}"`, { error: result.error });
|
||||
logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${parentRequest.audiobook.title}"`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'E-book search retried',
|
||||
requestId: existingEbookRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Already exists and not in a retryable state
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: result.error || 'E-book download failed',
|
||||
message: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
requestId: existingEbookRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new ebook request
|
||||
const ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: parentRequest.userId,
|
||||
audiobookId: parentRequest.audiobookId,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created ebook request ${ebookRequest.id} for "${parentRequest.audiobook.title}"`);
|
||||
|
||||
// Trigger ebook search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(ebookRequest.id, {
|
||||
id: parentRequest.audiobook.id,
|
||||
title: parentRequest.audiobook.title,
|
||||
author: parentRequest.audiobook.author,
|
||||
asin: parentRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'E-book request created and search started',
|
||||
requestId: ebookRequest.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -45,13 +45,14 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook } = CreateRequestSchema.parse(body);
|
||||
|
||||
// First check: Is there an existing request in 'downloaded' or 'available' status?
|
||||
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
|
||||
// This catches the gap where files are organized but Plex hasn't scanned yet
|
||||
const existingActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: {
|
||||
audibleAsin: audiobook.asin,
|
||||
},
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -165,11 +166,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
// Check if user already has an active (non-deleted) audiobook request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
@@ -257,6 +259,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: initialStatus,
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
@@ -353,6 +356,7 @@ export async function GET(request: NextRequest) {
|
||||
const status = searchParams.get('status');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const myOnly = searchParams.get('myOnly') === 'true';
|
||||
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
// Build query
|
||||
@@ -362,6 +366,10 @@ export async function GET(request: NextRequest) {
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
// Filter by type if specified (otherwise returns all types)
|
||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||
where.type = type;
|
||||
}
|
||||
// Only show active (non-deleted) requests
|
||||
where.deletedAt = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user