mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add e-book sidecar integration and improve request handling
Introduces optional e-book sidecar downloads from Anna's Archive, including admin UI, settings API, FlareSolverr integration, and documentation. Enhances request creation logic to prevent duplicate downloads by checking for 'downloaded' and 'available' statuses, updates UI to reflect processing state, and adds SABnzbd support to download and cleanup flows. Also updates ranking algorithm documentation and improves cache invalidation for recent requests.
This commit is contained in:
@@ -102,6 +102,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
await mutate('/api/admin/requests/recent');
|
||||
await mutate('/api/admin/metrics');
|
||||
|
||||
// Invalidate audiobook caches to update request status on home/search pages
|
||||
await mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
|
||||
|
||||
// Close dialog
|
||||
setShowDeleteConfirm(false);
|
||||
setSelectedRequest(null);
|
||||
|
||||
@@ -82,6 +82,12 @@ interface Settings {
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
};
|
||||
ebook: {
|
||||
enabled: boolean;
|
||||
preferredFormat: string;
|
||||
baseUrl: string;
|
||||
flaresolverrUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PendingUser {
|
||||
@@ -127,7 +133,7 @@ export default function AdminSettings() {
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(
|
||||
null
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'account' | 'bookdate'>('library');
|
||||
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'account' | 'bookdate'>('library');
|
||||
|
||||
// Password change form state
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
@@ -147,6 +153,14 @@ export default function AdminSettings() {
|
||||
const [testingBookdate, setTestingBookdate] = useState(false);
|
||||
const [clearingBookdateSwipes, setClearingBookdateSwipes] = useState(false);
|
||||
|
||||
// FlareSolverr testing state
|
||||
const [testingFlaresolverr, setTestingFlaresolverr] = useState(false);
|
||||
const [flaresolverrTestResult, setFlaresolverrTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseTime?: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchCurrentUser();
|
||||
@@ -460,6 +474,73 @@ export default function AdminSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEbookSettings = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/ebook', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: settings.ebook?.enabled || false,
|
||||
format: settings.ebook?.preferredFormat || 'epub',
|
||||
baseUrl: settings.ebook?.baseUrl || 'https://annas-archive.li',
|
||||
flaresolverrUrl: settings.ebook?.flaresolverrUrl || '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save e-book settings');
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: 'E-book sidecar settings saved successfully!' });
|
||||
// Update original settings to reflect the saved state
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)));
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Failed to save e-book settings',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testFlaresolverrConnection = async () => {
|
||||
if (!settings?.ebook?.flaresolverrUrl) {
|
||||
setFlaresolverrTestResult({
|
||||
success: false,
|
||||
message: 'Please enter a FlareSolverr URL first',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingFlaresolverr(true);
|
||||
setFlaresolverrTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: settings.ebook.flaresolverrUrl }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
setFlaresolverrTestResult(result);
|
||||
} catch (error) {
|
||||
setFlaresolverrTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Test failed',
|
||||
});
|
||||
} finally {
|
||||
setTestingFlaresolverr(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testPlexConnection = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
@@ -924,6 +1005,7 @@ export default function AdminSettings() {
|
||||
{ id: 'prowlarr', label: 'Indexers', icon: '🔍' },
|
||||
{ id: 'download', label: 'Download Client', icon: '⬇️' },
|
||||
{ id: 'paths', label: 'Paths', icon: '📁' },
|
||||
{ id: 'ebook', label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate', label: 'BookDate', icon: '📚' },
|
||||
...(isLocalAdmin ? [{ id: 'account', label: 'Account', icon: '🔒' }] : []),
|
||||
];
|
||||
@@ -1915,6 +1997,201 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* E-book Sidecar Tab */}
|
||||
{activeTab === 'ebook' && (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
E-book Sidecar
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Automatically download e-books from Anna's Archive to accompany your audiobooks.
|
||||
E-books are placed in the same folder as the audiobook files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ebook-enabled"
|
||||
checked={settings.ebook?.enabled || false}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
ebook: { ...settings.ebook, enabled: e.target.checked },
|
||||
});
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="ebook-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable e-book sidecar downloads
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When enabled, the system will search for e-books matching your audiobook's ASIN
|
||||
and download them to the same folder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
{settings.ebook?.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Preferred Format
|
||||
</label>
|
||||
<select
|
||||
value={settings.ebook?.preferredFormat || 'epub'}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
ebook: { ...settings.ebook, preferredFormat: e.target.value },
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="epub">EPUB</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mobi">MOBI</option>
|
||||
<option value="azw3">AZW3</option>
|
||||
<option value="any">Any format</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
EPUB is recommended for most e-readers. "Any format" will download the first available format.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL (Advanced) */}
|
||||
{settings.ebook?.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL (Advanced)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.ebook?.baseUrl || 'https://annas-archive.li'}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
ebook: { ...settings.ebook, baseUrl: e.target.value },
|
||||
});
|
||||
}}
|
||||
placeholder="https://annas-archive.li"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Change this if the primary Anna's Archive mirror is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FlareSolverr (Optional - for Cloudflare bypass) */}
|
||||
{settings.ebook?.enabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
FlareSolverr URL (Optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.ebook?.flaresolverrUrl || ''}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
ebook: { ...settings.ebook, flaresolverrUrl: e.target.value },
|
||||
});
|
||||
setFlaresolverrTestResult(null);
|
||||
}}
|
||||
placeholder="http://localhost:8191"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={testFlaresolverrConnection}
|
||||
loading={testingFlaresolverr}
|
||||
variant="secondary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
FlareSolverr helps bypass Cloudflare protection on Anna's Archive.
|
||||
Leave empty if not needed.
|
||||
</p>
|
||||
{flaresolverrTestResult && (
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-lg text-sm ${
|
||||
flaresolverrTestResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
{flaresolverrTestResult.success ? '✓ ' : '✗ '}
|
||||
{flaresolverrTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!settings.ebook?.flaresolverrUrl && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
|
||||
has Cloudflare protection enabled. Success rates are typically lower without it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
How it works
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• Searches Anna's Archive in two ways:</li>
|
||||
<li className="ml-4">1. First tries ASIN (exact match - most accurate)</li>
|
||||
<li className="ml-4">2. Falls back to title + author (with book/language filters)</li>
|
||||
<li>• Downloads matching e-book in your preferred format</li>
|
||||
<li>• Places e-book file in the same folder as the audiobook</li>
|
||||
<li>• If no match is found or download fails, audiobook download continues normally</li>
|
||||
<li>• Completely optional and non-blocking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
||||
⚠️ Important Note
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility.
|
||||
Ensure compliance with your local laws and regulations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={handleSaveEbookSettings}
|
||||
loading={saving}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Save E-book Sidecar Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BookDate Tab */}
|
||||
{activeTab === 'bookdate' && (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
@@ -2738,8 +3015,8 @@ export default function AdminSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Hide for Account tab */}
|
||||
{activeTab !== 'account' && activeTab !== 'bookdate' && (
|
||||
{/* Footer - Hide for Account, BookDate, and E-book tabs (they have their own save buttons) */}
|
||||
{activeTab !== 'account' && activeTab !== 'bookdate' && activeTab !== 'ebook' && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-8 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
@@ -48,6 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
downloadStatus: true,
|
||||
torrentName: true,
|
||||
torrentHash: true,
|
||||
nzbId: true,
|
||||
startedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
@@ -59,48 +62,41 @@ export async function GET(request: NextRequest) {
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Get qBittorrent service
|
||||
let qbService;
|
||||
try {
|
||||
qbService = await getQBittorrentService();
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to initialize qBittorrent service:', error);
|
||||
// Return downloads without speed/eta if qBittorrent is unavailable
|
||||
const formatted = activeDownloads.map((download) => ({
|
||||
requestId: download.id,
|
||||
title: download.audiobook.title,
|
||||
author: download.audiobook.author,
|
||||
status: download.status,
|
||||
progress: download.progress,
|
||||
speed: 0,
|
||||
eta: null,
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
}));
|
||||
return NextResponse.json({ downloads: formatted });
|
||||
}
|
||||
// Get configured download client type
|
||||
const configService = getConfigService();
|
||||
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
|
||||
|
||||
// Format response with speed and ETA from qBittorrent
|
||||
// Format response with speed and ETA from download client
|
||||
const formatted = await Promise.all(
|
||||
activeDownloads.map(async (download) => {
|
||||
let speed = 0;
|
||||
let eta: number | null = null;
|
||||
|
||||
// Get torrent hash from download history
|
||||
const torrentHash = download.downloadHistory[0]?.torrentHash;
|
||||
|
||||
// Fetch torrent info from qBittorrent if we have a hash
|
||||
if (torrentHash) {
|
||||
try {
|
||||
const torrentInfo = await qbService.getTorrent(torrentHash);
|
||||
speed = torrentInfo.dlspeed;
|
||||
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
|
||||
} catch (error) {
|
||||
// Torrent not found or other error - use defaults
|
||||
console.error(`[Admin] Failed to get torrent info for ${torrentHash}:`, error);
|
||||
try {
|
||||
if (clientType === 'qbittorrent') {
|
||||
// Get torrent hash from download history
|
||||
const torrentHash = download.downloadHistory[0]?.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') {
|
||||
// Get NZB ID from download history
|
||||
const nzbId = download.downloadHistory[0]?.nzbId;
|
||||
if (nzbId) {
|
||||
const sabnzbdService = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbdService.getNZB(nzbId);
|
||||
if (nzbInfo) {
|
||||
speed = nzbInfo.downloadSpeed;
|
||||
eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Download client unavailable or download not found - use defaults
|
||||
console.error(`[Admin] Failed to get download info:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Component: E-book Sidecar Settings API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Parse request body
|
||||
const { enabled, format, baseUrl, flaresolverrUrl } = await request.json();
|
||||
|
||||
// Validate format
|
||||
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
|
||||
if (format && !validFormats.includes(format)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid format. Must be one of: ${validFormats.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate baseUrl (basic check)
|
||||
if (baseUrl && !baseUrl.startsWith('http')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate flaresolverrUrl if provided
|
||||
if (flaresolverrUrl && !flaresolverrUrl.startsWith('http')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'FlareSolverr URL must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
const configs = [
|
||||
{
|
||||
key: 'ebook_sidecar_enabled',
|
||||
value: enabled ? 'true' : 'false',
|
||||
category: 'ebook',
|
||||
description: 'Enable e-book sidecar downloads from Annas Archive',
|
||||
},
|
||||
{
|
||||
key: 'ebook_sidecar_preferred_format',
|
||||
value: format || 'epub',
|
||||
category: 'ebook',
|
||||
description: 'Preferred e-book format',
|
||||
},
|
||||
{
|
||||
key: 'ebook_sidecar_base_url',
|
||||
value: baseUrl || 'https://annas-archive.li',
|
||||
category: 'ebook',
|
||||
description: 'Base URL for Annas Archive',
|
||||
},
|
||||
{
|
||||
key: 'ebook_sidecar_flaresolverr_url',
|
||||
value: flaresolverrUrl || '',
|
||||
category: 'ebook',
|
||||
description: 'FlareSolverr URL for bypassing Cloudflare protection',
|
||||
},
|
||||
];
|
||||
|
||||
await configService.setMany(configs);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to save e-book settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Component: FlareSolverr Connection Test API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { testFlareSolverrConnection } from '@/lib/services/ebook-scraper';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: 'FlareSolverr URL is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!url.startsWith('http')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await testFlareSolverrConnection(url);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('FlareSolverr test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -82,6 +82,12 @@ export async function GET(request: NextRequest) {
|
||||
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
|
||||
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
||||
},
|
||||
ebook: {
|
||||
enabled: configMap.get('ebook_sidecar_enabled') === 'true',
|
||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
|
||||
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
||||
},
|
||||
general: {
|
||||
appName: configMap.get('app_name') || 'ReadMeABook',
|
||||
allowRegistrations: configMap.get('allow_registrations') === 'true',
|
||||
|
||||
@@ -24,6 +24,8 @@ export async function POST(request: NextRequest) {
|
||||
localPath,
|
||||
} = await request.json();
|
||||
|
||||
console.log('[TestDownloadClient] Received request:', { type, url, hasUsername: !!username, hasPassword: !!password });
|
||||
|
||||
if (!type || !url) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Type and URL are required' },
|
||||
@@ -59,6 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
let version: string | undefined;
|
||||
|
||||
if (type === 'qbittorrent') {
|
||||
console.log('[TestDownloadClient] Testing qBittorrent connection');
|
||||
if (!username || !actualPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Username and password are required for qBittorrent' },
|
||||
@@ -74,6 +77,7 @@ export async function POST(request: NextRequest) {
|
||||
disableSSLVerify || false
|
||||
);
|
||||
} else if (type === 'sabnzbd') {
|
||||
console.log('[TestDownloadClient] Testing SABnzbd connection');
|
||||
if (!actualPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'API key (password) is required for SABnzbd' },
|
||||
|
||||
@@ -57,7 +57,40 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
|
||||
|
||||
// Check if audiobook is already available in Plex library
|
||||
// First check: Is there an existing 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,
|
||||
},
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingActiveRequest) {
|
||||
const status = existingActiveRequest.status;
|
||||
const isOwnRequest = existingActiveRequest.userId === req.user.id;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
|
||||
message: status === 'available'
|
||||
? 'This audiobook is already available in your Plex library'
|
||||
: 'This audiobook is being processed and will be available soon',
|
||||
requestStatus: status,
|
||||
isOwnRequest,
|
||||
requestedBy: existingActiveRequest.user?.plexUsername,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
|
||||
@@ -194,11 +194,39 @@ export async function PATCH(
|
||||
|
||||
const downloadHistory = requestWithData.downloadHistory[0];
|
||||
|
||||
// Get download path from qBittorrent
|
||||
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId!);
|
||||
const downloadPath = `${torrent.save_path}/${torrent.name}`;
|
||||
// Get download path from the appropriate download client
|
||||
let downloadPath: string;
|
||||
|
||||
if (downloadHistory.torrentHash) {
|
||||
// qBittorrent - get path from torrent info
|
||||
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
downloadPath = `${torrent.save_path}/${torrent.name}`;
|
||||
} else if (downloadHistory.nzbId) {
|
||||
// SABnzbd - get path from NZB info
|
||||
const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
|
||||
if (!nzbInfo || !nzbInfo.downloadPath) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
message: 'Download path not available from SABnzbd',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
downloadPath = nzbInfo.downloadPath;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
message: 'No download client ID found in history',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
id,
|
||||
|
||||
@@ -41,7 +41,40 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook } = CreateRequestSchema.parse(body);
|
||||
|
||||
// Check if audiobook is already available in Plex library
|
||||
// First check: Is there an existing 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,
|
||||
},
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingActiveRequest) {
|
||||
const status = existingActiveRequest.status;
|
||||
const isOwnRequest = existingActiveRequest.userId === req.user.id;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
|
||||
message: status === 'available'
|
||||
? 'This audiobook is already available in your Plex library'
|
||||
: 'This audiobook is being processed and will be available soon',
|
||||
requestStatus: status,
|
||||
isOwnRequest,
|
||||
requestedBy: existingActiveRequest.user?.plexUsername,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
|
||||
Reference in New Issue
Block a user