mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add e-book fetch API and UI integration for requests
Introduces an API endpoint to trigger e-book downloads for completed requests, with admin UI integration in RecentRequestsTable and RequestActionsDropdown. Updates the admin dashboard to detect e-book sidecar feature availability from settings. Enhances torrent search result handling with info URLs, improves ranking algorithm normalization, and refines interactive search to show all results without threshold filtering. Also allows nullable ratings in request schemas.
This commit is contained in:
@@ -11,6 +11,7 @@ import { ConfirmDialog } from './ConfirmDialog';
|
|||||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
interface RecentRequest {
|
interface RecentRequest {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -26,6 +27,7 @@ interface RecentRequest {
|
|||||||
|
|
||||||
interface RecentRequestsTableProps {
|
interface RecentRequestsTableProps {
|
||||||
requests: RecentRequest[];
|
requests: RecentRequest[];
|
||||||
|
ebookSidecarEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusBadge(status: string) {
|
function getStatusBadge(status: string) {
|
||||||
@@ -62,13 +64,15 @@ function getStatusBadge(status: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: RecentRequestsTableProps) {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [selectedRequest, setSelectedRequest] = useState<{
|
const [selectedRequest, setSelectedRequest] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const handleDeleteClick = (requestId: string, title: string) => {
|
const handleDeleteClick = (requestId: string, title: string) => {
|
||||||
setSelectedRequest({ id: requestId, title });
|
setSelectedRequest({ id: requestId, title });
|
||||||
@@ -110,11 +114,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
|||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Admin] Failed to delete request:', error);
|
console.error('[Admin] Failed to delete request:', error);
|
||||||
alert(
|
toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
`Failed to delete request: ${
|
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -144,11 +144,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
|||||||
await mutate('/api/admin/requests/recent');
|
await mutate('/api/admin/requests/recent');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Admin] Failed to trigger manual search:', error);
|
console.error('[Admin] Failed to trigger manual search:', error);
|
||||||
alert(
|
toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
`Failed to trigger manual search: ${
|
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,11 +168,36 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
|||||||
await mutate('/api/admin/requests/recent');
|
await mutate('/api/admin/requests/recent');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Admin] Failed to cancel request:', error);
|
console.error('[Admin] Failed to cancel request:', error);
|
||||||
alert(
|
toast.error(`Failed to cancel request: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
`Failed to cancel request: ${
|
}
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
};
|
||||||
}`
|
|
||||||
);
|
const handleFetchEbook = async (requestId: string) => {
|
||||||
|
setIsFetchingEbook(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`/api/requests/${requestId}/fetch-ebook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || data.message || 'Failed to fetch e-book');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(data.message || 'E-book fetched successfully');
|
||||||
|
} else {
|
||||||
|
toast.warning(`E-book fetch failed: ${data.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Admin] Failed to fetch e-book:', error);
|
||||||
|
toast.error(`Failed to fetch e-book: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsFetchingEbook(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,7 +303,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
|||||||
onDelete={handleDeleteClick}
|
onDelete={handleDeleteClick}
|
||||||
onManualSearch={handleManualSearch}
|
onManualSearch={handleManualSearch}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
isLoading={isDeleting}
|
onFetchEbook={handleFetchEbook}
|
||||||
|
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||||
|
isLoading={isDeleting || isFetchingEbook}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface RequestActionsDropdownProps {
|
|||||||
onDelete: (requestId: string, title: string) => void;
|
onDelete: (requestId: string, title: string) => void;
|
||||||
onManualSearch: (requestId: string) => Promise<void>;
|
onManualSearch: (requestId: string) => Promise<void>;
|
||||||
onCancel: (requestId: string) => Promise<void>;
|
onCancel: (requestId: string) => Promise<void>;
|
||||||
|
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||||
|
ebookSidecarEnabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export function RequestActionsDropdown({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onManualSearch,
|
onManualSearch,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onFetchEbook,
|
||||||
|
ebookSidecarEnabled = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: RequestActionsDropdownProps) {
|
}: RequestActionsDropdownProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -40,6 +44,7 @@ export function RequestActionsDropdown({
|
|||||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||||
const canDelete = true; // Admins can always delete
|
const canDelete = true; // Admins can always delete
|
||||||
const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||||
|
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,6 +93,17 @@ export function RequestActionsDropdown({
|
|||||||
onDelete(request.requestId, request.title);
|
onDelete(request.requestId, request.title);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFetchEbook = async () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (onFetchEbook) {
|
||||||
|
try {
|
||||||
|
await onFetchEbook(request.requestId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch e-book:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
{/* Three-dot menu button */}
|
{/* Three-dot menu button */}
|
||||||
@@ -185,8 +201,32 @@ export function RequestActionsDropdown({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fetch E-book */}
|
||||||
|
{canFetchEbook && (
|
||||||
|
<button
|
||||||
|
onClick={handleFetchEbook}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Try to fetch Ebook
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Divider if we have search/view actions and other actions */}
|
{/* Divider if we have search/view actions and other actions */}
|
||||||
{(canSearch || canViewSource) && (canCancel || canDelete) && (
|
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -5,15 +5,15 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||||
import { MetricCard } from './components/MetricCard';
|
import { MetricCard } from './components/MetricCard';
|
||||||
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
function AdminDashboardContent() {
|
||||||
// Fetch data with auto-refresh every 10 seconds
|
// Fetch data with auto-refresh every 10 seconds
|
||||||
const { data: metrics, error: metricsError } = useSWR(
|
const { data: metrics, error: metricsError } = useSWR(
|
||||||
'/api/admin/metrics',
|
'/api/admin/metrics',
|
||||||
@@ -39,6 +39,14 @@ export default function AdminDashboard() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: settingsData } = useSWR(
|
||||||
|
'/api/admin/settings',
|
||||||
|
authenticatedFetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: 60000, // Settings change infrequently
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = !metrics || !downloadsData || !requestsData;
|
const isLoading = !metrics || !downloadsData || !requestsData;
|
||||||
const hasError = metricsError || downloadsError || requestsError;
|
const hasError = metricsError || downloadsError || requestsError;
|
||||||
|
|
||||||
@@ -202,7 +210,10 @@ export default function AdminDashboard() {
|
|||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
Recent Requests
|
Recent Requests
|
||||||
</h2>
|
</h2>
|
||||||
<RecentRequestsTable requests={requestsData.requests} />
|
<RecentRequestsTable
|
||||||
|
requests={requestsData.requests}
|
||||||
|
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
@@ -298,3 +309,11 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<AdminDashboardContent />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const RequestWithTorrentSchema = z.object({
|
|||||||
coverArtUrl: z.string().optional(),
|
coverArtUrl: z.string().optional(),
|
||||||
durationMinutes: z.number().optional(),
|
durationMinutes: z.number().optional(),
|
||||||
releaseDate: z.string().optional(),
|
releaseDate: z.string().optional(),
|
||||||
rating: z.number().optional(),
|
rating: z.number().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
torrent: z.object({
|
torrent: z.object({
|
||||||
guid: z.string(),
|
guid: z.string(),
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Component: Fetch E-book API
|
||||||
|
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||||
|
*
|
||||||
|
* Triggers e-book download for a completed 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 fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const DEBUG_ENABLED = process.env.LOG_LEVEL === 'debug';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize path component (same logic as file-organizer)
|
||||||
|
*/
|
||||||
|
function sanitizePath(name: string): string {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.replace(/[<>:"/\\|?*]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.replace(/\.+$/, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.slice(0, 200)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build target path (same logic as file-organizer)
|
||||||
|
*/
|
||||||
|
function buildTargetPath(
|
||||||
|
baseDir: string,
|
||||||
|
author: string,
|
||||||
|
title: string,
|
||||||
|
year?: number | null,
|
||||||
|
asin?: string | null
|
||||||
|
): string {
|
||||||
|
const authorClean = sanitizePath(author);
|
||||||
|
const titleClean = sanitizePath(title);
|
||||||
|
|
||||||
|
let folderName = titleClean;
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
folderName = `${folderName} (${year})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asin) {
|
||||||
|
folderName = `${folderName} ${asin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(baseDir, authorClean, folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Check if e-book sidecar is enabled
|
||||||
|
const ebookEnabledConfig = await prisma.configuration.findUnique({
|
||||||
|
where: { key: 'ebook_sidecar_enabled' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ebookEnabledConfig?.value !== 'true') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'E-book sidecar feature is not enabled' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the request with audiobook data
|
||||||
|
const requestRecord = await prisma.request.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
audiobook: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestRecord) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Request not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if request is in completed state
|
||||||
|
if (!['downloaded', 'available'].includes(requestRecord.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Cannot fetch e-book for request in ${requestRecord.status} status` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audiobook = requestRecord.audiobook;
|
||||||
|
|
||||||
|
// Get configuration
|
||||||
|
const [mediaDirConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
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 preferredFormat = formatConfig?.value || 'epub';
|
||||||
|
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
|
||||||
|
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
|
||||||
|
|
||||||
|
// Get year from AudibleCache if available
|
||||||
|
let year: number | undefined;
|
||||||
|
if (audiobook.audibleAsin) {
|
||||||
|
const audibleCacheData = await prisma.audibleCache.findUnique({
|
||||||
|
where: { asin: audiobook.audibleAsin },
|
||||||
|
select: { releaseDate: true },
|
||||||
|
});
|
||||||
|
if (audibleCacheData?.releaseDate) {
|
||||||
|
year = new Date(audibleCacheData.releaseDate).getFullYear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build target path
|
||||||
|
const targetPath = buildTargetPath(
|
||||||
|
mediaDir,
|
||||||
|
audiobook.author,
|
||||||
|
audiobook.title,
|
||||||
|
year,
|
||||||
|
audiobook.audibleAsin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (DEBUG_ENABLED) {
|
||||||
|
console.log(`[FetchEbook] Request: ${id}, Title: "${audiobook.title}", Author: "${audiobook.author}"`);
|
||||||
|
console.log(`[FetchEbook] Target path: ${targetPath}`);
|
||||||
|
console.log(`[FetchEbook] Config: format=${preferredFormat}, baseUrl=${baseUrl}, flaresolverr=${flaresolverrUrl || 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target directory exists
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
} catch {
|
||||||
|
if (DEBUG_ENABLED) {
|
||||||
|
console.log(`[FetchEbook] Target directory not found: ${targetPath}`);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Audiobook directory not found. Was the audiobook properly organized?' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download e-book
|
||||||
|
const result = await downloadEbook(
|
||||||
|
audiobook.audibleAsin || '',
|
||||||
|
audiobook.title,
|
||||||
|
audiobook.author,
|
||||||
|
targetPath,
|
||||||
|
preferredFormat,
|
||||||
|
baseUrl,
|
||||||
|
undefined, // No logger in API context
|
||||||
|
flaresolverrUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`[FetchEbook] Success: ${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 {
|
||||||
|
console.log(`[FetchEbook] Failed for "${audiobook.title}": ${result.error}`);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: result.error || 'E-book download failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[FetchEbook] Unexpected error:', error instanceof Error ? error.message : error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -123,30 +123,18 @@ export async function POST(
|
|||||||
author: requestRecord.audiobook.author,
|
author: requestRecord.audiobook.author,
|
||||||
}, indexerPriorities, flagConfigs);
|
}, indexerPriorities, flagConfigs);
|
||||||
|
|
||||||
// Dual threshold filtering:
|
// No threshold filtering for interactive search - show all results
|
||||||
// 1. Base score must be >= 50 (quality minimum)
|
// User can see scores and make their own decision
|
||||||
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
|
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results (no threshold filter - user decides)`);
|
||||||
const filteredResults = rankedResults.filter(result =>
|
|
||||||
result.score >= 50 && result.finalScore >= 50
|
|
||||||
);
|
|
||||||
|
|
||||||
const disqualifiedByNegativeBonus = rankedResults.filter(result =>
|
|
||||||
result.score >= 50 && result.finalScore < 50
|
|
||||||
).length;
|
|
||||||
|
|
||||||
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
|
|
||||||
if (disqualifiedByNegativeBonus > 0) {
|
|
||||||
console.log(`[InteractiveSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log top 3 results with detailed score breakdown for debugging
|
// Log top 3 results with detailed score breakdown for debugging
|
||||||
const top3 = filteredResults.slice(0, 3);
|
const top3 = rankedResults.slice(0, 3);
|
||||||
if (top3.length > 0) {
|
if (top3.length > 0) {
|
||||||
console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`);
|
console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`);
|
||||||
console.log(`[InteractiveSearch] Search Query: "${searchQuery}"`);
|
console.log(`[InteractiveSearch] Search Query: "${searchQuery}"`);
|
||||||
console.log(`[InteractiveSearch] Requested Title (for ranking): "${requestRecord.audiobook.title}"`);
|
console.log(`[InteractiveSearch] Requested Title (for ranking): "${requestRecord.audiobook.title}"`);
|
||||||
console.log(`[InteractiveSearch] Requested Author (for ranking): "${requestRecord.audiobook.author}"`);
|
console.log(`[InteractiveSearch] Requested Author (for ranking): "${requestRecord.audiobook.author}"`);
|
||||||
console.log(`[InteractiveSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
|
console.log(`[InteractiveSearch] Top ${top3.length} results (out of ${rankedResults.length} total):`);
|
||||||
console.log(`[InteractiveSearch] --------------------------------------------------------`);
|
console.log(`[InteractiveSearch] --------------------------------------------------------`);
|
||||||
top3.forEach((result, index) => {
|
top3.forEach((result, index) => {
|
||||||
console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`);
|
console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`);
|
||||||
@@ -177,7 +165,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add rank position to each result
|
// Add rank position to each result
|
||||||
const resultsWithRank = filteredResults.map((result, index) => ({
|
const resultsWithRank = rankedResults.map((result, index) => ({
|
||||||
...result,
|
...result,
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
}));
|
}));
|
||||||
@@ -185,9 +173,9 @@ export async function POST(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
results: resultsWithRank,
|
results: resultsWithRank,
|
||||||
message: filteredResults.length > 0
|
message: rankedResults.length > 0
|
||||||
? `Found ${filteredResults.length} quality matches`
|
? `Found ${rankedResults.length} results`
|
||||||
: 'No quality matches found',
|
: 'No results found',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to perform interactive search:', error);
|
console.error('Failed to perform interactive search:', error);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const CreateRequestSchema = z.object({
|
|||||||
coverArtUrl: z.string().optional(),
|
coverArtUrl: z.string().optional(),
|
||||||
durationMinutes: z.number().optional(),
|
durationMinutes: z.number().optional(),
|
||||||
releaseDate: z.string().optional(),
|
releaseDate: z.string().optional(),
|
||||||
rating: z.number().optional(),
|
rating: z.number().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
|
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<a
|
<a
|
||||||
href={result.guid}
|
href={result.infoUrl || result.guid}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ interface ProwlarrSearchResult {
|
|||||||
seeders: number;
|
seeders: number;
|
||||||
leechers: number;
|
leechers: number;
|
||||||
publishDate: string;
|
publishDate: string;
|
||||||
downloadUrl: string;
|
downloadUrl?: string; // Torrent file download URL (most indexers)
|
||||||
|
magnetUrl?: string; // Magnet link (public trackers like TPB)
|
||||||
|
infoUrl?: string; // Link to indexer's info page
|
||||||
infoHash?: string;
|
infoHash?: string;
|
||||||
categories?: number[];
|
categories?: number[];
|
||||||
downloadVolumeFactor?: number;
|
downloadVolumeFactor?: number;
|
||||||
@@ -104,14 +106,22 @@ export class ProwlarrService {
|
|||||||
|
|
||||||
const response = await this.client.get('/search', { params });
|
const response = await this.client.get('/search', { params });
|
||||||
|
|
||||||
// Debug: Log first raw result to see structure
|
// Debug: Log first raw result to see structure (debug mode only)
|
||||||
if (response.data.length > 0) {
|
if (process.env.LOG_LEVEL === 'debug' && response.data.length > 0) {
|
||||||
console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2));
|
console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2));
|
||||||
|
console.log(`[Prowlarr] Received ${response.data.length} total results from API`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform Prowlarr results to our format
|
// Transform Prowlarr results to our format
|
||||||
const results = response.data
|
const results = response.data
|
||||||
.map((result: ProwlarrSearchResult) => this.transformResult(result))
|
.map((result: ProwlarrSearchResult, index: number) => {
|
||||||
|
const transformed = this.transformResult(result);
|
||||||
|
if (!transformed && process.env.LOG_LEVEL === 'debug') {
|
||||||
|
// Log the full raw result that was skipped (debug mode only)
|
||||||
|
console.log(`[Prowlarr] Result #${index + 1} was skipped. Raw data:`, JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
return transformed;
|
||||||
|
})
|
||||||
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
|
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
|
||||||
|
|
||||||
// Filter by protocol based on configured download client
|
// Filter by protocol based on configured download client
|
||||||
@@ -251,6 +261,7 @@ export class ProwlarrService {
|
|||||||
leechers,
|
leechers,
|
||||||
publishDate: item.pubDate ? new Date(item.pubDate) : new Date(),
|
publishDate: item.pubDate ? new Date(item.pubDate) : new Date(),
|
||||||
downloadUrl: downloadUrl.trim(),
|
downloadUrl: downloadUrl.trim(),
|
||||||
|
infoUrl: item.comments || undefined, // RSS feeds often have comments field with info URL
|
||||||
infoHash: getAttr('infohash'),
|
infoHash: getAttr('infohash'),
|
||||||
guid: item.guid || '',
|
guid: item.guid || '',
|
||||||
format: metadata.format,
|
format: metadata.format,
|
||||||
@@ -352,9 +363,12 @@ export class ProwlarrService {
|
|||||||
*/
|
*/
|
||||||
private transformResult(result: ProwlarrSearchResult): TorrentResult | null {
|
private transformResult(result: ProwlarrSearchResult): TorrentResult | null {
|
||||||
try {
|
try {
|
||||||
// Validate download URL
|
// Get download URL - prefer downloadUrl (torrent file), fallback to magnetUrl (magnet link)
|
||||||
if (!result.downloadUrl || typeof result.downloadUrl !== 'string' || result.downloadUrl.trim() === '') {
|
const downloadUrl = result.downloadUrl || result.magnetUrl || '';
|
||||||
console.warn(`[Prowlarr] Skipping result "${result.title}" - missing download URL`);
|
|
||||||
|
// Validate we have a valid download URL
|
||||||
|
if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') {
|
||||||
|
console.warn(`[Prowlarr] Skipping result "${result.title}" - missing both downloadUrl and magnetUrl`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +386,8 @@ export class ProwlarrService {
|
|||||||
seeders: result.seeders,
|
seeders: result.seeders,
|
||||||
leechers: result.leechers,
|
leechers: result.leechers,
|
||||||
publishDate: new Date(result.publishDate),
|
publishDate: new Date(result.publishDate),
|
||||||
downloadUrl: result.downloadUrl.trim(),
|
downloadUrl: downloadUrl.trim(),
|
||||||
|
infoUrl: result.infoUrl,
|
||||||
infoHash: result.infoHash,
|
infoHash: result.infoHash,
|
||||||
guid: result.guid,
|
guid: result.guid,
|
||||||
format: metadata.format,
|
format: metadata.format,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface TorrentResult {
|
|||||||
leechers: number;
|
leechers: number;
|
||||||
publishDate: Date;
|
publishDate: Date;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
|
infoUrl?: string; // Link to indexer's info page (for user reference)
|
||||||
infoHash?: string;
|
infoHash?: string;
|
||||||
guid: string;
|
guid: string;
|
||||||
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
|
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
|
||||||
@@ -273,9 +274,10 @@ export class RankingAlgorithm {
|
|||||||
torrent: TorrentResult,
|
torrent: TorrentResult,
|
||||||
audiobook: AudiobookRequest
|
audiobook: AudiobookRequest
|
||||||
): number {
|
): number {
|
||||||
const torrentTitle = torrent.title.toLowerCase();
|
// Normalize whitespace (multiple spaces → single space) for consistent matching
|
||||||
const requestTitle = audiobook.title.toLowerCase();
|
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
const requestAuthor = audiobook.author.toLowerCase();
|
const requestTitle = audiobook.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
const requestAuthor = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
|
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
|
||||||
// Extract significant words (filter out common stop words)
|
// Extract significant words (filter out common stop words)
|
||||||
@@ -353,8 +355,14 @@ export class RankingAlgorithm {
|
|||||||
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
|
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
|
||||||
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
|
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
|
||||||
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
|
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
|
||||||
|
|
||||||
|
// Check if afterTitle starts with author name (handles space-separated format like "Title Author Year")
|
||||||
|
const afterStartsWithAuthor = requestAuthor.length > 2 &&
|
||||||
|
afterTitle.trim().startsWith(requestAuthor);
|
||||||
|
|
||||||
const hasMetadataSuffix = afterTitle === '' ||
|
const hasMetadataSuffix = afterTitle === '' ||
|
||||||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
|
metadataMarkers.some(marker => afterTitle.startsWith(marker)) ||
|
||||||
|
afterStartsWithAuthor;
|
||||||
|
|
||||||
// Check prefix validity:
|
// Check prefix validity:
|
||||||
// - No words before = clean match
|
// - No words before = clean match
|
||||||
|
|||||||
Reference in New Issue
Block a user