mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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)}
|
||||
|
||||
Reference in New Issue
Block a user