Compare commits

..

3 Commits

Author SHA1 Message Date
kikootwo d0e3c9c665 Bump package version to 1.0.3
Update package.json version from 1.0.2 to 1.0.3 to prepare a patch release.
2026-02-06 17:17:25 -05:00
kikootwo 95e63dfc36 Add ROOTLESS_CONTAINER and request UI updates
Introduce ROOTLESS_CONTAINER env to opt out of gosu (replace /proc uid_map detection) and update entrypoint messaging; adjust app-start.sh and redis-start.sh to skip gosu when ROOTLESS_CONTAINER=true and warn on UID/GID mismatch only when applicable. Backend: include audiobook audibleAsin in admin requests response (mapped to asin) and pass baseUrl through test-flaresolverr endpoint to the FlareSolverr tester. Frontend: RecentRequestsTable and RequestActionsDropdown now surface asin, accept/passthrough annasArchiveBaseUrl, and add a "View Details" flow using AudiobookDetailsModal; admin page passes ebook baseUrl from settings. InteractiveTorrentSearchModal refactor: improved UX/UI, keyboard handling, portal/modal mounting, skeleton/loading states, formatting helpers, and richer result display. Tests updated to match changes.
2026-02-06 17:13:39 -05:00
kikootwo 03371be81d Add optional rootless Podman support
Add documentation and example env var to docker-compose.yml for running with rootless Podman. Introduces a commented ROOTLESS_CONTAINER option that, when set to "true", skips gosu UID/GID switching since user namespaces handle mapping; includes a warning not to enable this for Docker or LXC to avoid creating files as root.
2026-02-06 16:09:00 -05:00
20 changed files with 1037 additions and 428 deletions
+9
View File
@@ -53,6 +53,15 @@ services:
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here" # CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here"
# POSTGRES_PASSWORD: "your-custom-postgres-password-here" # POSTGRES_PASSWORD: "your-custom-postgres-password-here"
# ========================================================================
# OPTIONAL: Rootless Podman Support
# ========================================================================
# Set to "true" ONLY if running with rootless Podman.
# This skips gosu UID/GID switching since the user namespace already
# handles mapping. Do NOT enable for Docker or LXC - it will cause
# files to be created as root.
# ROOTLESS_CONTAINER: "true"
# ======================================================================== # ========================================================================
# OPTIONAL: Application Configuration # OPTIONAL: Application Configuration
# ======================================================================== # ========================================================================
+11 -50
View File
@@ -3,42 +3,11 @@
# Uses gosu to ensure correct PUID:PGID for file operations # Uses gosu to ensure correct PUID:PGID for file operations
# #
# Supports: # Supports:
# - Docker: Uses gosu to switch to PUID:PGID # - Docker/LXC: Uses gosu to switch to PUID:PGID (default)
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker) # - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
set -e set -e
# =============================================================================
# USER NAMESPACE DETECTION
# =============================================================================
# Detects if running in a user namespace where UID 0 is remapped to a non-root
# user on the host (e.g., rootless Podman). In this case, using gosu would
# cause a double-mapping that breaks volume permissions.
#
# How it works:
# - /proc/self/uid_map shows the UID mapping for the current namespace
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
# - In a normal container: "0 0 4294967295" (root maps to root)
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
#
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
# =============================================================================
is_user_namespace_root() {
if [ -f /proc/self/uid_map ]; then
# Read the first mapping line (covers UID 0)
read -r inside outside count < /proc/self/uid_map
# Trim whitespace (uid_map has leading spaces for alignment)
inside=$(echo "$inside" | xargs)
outside=$(echo "$outside" | xargs)
# If UID 0 inside maps to non-0 outside, we're in a user namespace
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
return 0 # true - rootless container detected
fi
fi
return 1 # false - normal container (Docker or rootful Podman)
}
# Load environment from /etc/environment (set by entrypoint) # Load environment from /etc/environment (set by entrypoint)
if [ -f /etc/environment ]; then if [ -f /etc/environment ]; then
set -a set -a
@@ -58,23 +27,18 @@ cd /app
# ============================================================================= # =============================================================================
# START SERVER WITH APPROPRIATE UID:GID HANDLING # START SERVER WITH APPROPRIATE UID:GID HANDLING
# ============================================================================= # =============================================================================
# Three scenarios: # Two scenarios:
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID # 1. Default: Running as root, use gosu to switch to PUID:PGID
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping # 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping)
# 3. Non-root fallback: Already running as non-root, run directly
start_server() { start_server() {
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
if is_user_namespace_root; then if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
# Rootless container (e.g., rootless Podman) # Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user
# Skip gosu - the user namespace already maps our "root" to the correct host UID echo "[App] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)"
echo "[App] Detected rootless container (user namespace with remapped root)"
echo "[App] Skipping gosu to preserve user namespace UID mapping"
echo "[App] Process will run as namespace UID 0 (mapped to host user)"
node server.js & node server.js &
else else
# Normal container (Docker or rootful Podman) # Default: Use gosu to switch to the specified PUID:PGID
# Use gosu to switch to the specified PUID:PGID
echo "[App] Switching to UID:GID $PUID:$PGID via gosu..." echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
gosu "$PUID:$PGID" node server.js & gosu "$PUID:$PGID" node server.js &
fi fi
@@ -104,11 +68,8 @@ if [ -f "/proc/$SERVER_PID/status" ]; then
ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}') ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}')
echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID" echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID"
# Only warn about mismatch in non-rootless scenarios if [ "${ROOTLESS_CONTAINER}" != "true" ] && { [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; }; then
if ! is_user_namespace_root; then echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
if [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; then
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
fi
fi fi
fi fi
+5 -1
View File
@@ -329,6 +329,7 @@ PORT=$PORT
HOSTNAME=$HOSTNAME HOSTNAME=$HOSTNAME
PUID=${PUID:-} PUID=${PUID:-}
PGID=${PGID:-} PGID=${PGID:-}
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
EOF EOF
echo "✅ Environment configured" echo "✅ Environment configured"
@@ -363,7 +364,10 @@ echo "📊 Services starting:"
echo " - PostgreSQL (internal, user=postgres)" echo " - PostgreSQL (internal, user=postgres)"
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})" echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})"
echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})" echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})"
if [ -n "$PUID" ] && [ -n "$PGID" ]; then if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
echo ""
echo "🔐 ROOTLESS_CONTAINER=true - gosu will be skipped (user namespace handles UID mapping)"
elif [ -n "$PUID" ] && [ -n "$PGID" ]; then
echo "" echo ""
echo "🔐 Using gosu for reliable UID:GID switching" echo "🔐 Using gosu for reliable UID:GID switching"
echo " App and Redis will run as $PUID:$PGID" echo " App and Redis will run as $PUID:$PGID"
+9 -45
View File
@@ -3,42 +3,11 @@
# Uses gosu to ensure correct PUID:PGID for file operations # Uses gosu to ensure correct PUID:PGID for file operations
# #
# Supports: # Supports:
# - Docker: Uses gosu to switch to PUID:PGID # - Docker/LXC: Uses gosu to switch to PUID:PGID (default)
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker) # - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
set -e set -e
# =============================================================================
# USER NAMESPACE DETECTION
# =============================================================================
# Detects if running in a user namespace where UID 0 is remapped to a non-root
# user on the host (e.g., rootless Podman). In this case, using gosu would
# cause a double-mapping that breaks volume permissions.
#
# How it works:
# - /proc/self/uid_map shows the UID mapping for the current namespace
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
# - In a normal container: "0 0 4294967295" (root maps to root)
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
#
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
# =============================================================================
is_user_namespace_root() {
if [ -f /proc/self/uid_map ]; then
# Read the first mapping line (covers UID 0)
read -r inside outside count < /proc/self/uid_map
# Trim whitespace (uid_map has leading spaces for alignment)
inside=$(echo "$inside" | xargs)
outside=$(echo "$outside" | xargs)
# If UID 0 inside maps to non-0 outside, we're in a user namespace
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
return 0 # true - rootless container detected
fi
fi
return 1 # false - normal container (Docker or rootful Podman)
}
# Load environment from /etc/environment (set by entrypoint) # Load environment from /etc/environment (set by entrypoint)
if [ -f /etc/environment ]; then if [ -f /etc/environment ]; then
set -a set -a
@@ -56,24 +25,19 @@ echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
# ============================================================================= # =============================================================================
# START REDIS WITH APPROPRIATE UID:GID HANDLING # START REDIS WITH APPROPRIATE UID:GID HANDLING
# ============================================================================= # =============================================================================
# Three scenarios: # Two scenarios:
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID # 1. Default: Running as root, use gosu to switch to PUID:PGID
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping # 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping)
# 3. Non-root fallback: Already running as non-root, run directly
REDIS_CMD="/usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379" REDIS_CMD="/usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379"
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
if is_user_namespace_root; then if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
# Rootless container (e.g., rootless Podman) # Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user
# Skip gosu - the user namespace already maps our "root" to the correct host UID echo "[Redis] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)"
echo "[Redis] Detected rootless container (user namespace with remapped root)"
echo "[Redis] Skipping gosu to preserve user namespace UID mapping"
echo "[Redis] Process will run as namespace UID 0 (mapped to host user)"
exec $REDIS_CMD exec $REDIS_CMD
else else
# Normal container (Docker or rootful Podman) # Default: Use gosu to switch to the specified PUID:PGID
# Use gosu to switch to the specified PUID:PGID
echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..." echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
exec gosu "$PUID:$PGID" $REDIS_CMD exec gosu "$PUID:$PGID" $REDIS_CMD
fi fi
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.2", "version": "1.0.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,11 +13,13 @@ import { RequestActionsDropdown } from './RequestActionsDropdown';
import { mutate } from 'swr'; import { mutate } from 'swr';
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
interface RecentRequest { interface RecentRequest {
requestId: string; requestId: string;
title: string; title: string;
author: string; author: string;
asin?: string | null;
status: string; status: string;
type?: 'audiobook' | 'ebook'; type?: 'audiobook' | 'ebook';
userId: string; userId: string;
@@ -43,6 +45,7 @@ interface RequestsResponse {
interface RecentRequestsTableProps { interface RecentRequestsTableProps {
ebookSidecarEnabled?: boolean; ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
} }
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
@@ -158,7 +161,7 @@ function getInitialParams(): {
}; };
} }
export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) { export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
const toast = useToast(); const toast = useToast();
// Get initial filter state from URL (only evaluated once due to lazy init) // Get initial filter state from URL (only evaluated once due to lazy init)
@@ -185,6 +188,10 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isFetchingEbook, setIsFetchingEbook] = useState(false); const [isFetchingEbook, setIsFetchingEbook] = useState(false);
// View Details modal state
const [viewDetailsAsin, setViewDetailsAsin] = useState<string | null>(null);
const [viewDetailsStatus, setViewDetailsStatus] = useState<string | null>(null);
// Build API URL with current local filters // Build API URL with current local filters
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`; const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
@@ -314,6 +321,11 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
const hasActiveFilters = debouncedSearch || status !== 'all' || userId; const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
// Action handlers // Action handlers
const handleViewDetails = (asin: string, requestStatus?: string) => {
setViewDetailsAsin(asin);
setViewDetailsStatus(requestStatus || null);
};
const handleDeleteClick = (requestId: string, title: string) => { const handleDeleteClick = (requestId: string, title: string) => {
setSelectedRequest({ id: requestId, title }); setSelectedRequest({ id: requestId, title });
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
@@ -659,13 +671,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
author: request.author, author: request.author,
status: request.status, status: request.status,
type: request.type, type: request.type,
asin: request.asin,
torrentUrl: request.torrentUrl, torrentUrl: request.torrentUrl,
}} }}
onDelete={handleDeleteClick} onDelete={handleDeleteClick}
onManualSearch={handleManualSearch} onManualSearch={handleManualSearch}
onCancel={handleCancel} onCancel={handleCancel}
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook} onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled} ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook} isLoading={isDeleting || isFetchingEbook}
/> />
</td> </td>
@@ -808,6 +823,21 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel} onCancel={handleDeleteCancel}
/> />
{/* Audiobook Details Modal */}
{viewDetailsAsin && (
<AudiobookDetailsModal
asin={viewDetailsAsin}
isOpen={!!viewDetailsAsin}
onClose={() => {
setViewDetailsAsin(null);
setViewDetailsStatus(null);
}}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus}
hideRequestActions
/>
)}
</div> </div>
); );
} }
@@ -19,13 +19,16 @@ export interface RequestActionsDropdownProps {
author: string; author: string;
status: string; status: string;
type?: 'audiobook' | 'ebook'; type?: 'audiobook' | 'ebook';
asin?: string | null;
torrentUrl?: string | null; torrentUrl?: string | null;
}; };
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>;
onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>; onFetchEbook?: (requestId: string) => Promise<void>;
ebookSidecarEnabled?: boolean; ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
isLoading?: boolean; isLoading?: boolean;
} }
@@ -34,8 +37,10 @@ export function RequestActionsDropdown({
onDelete, onDelete,
onManualSearch, onManualSearch,
onCancel, onCancel,
onViewDetails,
onFetchEbook, onFetchEbook,
ebookSidecarEnabled = false, ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
isLoading = false, isLoading = false,
}: RequestActionsDropdownProps) { }: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -46,6 +51,9 @@ export function RequestActionsDropdown({
// Determine request type // Determine request type
const isEbook = request.type === 'ebook'; const isEbook = request.type === 'ebook';
// View Details: available when ASIN exists (audiobook requests only)
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status and type // Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only) // Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
@@ -64,7 +72,7 @@ export function RequestActionsDropdown({
if (Array.isArray(urls) && urls.length > 0) { if (Array.isArray(urls) && urls.length > 0) {
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i); const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
if (md5Match) { if (md5Match) {
viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`; viewSourceUrl = `${annasArchiveBaseUrl.replace(/\/+$/, '')}/md5/${md5Match[1]}`;
} }
} }
} catch { } catch {
@@ -147,6 +155,13 @@ export function RequestActionsDropdown({
} }
}; };
const handleViewDetails = () => {
setIsOpen(false);
if (request.asin && onViewDetails) {
onViewDetails(request.asin);
}
};
// Dropdown menu content (rendered via portal) // Dropdown menu content (rendered via portal)
const dropdownMenu = isOpen && style && ( const dropdownMenu = isOpen && style && (
<div <div
@@ -155,6 +170,41 @@ export function RequestActionsDropdown({
className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto" className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
> >
<div className="py-1" role="menu"> <div className="py-1" role="menu">
{/* View Details */}
{canViewDetails && (
<button
onClick={handleViewDetails}
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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
View Details
</button>
)}
{/* Divider after View Details */}
{canViewDetails && (canSearch || canViewSource || canFetchEbook || canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
{/* Manual Search */} {/* Manual Search */}
{canSearch && ( {canSearch && (
<button <button
+26 -24
View File
@@ -485,31 +485,8 @@ function AdminDashboardContent() {
/> />
</div> </div>
{/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} />
)}
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Active Downloads
</h2>
<ActiveDownloadsTable downloads={downloadsData.downloads} />
</div>
{/* Request Management */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Request Management
</h2>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
/>
</div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Link <Link
href="/admin/settings" href="/admin/settings"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all" className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
@@ -595,6 +572,31 @@ function AdminDashboardContent() {
</div> </div>
</Link> </Link>
</div> </div>
{/* Requests Awaiting Approval */}
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
<PendingApprovalSection requests={pendingApprovalData.requests} />
)}
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Active Downloads
</h2>
<ActiveDownloadsTable downloads={downloadsData.downloads} />
</div>
{/* Request Management */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Request Management
</h2>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
annasArchiveBaseUrl={settingsData?.ebook?.baseUrl}
/>
</div>
</> </>
)} )}
</div> </div>
@@ -51,7 +51,10 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', { const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: ebook.flaresolverrUrl }), body: JSON.stringify({
url: ebook.flaresolverrUrl,
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
}),
}); });
const result = await response.json(); const result = await response.json();
+2
View File
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
id: true, id: true,
title: true, title: true,
author: true, author: true,
audibleAsin: true,
}, },
}, },
user: { user: {
@@ -129,6 +130,7 @@ export async function GET(request: NextRequest) {
requestId: request.id, requestId: request.id,
title: request.audiobook.title, title: request.audiobook.title,
author: request.audiobook.author, author: request.audiobook.author,
asin: request.audiobook.audibleAsin || null,
status: request.status, status: request.status,
type: request.type || 'audiobook', // Include request type for UI display type: request.type || 'audiobook', // Include request type for UI display
userId: request.user.id, userId: request.user.id,
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const { url } = await request.json(); const { url, baseUrl } = await request.json();
if (!url) { if (!url) {
return NextResponse.json( return NextResponse.json(
@@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
); );
} }
const result = await testFlareSolverrConnection(url); const result = await testFlareSolverrConnection(url, baseUrl);
return NextResponse.json(result); return NextResponse.json(result);
} catch (error) { } catch (error) {
@@ -9,10 +9,8 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Modal } from '@/components/ui/Modal'; import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { import {
useInteractiveSearch, useInteractiveSearch,
@@ -40,6 +38,46 @@ interface InteractiveTorrentSearchModalProps {
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
} }
// Format relative time from publish date
const formatAge = (date: Date | string): string => {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
if (diffMs < 0) return 'Soon';
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1d ago';
if (diffDays < 30) return `${diffDays}d ago`;
const months = Math.floor(diffDays / 30.44);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(diffDays / 365.25);
return `${years}y ago`;
};
// Format file size
const formatSize = (bytes: number): string => {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
};
// Score badge color scheme
const getScoreStyle = (score: number) => {
if (score >= 90) return { bg: 'bg-emerald-500/15 dark:bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-400' };
if (score >= 70) return { bg: 'bg-blue-500/15 dark:bg-blue-400/15', text: 'text-blue-700 dark:text-blue-400' };
if (score >= 50) return { bg: 'bg-amber-500/15 dark:bg-amber-400/15', text: 'text-amber-700 dark:text-amber-400' };
return { bg: 'bg-gray-500/10 dark:bg-gray-400/10', text: 'text-gray-500 dark:text-gray-400' };
};
// Skeleton widths for loading state (deterministic to avoid hydration mismatch)
const skeletonRows = [
{ title: '72%', meta: '48%' },
{ title: '85%', meta: '58%' },
{ title: '64%', meta: '42%' },
{ title: '78%', meta: '52%' },
{ title: '68%', meta: '45%' },
];
export function InteractiveTorrentSearchModal({ export function InteractiveTorrentSearchModal({
isOpen, isOpen,
onClose, onClose,
@@ -66,9 +104,15 @@ export function InteractiveTorrentSearchModal({
const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin(); const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin();
const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin(); const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin();
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]); const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null); const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title); const [searchTitle, setSearchTitle] = useState(audiobook.title);
const [mounted, setMounted] = useState(false);
// Stable close handler via ref
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const handleClose = useCallback(() => { onCloseRef.current(); }, []);
// Determine which mode we're in // Determine which mode we're in
const isEbookMode = searchMode === 'ebook'; const isEbookMode = searchMode === 'ebook';
@@ -89,58 +133,72 @@ export function InteractiveTorrentSearchModal({
? (searchByRequestError || selectTorrentError) ? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError)); : (searchByAudiobookError || requestWithTorrentError));
// Mount tracking for portal
useEffect(() => { setMounted(true); }, []);
// Reset search title when modal opens/closes or audiobook changes // Reset search title when modal opens/closes or audiobook changes
React.useEffect(() => { useEffect(() => {
setSearchTitle(audiobook.title); setSearchTitle(audiobook.title);
setResults([]); setResults([]);
}, [isOpen, audiobook.title]); }, [isOpen, audiobook.title]);
// Perform search when modal opens // Perform search when modal opens
React.useEffect(() => { useEffect(() => {
if (isOpen && results.length === 0) { if (isOpen && results.length === 0) {
performSearch(); performSearch();
} }
}, [isOpen]); }, [isOpen]);
const performSearch = async () => { // ESC key and body scroll lock
// Clear existing results while searching // ESC dismisses confirmation first, then closes modal
setResults([]); useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (confirmTorrent) {
setConfirmTorrent(null);
} else {
handleClose();
}
}
};
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = '';
};
}, [isOpen, handleClose, confirmTorrent]);
const performSearch = async () => {
setResults([]);
try { try {
let data; let data;
if (isEbookMode) { if (isEbookMode) {
// Ebook mode: search Anna's Archive + indexers
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
if (useAsinMode && asin) { if (useAsinMode && asin) {
// ASIN-based ebook search (user flow from details modal)
data = await searchEbooksByAsin(asin, customTitle); data = await searchEbooksByAsin(asin, customTitle);
} else if (requestId) { } else if (requestId) {
// Request ID-based ebook search (admin flow)
data = await searchEbooks(requestId, customTitle); data = await searchEbooks(requestId, customTitle);
} else { } else {
console.error('Ebook search requires either requestId or asin'); console.error('Ebook search requires either requestId or asin');
return; return;
} }
} else if (hasRequestId) { } else if (hasRequestId) {
// Existing audiobook flow: search by requestId with optional custom title
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle); data = await searchByRequestId(requestId, customTitle);
} else { } else {
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
const audiobookAsin = fullAudiobook?.asin; const audiobookAsin = fullAudiobook?.asin;
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin); data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
} }
setResults(data || []); setResults(data || []);
} catch (err) { } catch (err) {
// Error already handled by hook
console.error('Search failed:', err); console.error('Search failed:', err);
} }
}; };
const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') performSearch();
performSearch();
}
}; };
const handleDownloadClick = (torrent: TorrentResult) => { const handleDownloadClick = (torrent: TorrentResult) => {
@@ -149,270 +207,385 @@ export function InteractiveTorrentSearchModal({
const handleConfirmDownload = async () => { const handleConfirmDownload = async () => {
if (!confirmTorrent) return; if (!confirmTorrent) return;
try { try {
if (isEbookMode) { if (isEbookMode) {
// Ebook flow
if (useAsinMode && asin) { if (useAsinMode && asin) {
// ASIN-based ebook selection (user flow from details modal)
await selectEbookByAsin(asin, confirmTorrent); await selectEbookByAsin(asin, confirmTorrent);
} else if (requestId) { } else if (requestId) {
// Request ID-based ebook selection (admin flow)
await selectEbook(requestId, confirmTorrent); await selectEbook(requestId, confirmTorrent);
} else { } else {
throw new Error('Request ID or ASIN required for ebook selection'); throw new Error('Request ID or ASIN required for ebook selection');
} }
} else if (hasRequestId) { } else if (hasRequestId) {
// Existing audiobook flow: select torrent for existing request
await selectTorrent(requestId, confirmTorrent); await selectTorrent(requestId, confirmTorrent);
} else { } else {
// New audiobook flow: create request with torrent if (!fullAudiobook) throw new Error('Audiobook data required to create request');
if (!fullAudiobook) {
throw new Error('Audiobook data required to create request');
}
await requestWithTorrent(fullAudiobook, confirmTorrent); await requestWithTorrent(fullAudiobook, confirmTorrent);
} }
// Notify parent of successful selection
onSuccess?.(); onSuccess?.();
// Close modals on success
setConfirmTorrent(null); setConfirmTorrent(null);
onClose(); onClose();
// Request list will auto-refresh via SWR
} catch (err) { } catch (err) {
// Error already handled by hook
console.error('Failed to download:', err); console.error('Failed to download:', err);
setConfirmTorrent(null); setConfirmTorrent(null);
} }
}; };
const formatSize = (bytes: number) => {
const gb = bytes / (1024 ** 3);
const mb = bytes / (1024 ** 2);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
};
const getQualityBadgeColor = (score: number) => {
if (score >= 90) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
if (score >= 70) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
if (score >= 50) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
};
// UI text based on mode // UI text based on mode
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent'; const modalTitle = isEbookMode ? 'Find Ebook' : 'Find Audiobook';
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title'; const noResultsText = isEbookMode ? 'No ebooks found' : 'No results found';
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
const resultCountText = (count: number) => const resultCountText = (count: number) =>
isEbookMode isEbookMode
? `Found ${count} ebook${count !== 1 ? 's' : ''}` ? `${count} ebook${count !== 1 ? 's' : ''} found`
: `Found ${count} torrent${count !== 1 ? 's' : ''}`; : `${count} result${count !== 1 ? 's' : ''} found`;
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent'; const confirmModalTitle = isEbookMode ? 'Download Ebook' : 'Confirm Download';
return ( if (!isOpen || !mounted) return null;
<>
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full"> const modalContent = (
<div className="space-y-4"> <div
{/* Search customization - editable for ALL modes */} className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg"> style={{ height: '100dvh' }}
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> onClick={handleClose}
{searchLabel} >
</label> <div
<div className="flex gap-2"> className="relative w-full sm:max-w-2xl lg:max-w-3xl bg-white dark:bg-gray-900 sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-4 sm:slide-in-from-bottom-0 sm:zoom-in-95 duration-300"
<input style={{
type="text" maxHeight: 'calc(100dvh - env(safe-area-inset-top, 0px) - 1rem)',
value={searchTitle} paddingTop: 'env(safe-area-inset-top, 0px)',
onChange={(e) => setSearchTitle(e.target.value)} }}
onKeyPress={handleSearchKeyPress} onClick={(e) => e.stopPropagation()}
placeholder={searchPlaceholder} >
disabled={isSearching} {/* Header */}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" <div className="flex items-center justify-between px-5 py-3.5 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-700/50">
/> <h2 className="text-[17px] font-semibold text-gray-900 dark:text-white">{modalTitle}</h2>
<Button <button
onClick={performSearch} onClick={handleClose}
disabled={isSearching || !searchTitle.trim()} className="p-1.5 -mr-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
variant="primary" aria-label="Close"
size="sm" >
> <svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Search <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</Button> </svg>
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain">
<div className="p-4 sm:p-5 space-y-4">
{/* Search Bar */}
<div>
<div className="flex items-center gap-2.5 bg-gray-100/80 dark:bg-white/[0.06] rounded-xl px-3.5 py-2.5 border border-transparent focus-within:border-blue-500/40 focus-within:bg-white dark:focus-within:bg-white/[0.08] focus-within:shadow-sm focus-within:shadow-blue-500/10 transition-all duration-200">
<svg className="w-[18px] h-[18px] text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search title..."
disabled={isSearching}
className="flex-1 bg-transparent outline-none text-[15px] text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 min-w-0"
/>
{isSearching ? (
<div className="flex-shrink-0 w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
) : (
<button
onClick={performSearch}
disabled={!searchTitle.trim()}
className="flex-shrink-0 px-3 py-1 text-[13px] font-semibold text-white bg-blue-600 hover:bg-blue-700 active:scale-[0.97] rounded-lg transition-all disabled:opacity-30 disabled:pointer-events-none"
>
Search
</button>
)}
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5 ml-1 truncate">
by {audiobook.author}
</p>
</div> </div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p>
</div>
{/* Error message */} {/* Error */}
{error && ( {error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> <div className="flex items-start gap-2.5 px-3.5 py-3 bg-red-50/80 dark:bg-red-500/10 rounded-xl border border-red-200/60 dark:border-red-500/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p> <svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
)} </svg>
<p className="text-sm text-red-600 dark:text-red-400 leading-snug">{error}</p>
</div>
)}
{/* Loading state */} {/* Loading Skeleton */}
{isSearching && ( {isSearching && (
<div className="flex items-center justify-center py-12"> <div className="space-y-0.5">
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div> {skeletonRows.map((widths, i) => (
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span> <div key={i} className="flex items-center gap-3 px-3 py-3.5 rounded-xl animate-pulse">
</div> <div className="w-11 h-11 rounded-xl bg-gray-200/80 dark:bg-gray-700/50 flex-shrink-0" />
)} <div className="flex-1 min-w-0 space-y-2">
<div className="h-3.5 rounded-lg bg-gray-200/80 dark:bg-gray-700/50" style={{ width: widths.title }} />
<div className="h-3 rounded-lg bg-gray-100 dark:bg-gray-800/60" style={{ width: widths.meta }} />
</div>
<div className="w-14 h-[30px] rounded-full bg-gray-200/80 dark:bg-gray-700/50 flex-shrink-0" />
</div>
))}
</div>
)}
{/* No results */} {/* Empty State */}
{!isSearching && results.length === 0 && ( {!isSearching && results.length === 0 && !error && (
<div className="text-center py-12"> <div className="flex flex-col items-center justify-center py-14">
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p> <div className="w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-3">
<Button onClick={performSearch} variant="outline" className="mt-4"> <svg className="w-7 h-7 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Try Again <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</Button> </svg>
</div> </div>
)} <p className="text-[15px] font-medium text-gray-500 dark:text-gray-400">{noResultsText}</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">Try adjusting your search terms</p>
<button
onClick={performSearch}
className="mt-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
Search Again
</button>
</div>
)}
{/* Results table */} {/* Results List */}
{!isSearching && results.length > 0 && ( {!isSearching && results.length > 0 && (
<div className="overflow-x-auto -mx-6"> <div className="space-y-0.5">
<div className="inline-block min-w-full align-middle px-6"> {results.map((result) => {
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> const score = Math.round(result.score);
<thead className="bg-gray-50 dark:bg-gray-900"> const style = getScoreStyle(score);
<tr> const isUsenet = result.protocol === 'usenet';
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12"> const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
# const displayFormat = result.format || result.ebookFormat;
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> return (
Title <div
</th> key={result.guid}
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell w-24"> className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group"
Size >
</th> {/* Score Badge */}
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Base quality score (0-100): Title/Author match (50) + Format (25) + Seeders (15) + Size (10)"> <div
Score className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
</th> title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Bonus points from indexer priority and other modifiers"> >
Bonus <span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
</th> {score}
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell w-20"> </span>
Seeds </div>
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32"> {/* Content */}
{isEbookMode ? 'Source' : 'Indexer'} <div className="flex-1 min-w-0">
</th> {/* Title Row */}
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24"> <div className="flex items-center gap-1.5">
Action <a
</th> href={result.infoUrl || result.guid}
</tr> target="_blank"
</thead> rel="noopener noreferrer"
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
{results.map((result) => ( title={result.title}
<tr key={result.guid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-2 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{result.rank}
</td>
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
<div className="truncate">
<a
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
title={result.title}
>
{result.title}
</a>
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{/* Anna's Archive badge for ebook mode */}
{isEbookMode && result.source === 'annas_archive' && (
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
Anna's Archive
</span>
)}
{result.format && (
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
{result.format}
</span>
)}
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
</span>
{/* Hide seeds badge for Anna's Archive results */}
{!(isEbookMode && result.source === 'annas_archive') && (
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
</span>
)}
</div>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
{result.size > 0 ? formatSize(result.size) : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
{Math.round(result.score)}
</span>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-gray-400">N/A</span>
) : (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
) : (
result.indexer
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
<Button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
size="sm"
variant="primary"
> >
Download {result.title}
</Button> </a>
</td> </div>
</tr>
))} {/* Metadata Row */}
</tbody> <div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
</table> {/* Rank */}
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{/* Indexer / Source */}
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : (
<span>{result.indexer}</span>
)}
{/* Size */}
{result.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatSize(result.size)}</span>
</>
)}
{/* Format */}
{displayFormat && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
)}
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
{!isAnnasArchive && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{isUsenet ? (
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
NZB
</span>
) : (
<span className="flex items-center gap-0.5">
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
</span>
)}
</>
)}
{/* Age */}
{result.publishDate && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatAge(result.publishDate)}</span>
</>
)}
{/* Bonus Points */}
{result.bonusPoints > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
</>
)}
</div>
</div>
{/* Action Button */}
<button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
>
Get
</button>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Sticky Footer */}
{!isSearching && results.length > 0 && (
<div className="flex items-center justify-between px-5 py-3 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50">
<p className="text-xs text-gray-400 dark:text-gray-500">
{resultCountText(results.length)}
</p>
<button
onClick={performSearch}
disabled={isSearching}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-40"
>
Refresh
</button>
</div>
)}
{/* Inline Confirmation Overlay */}
{confirmTorrent && (
<div
className="absolute inset-0 z-30 flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
onClick={() => !isDownloading && setConfirmTorrent(null)}
>
<div
className="mx-5 w-full max-w-sm bg-white dark:bg-gray-800 rounded-2xl shadow-2xl shadow-black/20 overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Confirm Header */}
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 dark:bg-blue-400/15 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">
{confirmModalTitle}
</h3>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
This will start the download
</p>
</div>
</div>
{/* Selected Item Preview */}
<div className="bg-gray-50 dark:bg-white/[0.04] rounded-xl px-3.5 py-3 border border-gray-100 dark:border-gray-700/50">
<p className="text-sm font-medium text-gray-900 dark:text-white leading-snug line-clamp-2">
{confirmTorrent.title}
</p>
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span>{confirmTorrent.indexer}</span>
{confirmTorrent.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span>{formatSize(confirmTorrent.size)}</span>
</>
)}
{confirmTorrent.format && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="uppercase font-medium">{confirmTorrent.format}</span>
</>
)}
{confirmTorrent.protocol === 'usenet' ? (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-sky-600 dark:text-sky-400">NZB</span>
</>
) : confirmTorrent.seeders !== undefined && (
<>
<span className="text-gray-300 dark:text-gray-600">&middot;</span>
<span className="text-emerald-600 dark:text-emerald-400">{confirmTorrent.seeders} seeds</span>
</>
)}
</div>
</div>
</div>
{/* Confirm Actions */}
<div className="flex border-t border-gray-200/80 dark:border-gray-700/50">
<button
onClick={() => setConfirmTorrent(null)}
disabled={isDownloading}
className="flex-1 px-4 py-3 text-[15px] font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors disabled:opacity-40 border-r border-gray-200/80 dark:border-gray-700/50"
>
Cancel
</button>
<button
onClick={handleConfirmDownload}
disabled={isDownloading}
className="flex-1 px-4 py-3 text-[15px] font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors disabled:opacity-60"
>
{isDownloading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-blue-300 dark:border-blue-600 border-t-blue-600 dark:border-t-blue-400 rounded-full animate-spin" />
Downloading...
</span>
) : (
'Download'
)}
</button>
</div> </div>
</div> </div>
)} </div>
)}
{/* Footer with result count */} </div>
{!isSearching && results.length > 0 && ( </div>
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
{resultCountText(results.length)}
</p>
<Button onClick={performSearch} variant="outline" size="sm">
Refresh Results
</Button>
</div>
)}
</div>
</Modal>
{/* Confirmation Modal */}
<ConfirmModal
isOpen={!!confirmTorrent}
onClose={() => setConfirmTorrent(null)}
onConfirm={handleConfirmDownload}
title={confirmTitle}
message={`Download "${confirmTorrent?.title}"?`}
confirmText="Download"
isLoading={isDownloading}
variant="primary"
/>
</>
); );
return createPortal(modalContent, document.body);
} }
+4 -3
View File
@@ -127,13 +127,14 @@ async function fetchHtml(
* Test FlareSolverr connection * Test FlareSolverr connection
*/ */
export async function testFlareSolverrConnection( export async function testFlareSolverrConnection(
flaresolverrUrl: string flaresolverrUrl: string,
baseUrl: string = 'https://annas-archive.li'
): Promise<{ success: boolean; message: string; responseTime?: number }> { ): Promise<{ success: boolean; message: string; responseTime?: number }> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
// Test with a simple request to Anna's Archive homepage // Test with a simple request to the configured Anna's Archive base URL
const testUrl = 'https://annas-archive.li/'; const testUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const html = await fetchViaFlareSolverr(testUrl, flaresolverrUrl, 30000); const html = await fetchViaFlareSolverr(testUrl, flaresolverrUrl, 30000);
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
@@ -21,6 +21,9 @@ import {
} from '../audiobookshelf/api'; } from '../audiobookshelf/api';
import { ABSLibraryItem } from '../audiobookshelf/types'; import { ABSLibraryItem } from '../audiobookshelf/types';
import { getConfigService } from '@/lib/services/config.service'; import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('AudiobookshelfLibrary');
export class AudiobookshelfLibraryService implements ILibraryService { export class AudiobookshelfLibraryService implements ILibraryService {
private configService = getConfigService(); private configService = getConfigService();
@@ -63,17 +66,26 @@ export class AudiobookshelfLibraryService implements ILibraryService {
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> { async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const items = await getABSLibraryItems(libraryId); const items = await getABSLibraryItems(libraryId);
return items.map(this.mapABSItemToLibraryItem); const audioItems = items.filter(this.hasAudioContent);
const skipped = items.length - audioItems.length;
if (skipped > 0) {
logger.info(`Filtered ${skipped} ebook-only item(s) from library (no audio files)`);
}
return audioItems.map(this.mapABSItemToLibraryItem);
} }
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> { async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const items = await getABSRecentItems(libraryId, limit); const items = await getABSRecentItems(libraryId, limit);
return items.map(this.mapABSItemToLibraryItem); return items.filter(this.hasAudioContent).map(this.mapABSItemToLibraryItem);
} }
async getItem(itemId: string): Promise<LibraryItem | null> { async getItem(itemId: string): Promise<LibraryItem | null> {
try { try {
const item = await getABSItem(itemId); const item = await getABSItem(itemId);
if (!this.hasAudioContent(item)) {
logger.debug(`Item ${itemId} is ebook-only (no audio files), skipping`);
return null;
}
return this.mapABSItemToLibraryItem(item); return this.mapABSItemToLibraryItem(item);
} catch { } catch {
return null; return null;
@@ -82,7 +94,9 @@ export class AudiobookshelfLibraryService implements ILibraryService {
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> { async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const items = await searchABSItems(libraryId, query); const items = await searchABSItems(libraryId, query);
return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem)); return items
.filter((result: any) => this.hasAudioContent(result.libraryItem))
.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
} }
async triggerLibraryScan(libraryId: string): Promise<void> { async triggerLibraryScan(libraryId: string): Promise<void> {
@@ -117,6 +131,37 @@ export class AudiobookshelfLibraryService implements ILibraryService {
}; };
} }
/**
* Check if an ABS library item contains audio content.
* ABS stores both audiobooks and ebooks under mediaType 'book'.
* Ebook-only items have no audio files and should be excluded from RMAB's audiobook pipeline.
*
* The list endpoint returns minified media (numAudioFiles, duration) without the full audioFiles array.
* The single-item endpoint returns the full audioFiles array.
* We check all available signals to handle both response shapes.
*/
private hasAudioContent(item: any): boolean {
if (!item?.media) return false;
// numAudioFiles: present in list/search endpoint responses (minified media)
if (typeof item.media.numAudioFiles === 'number') {
return item.media.numAudioFiles > 0;
}
// audioFiles array: present in full single-item responses
if (Array.isArray(item.media.audioFiles)) {
return item.media.audioFiles.length > 0;
}
// duration fallback: ebook-only items have 0 duration
if (typeof item.media.duration === 'number') {
return item.media.duration > 0;
}
// Cannot determine — assume audio content to avoid false filtering
return true;
}
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem { private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
const metadata = item.media.metadata; const metadata = item.media.metadata;
return { return {
@@ -331,13 +331,14 @@ describe('Admin settings test routes', () => {
it('tests FlareSolverr connection', async () => { it('tests FlareSolverr connection', async () => {
testFlareSolverrMock.mockResolvedValueOnce({ success: true }); testFlareSolverrMock.mockResolvedValueOnce({ success: true });
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare' }) }; const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route'); const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
const response = await POST(request as any); const response = await POST(request as any);
const payload = await response.json(); const payload = await response.json();
expect(payload.success).toBe(true); expect(payload.success).toBe(true);
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.li');
}); });
it('rejects FlareSolverr test when URL is missing', async () => { it('rejects FlareSolverr test when URL is missing', async () => {
@@ -364,7 +365,7 @@ describe('Admin settings test routes', () => {
it('returns error when FlareSolverr test throws', async () => { it('returns error when FlareSolverr test throws', async () => {
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down')); testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare' }) }; const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route'); const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
const response = await POST(request as any); const response = await POST(request as any);
@@ -74,6 +74,29 @@ describe('RequestActionsDropdown', () => {
expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book'); expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book');
}); });
it('uses configured base URL for ebook View Source link', () => {
render(
<RequestActionsDropdown
request={{
requestId: 'req-ebook',
title: 'Ebook Title',
author: 'Author',
status: 'downloaded',
type: 'ebook',
torrentUrl: JSON.stringify(['https://annas-archive.li/slow_download/abc123def456abc123def456abc123de/0/5']),
}}
onManualSearch={vi.fn().mockResolvedValue(undefined)}
onCancel={vi.fn().mockResolvedValue(undefined)}
onDelete={vi.fn()}
annasArchiveBaseUrl="https://custom-mirror.org"
/>
);
fireEvent.click(screen.getByTitle('Actions'));
const viewSourceLink = screen.getByText('View Source').closest('a');
expect(viewSourceLink).toHaveAttribute('href', 'https://custom-mirror.org/md5/abc123def456abc123def456abc123de');
});
it('shows view source and ebook fetch when available', async () => { it('shows view source and ebook fetch when available', async () => {
const onFetchEbook = vi.fn().mockResolvedValue(undefined); const onFetchEbook = vi.fn().mockResolvedValue(undefined);
const onDelete = vi.fn(); const onDelete = vi.fn();
@@ -75,7 +75,7 @@ describe('useEbookSettings', () => {
expect(result.current.flaresolverrTestResult?.message).toContain('Please enter a FlareSolverr URL'); expect(result.current.flaresolverrTestResult?.message).toContain('Please enter a FlareSolverr URL');
}); });
it('tests FlareSolverr connection successfully', async () => { it('tests FlareSolverr connection successfully and sends baseUrl', async () => {
fetchWithAuthMock.mockResolvedValueOnce({ fetchWithAuthMock.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ success: true, message: 'OK' }), json: async () => ({ success: true, message: 'OK' }),
@@ -91,6 +91,10 @@ describe('useEbookSettings', () => {
}); });
expect(result.current.flaresolverrTestResult?.success).toBe(true); expect(result.current.flaresolverrTestResult?.success).toBe(true);
// Verify baseUrl is included in the request body
const callBody = JSON.parse(fetchWithAuthMock.mock.calls[0][1].body);
expect(callBody.baseUrl).toBe('https://annas-archive.li');
expect(callBody.url).toBe('http://flare');
}); });
it('handles FlareSolverr test failures', async () => { it('handles FlareSolverr test failures', async () => {
@@ -98,9 +98,8 @@ describe('InteractiveTorrentSearchModal', () => {
expect(await screen.findByText('Test Torrent')).toBeInTheDocument(); expect(await screen.findByText('Test Torrent')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Download' })); fireEvent.click(screen.getByRole('button', { name: 'Get' }));
const downloadButtons = screen.getAllByRole('button', { name: 'Download' }); fireEvent.click(await screen.findByRole('button', { name: 'Download' }));
fireEvent.click(downloadButtons[downloadButtons.length - 1]);
await waitFor(() => { await waitFor(() => {
expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult); expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult);
@@ -129,9 +128,8 @@ describe('InteractiveTorrentSearchModal', () => {
expect(searchByAudiobookMock).toHaveBeenCalledWith('Test Book', 'Test Author', 'ASIN-1'); expect(searchByAudiobookMock).toHaveBeenCalledWith('Test Book', 'Test Author', 'ASIN-1');
}); });
fireEvent.click(screen.getByRole('button', { name: 'Download' })); fireEvent.click(screen.getByRole('button', { name: 'Get' }));
const downloadButtons = screen.getAllByRole('button', { name: 'Download' }); fireEvent.click(await screen.findByRole('button', { name: 'Download' }));
fireEvent.click(downloadButtons[downloadButtons.length - 1]);
await waitFor(() => { await waitFor(() => {
expect(requestWithTorrentMock).toHaveBeenCalledWith(fullAudiobook, baseResult); expect(requestWithTorrentMock).toHaveBeenCalledWith(fullAudiobook, baseResult);
@@ -157,9 +155,9 @@ describe('InteractiveTorrentSearchModal', () => {
expect(searchByRequestMock).toHaveBeenCalledWith('req-456', undefined); expect(searchByRequestMock).toHaveBeenCalledWith('req-456', undefined);
}); });
const input = screen.getByPlaceholderText('Enter book title to search...'); const input = screen.getByPlaceholderText('Search title...');
fireEvent.change(input, { target: { value: 'Custom Title' } }); fireEvent.change(input, { target: { value: 'Custom Title' } });
fireEvent.keyPress(input, { key: 'Enter', code: 'Enter', charCode: 13 }); fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
await waitFor(() => { await waitFor(() => {
expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title'); expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title');
+23 -5
View File
@@ -63,12 +63,30 @@ describe('E-book sidecar', () => {
}, },
}); });
const result = await testFlareSolverrConnection('http://flare'); const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.responseTime).toBeTypeOf('number'); expect(result.responseTime).toBeTypeOf('number');
}); });
it('uses configured base URL for FlareSolverr test', async () => {
const longHtml = `<html>${'Anna'.padEnd(1200, 'A')}</html>`;
axiosMock.post.mockResolvedValue({
data: {
status: 'ok',
solution: { status: 200, response: longHtml },
},
});
await testFlareSolverrConnection('http://flare', 'https://custom-mirror.org');
expect(axiosMock.post).toHaveBeenCalledWith(
'http://flare/v1',
expect.objectContaining({ url: 'https://custom-mirror.org/' }),
expect.any(Object)
);
});
it('returns false when FlareSolverr response is invalid', async () => { it('returns false when FlareSolverr response is invalid', async () => {
axiosMock.post.mockResolvedValue({ axiosMock.post.mockResolvedValue({
data: { data: {
@@ -77,7 +95,7 @@ describe('E-book sidecar', () => {
}, },
}); });
const result = await testFlareSolverrConnection('http://flare'); const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
@@ -85,7 +103,7 @@ describe('E-book sidecar', () => {
it('returns error details when FlareSolverr request fails', async () => { it('returns error details when FlareSolverr request fails', async () => {
axiosMock.post.mockRejectedValue(new Error('flare down')); axiosMock.post.mockRejectedValue(new Error('flare down'));
const result = await testFlareSolverrConnection('http://flare'); const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('flare down'); expect(result.message).toContain('flare down');
@@ -99,7 +117,7 @@ describe('E-book sidecar', () => {
}, },
}); });
const result = await testFlareSolverrConnection('http://flare'); const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('FlareSolverr error'); expect(result.message).toContain('FlareSolverr error');
@@ -114,7 +132,7 @@ describe('E-book sidecar', () => {
}, },
}); });
const result = await testFlareSolverrConnection('http://flare'); const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.message).toContain('FlareSolverr returned HTTP 403'); expect(result.message).toContain('FlareSolverr returned HTTP 403');
@@ -26,6 +26,70 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock, getConfigService: () => configServiceMock,
})); }));
// --- Test data helpers ---
/** Creates a mock ABS item with audio files (audiobook) */
function makeAudiobookItem(overrides: Record<string, any> = {}) {
return {
id: overrides.id ?? 'item-1',
addedAt: overrides.addedAt ?? 1700000000000,
updatedAt: overrides.updatedAt ?? 1700000100000,
media: {
duration: overrides.duration ?? 3600,
coverPath: overrides.coverPath ?? '/covers/1.jpg',
numAudioFiles: overrides.numAudioFiles ?? 1,
numTracks: overrides.numTracks ?? 1,
audioFiles: overrides.audioFiles ?? [
{
index: 0,
ino: 'ino-1',
metadata: { filename: 'chapter01.mp3', ext: '.mp3', path: '/books/chapter01.mp3', size: 5000000, mtimeMs: 1700000000000 },
duration: 3600,
},
],
metadata: {
title: overrides.title ?? 'Audiobook Title',
authorName: overrides.authorName ?? 'Author',
narratorName: overrides.narratorName ?? 'Narrator',
description: overrides.description ?? 'Description',
asin: overrides.asin ?? 'B00ASIN001',
isbn: overrides.isbn ?? 'ISBN001',
publishedYear: overrides.publishedYear ?? '2020',
genres: overrides.genres ?? [],
explicit: false,
},
},
};
}
/** Creates a mock ABS item with NO audio files (ebook-only) */
function makeEbookOnlyItem(overrides: Record<string, any> = {}) {
return {
id: overrides.id ?? 'ebook-1',
addedAt: overrides.addedAt ?? 1700000000000,
updatedAt: overrides.updatedAt ?? 1700000100000,
media: {
duration: 0,
coverPath: overrides.coverPath ?? '/covers/ebook.jpg',
numAudioFiles: 0,
numTracks: 0,
audioFiles: [],
ebookFile: overrides.ebookFile ?? { ino: 'ino-e1', metadata: { filename: 'book.epub', ext: '.epub' } },
metadata: {
title: overrides.title ?? 'Ebook Title',
authorName: overrides.authorName ?? 'Ebook Author',
narratorName: undefined,
description: overrides.description ?? 'Ebook Description',
asin: overrides.asin ?? 'B00EBOOK01',
isbn: overrides.isbn ?? 'ISBN-EBOOK',
publishedYear: overrides.publishedYear ?? '2023',
genres: overrides.genres ?? [],
explicit: false,
},
},
};
}
describe('AudiobookshelfLibraryService', () => { describe('AudiobookshelfLibraryService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -71,24 +135,18 @@ describe('AudiobookshelfLibraryService', () => {
it('maps library items to generic fields', async () => { it('maps library items to generic fields', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([ apiMock.getABSLibraryItems.mockResolvedValue([
{ makeAudiobookItem({
id: 'item-1', id: 'item-1',
addedAt: 1700000000000, title: 'Title',
updatedAt: 1700000100000, authorName: 'Author',
media: { narratorName: 'Narrator',
duration: 3600, description: 'Desc',
coverPath: '/covers/1.jpg', asin: 'ASIN1',
metadata: { isbn: 'ISBN1',
title: 'Title', publishedYear: '2020',
authorName: 'Author', duration: 3600,
narratorName: 'Narrator', coverPath: '/covers/1.jpg',
description: 'Desc', }),
asin: 'ASIN1',
isbn: 'ISBN1',
publishedYear: '2020',
},
},
},
]); ]);
const service = new AudiobookshelfLibraryService(); const service = new AudiobookshelfLibraryService();
@@ -123,20 +181,18 @@ describe('AudiobookshelfLibraryService', () => {
it('searches items and maps results', async () => { it('searches items and maps results', async () => {
apiMock.searchABSItems.mockResolvedValue([ apiMock.searchABSItems.mockResolvedValue([
{ {
libraryItem: { libraryItem: makeAudiobookItem({
id: 'item-2', id: 'item-2',
addedAt: 1700000000000, title: 'Search Title',
updatedAt: 1700000000000, authorName: 'Search Author',
media: { narratorName: '',
duration: 200, description: '',
metadata: { duration: 200,
title: 'Search Title', coverPath: undefined,
authorName: 'Search Author', asin: undefined,
narratorName: '', isbn: undefined,
description: '', publishedYear: undefined,
}, }),
},
},
}, },
]); ]);
@@ -193,4 +249,269 @@ describe('AudiobookshelfLibraryService', () => {
await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete'); await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete');
}); });
// --- Ebook-only filtering tests ---
describe('ebook-only item filtering', () => {
it('getLibraryItems excludes ebook-only items (no audio files)', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([
makeAudiobookItem({ id: 'audio-1', title: 'Audiobook One' }),
makeEbookOnlyItem({ id: 'ebook-1', title: 'Ebook One' }),
makeAudiobookItem({ id: 'audio-2', title: 'Audiobook Two' }),
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(2);
expect(items.map(i => i.id)).toEqual(['audio-1', 'audio-2']);
});
it('getLibraryItems returns empty when all items are ebook-only', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([
makeEbookOnlyItem({ id: 'ebook-1' }),
makeEbookOnlyItem({ id: 'ebook-2' }),
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(0);
});
it('getLibraryItems returns all items when none are ebook-only', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([
makeAudiobookItem({ id: 'audio-1' }),
makeAudiobookItem({ id: 'audio-2' }),
makeAudiobookItem({ id: 'audio-3' }),
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(3);
});
it('getRecentlyAdded excludes ebook-only items', async () => {
apiMock.getABSRecentItems.mockResolvedValue([
makeEbookOnlyItem({ id: 'ebook-recent' }),
makeAudiobookItem({ id: 'audio-recent' }),
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getRecentlyAdded('lib-1', 10);
expect(items).toHaveLength(1);
expect(items[0].id).toBe('audio-recent');
});
it('getItem returns null for ebook-only item', async () => {
apiMock.getABSItem.mockResolvedValue(
makeEbookOnlyItem({ id: 'ebook-1' })
);
const service = new AudiobookshelfLibraryService();
const result = await service.getItem('ebook-1');
expect(result).toBeNull();
});
it('getItem returns audiobook item with audio files', async () => {
apiMock.getABSItem.mockResolvedValue(
makeAudiobookItem({ id: 'audio-1', title: 'Real Audiobook' })
);
const service = new AudiobookshelfLibraryService();
const result = await service.getItem('audio-1');
expect(result).not.toBeNull();
expect(result!.title).toBe('Real Audiobook');
});
it('searchItems excludes ebook-only results', async () => {
apiMock.searchABSItems.mockResolvedValue([
{ libraryItem: makeAudiobookItem({ id: 'audio-match', title: 'Audio Match' }) },
{ libraryItem: makeEbookOnlyItem({ id: 'ebook-match', title: 'Ebook Match' }) },
]);
const service = new AudiobookshelfLibraryService();
const items = await service.searchItems('lib-1', 'Match');
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Audio Match');
});
it('handles items with missing media field gracefully', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([
makeAudiobookItem({ id: 'audio-1' }),
{ id: 'broken-1', addedAt: 1700000000000, updatedAt: 1700000000000 }, // no media field at all
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(1);
expect(items[0].id).toBe('audio-1');
});
it('handles items with undefined audioFiles gracefully', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([
makeAudiobookItem({ id: 'audio-1' }),
{
id: 'no-audio-field',
addedAt: 1700000000000,
updatedAt: 1700000000000,
media: {
duration: 0,
metadata: { title: 'Broken', authorName: 'Author', genres: [], explicit: false },
// audioFiles intentionally absent
},
},
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(1);
expect(items[0].id).toBe('audio-1');
});
it('treats item with both audio files and ebook file as audiobook (passes filter)', async () => {
// ABS can have items with both audio + ebook (companion ebook)
const hybridItem = makeAudiobookItem({ id: 'hybrid-1', title: 'Hybrid Item' });
(hybridItem as any).media.ebookFile = { ino: 'ino-e', metadata: { filename: 'companion.epub' } };
apiMock.getABSLibraryItems.mockResolvedValue([hybridItem]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Hybrid Item');
});
it('filters using numAudioFiles when audioFiles array is absent (minified list response)', async () => {
// The ABS list endpoint returns minified media without the audioFiles array
apiMock.getABSLibraryItems.mockResolvedValue([
{
id: 'audiobook-minified',
addedAt: 1700000000000,
updatedAt: 1700000100000,
media: {
duration: 3600,
numAudioFiles: 5,
numTracks: 5,
coverPath: '/covers/1.jpg',
// no audioFiles array — minified response
metadata: {
title: 'Minified Audiobook',
authorName: 'Author',
narratorName: 'Narrator',
description: 'Desc',
asin: 'B00MIN001',
publishedYear: '2021',
genres: [],
explicit: false,
},
},
},
{
id: 'ebook-minified',
addedAt: 1700000000000,
updatedAt: 1700000100000,
media: {
duration: 0,
numAudioFiles: 0,
numTracks: 0,
coverPath: '/covers/ebook.jpg',
ebookFormat: 'epub',
// no audioFiles array — minified response
metadata: {
title: 'Minified Ebook',
authorName: 'Ebook Author',
description: 'Ebook Desc',
asin: 'B00EBOOK02',
publishedYear: '2023',
genres: [],
explicit: false,
},
},
},
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Minified Audiobook');
});
it('falls back to duration check when neither numAudioFiles nor audioFiles exist', async () => {
apiMock.getABSLibraryItems.mockResolvedValue([
{
id: 'audio-duration-only',
addedAt: 1700000000000,
updatedAt: 1700000100000,
media: {
duration: 7200,
coverPath: '/covers/1.jpg',
metadata: {
title: 'Duration Audiobook',
authorName: 'Author',
genres: [],
explicit: false,
},
},
},
{
id: 'ebook-duration-zero',
addedAt: 1700000000000,
updatedAt: 1700000100000,
media: {
duration: 0,
coverPath: '/covers/ebook.jpg',
metadata: {
title: 'Duration Ebook',
authorName: 'Author',
genres: [],
explicit: false,
},
},
},
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Duration Audiobook');
});
it('assumes audio content when no media fields can determine type', async () => {
// Safety: if we truly can't tell, don't filter it out
apiMock.getABSLibraryItems.mockResolvedValue([
{
id: 'unknown-1',
addedAt: 1700000000000,
updatedAt: 1700000100000,
media: {
coverPath: '/covers/1.jpg',
metadata: {
title: 'Unknown Media',
authorName: 'Author',
genres: [],
explicit: false,
},
},
},
]);
const service = new AudiobookshelfLibraryService();
const items = await service.getLibraryItems('lib-1');
// Should NOT be filtered — we can't determine, so assume audio
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Unknown Media');
});
});
}); });