From 95e63dfc361af5fda16dc928202ab91ebec0d125 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 6 Feb 2026 17:13:39 -0500 Subject: [PATCH] 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. --- docker/unified/app-start.sh | 61 +- docker/unified/entrypoint.sh | 6 +- docker/unified/redis-start.sh | 54 +- .../admin/components/RecentRequestsTable.tsx | 32 +- .../components/RequestActionsDropdown.tsx | 52 +- src/app/admin/page.tsx | 50 +- .../tabs/EbookTab/useEbookSettings.ts | 5 +- src/app/api/admin/requests/route.ts | 2 + .../settings/ebook/test-flaresolverr/route.ts | 4 +- .../InteractiveTorrentSearchModal.tsx | 673 +++++++++++------- src/lib/services/ebook-scraper.ts | 7 +- .../library/AudiobookshelfLibraryService.ts | 51 +- tests/api/admin-settings-tests.routes.test.ts | 5 +- .../RequestActionsDropdown.test.tsx | 23 + .../tabs/EbookTab/useEbookSettings.test.tsx | 6 +- .../InteractiveTorrentSearchModal.test.tsx | 14 +- tests/services/ebook-scraper.test.ts | 28 +- .../audiobookshelf-library.service.test.ts | 381 +++++++++- 18 files changed, 1027 insertions(+), 427 deletions(-) diff --git a/docker/unified/app-start.sh b/docker/unified/app-start.sh index 5cbfcef..1104d8f 100644 --- a/docker/unified/app-start.sh +++ b/docker/unified/app-start.sh @@ -3,42 +3,11 @@ # Uses gosu to ensure correct PUID:PGID for file operations # # Supports: -# - Docker: Uses gosu to switch to PUID:PGID -# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker) -# - Rootless Podman: Skips gosu to preserve user namespace UID mapping +# - Docker/LXC: Uses gosu to switch to PUID:PGID (default) +# - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu 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: -# - 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) if [ -f /etc/environment ]; then set -a @@ -58,23 +27,18 @@ cd /app # ============================================================================= # START SERVER WITH APPROPRIATE UID:GID HANDLING # ============================================================================= -# Three scenarios: -# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID -# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping -# 3. Non-root fallback: Already running as non-root, run directly +# Two scenarios: +# 1. Default: Running as root, use gosu to switch to PUID:PGID +# 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping) start_server() { if [ "$(id -u)" = "0" ]; then - if is_user_namespace_root; then - # Rootless container (e.g., rootless Podman) - # Skip gosu - the user namespace already maps our "root" to the correct host UID - 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)" + if [ "${ROOTLESS_CONTAINER}" = "true" ]; then + # Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user + echo "[App] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)" node server.js & else - # Normal container (Docker or rootful Podman) - # Use gosu to switch to the specified PUID:PGID + # Default: Use gosu to switch to the specified PUID:PGID echo "[App] Switching to UID:GID $PUID:$PGID via gosu..." gosu "$PUID:$PGID" node server.js & fi @@ -104,11 +68,8 @@ if [ -f "/proc/$SERVER_PID/status" ]; then ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}') echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID" - # Only warn about mismatch in non-rootless scenarios - if ! is_user_namespace_root; then - 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 + if [ "${ROOTLESS_CONTAINER}" != "true" ] && { [ "$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 diff --git a/docker/unified/entrypoint.sh b/docker/unified/entrypoint.sh index 63334e9..4669352 100644 --- a/docker/unified/entrypoint.sh +++ b/docker/unified/entrypoint.sh @@ -329,6 +329,7 @@ PORT=$PORT HOSTNAME=$HOSTNAME PUID=${PUID:-} PGID=${PGID:-} +ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-} EOF echo "✅ Environment configured" @@ -363,7 +364,10 @@ echo "📊 Services starting:" echo " - PostgreSQL (internal, user=postgres)" echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})" 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 "🔐 Using gosu for reliable UID:GID switching" echo " App and Redis will run as $PUID:$PGID" diff --git a/docker/unified/redis-start.sh b/docker/unified/redis-start.sh index d78c436..e647877 100644 --- a/docker/unified/redis-start.sh +++ b/docker/unified/redis-start.sh @@ -3,42 +3,11 @@ # Uses gosu to ensure correct PUID:PGID for file operations # # Supports: -# - Docker: Uses gosu to switch to PUID:PGID -# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker) -# - Rootless Podman: Skips gosu to preserve user namespace UID mapping +# - Docker/LXC: Uses gosu to switch to PUID:PGID (default) +# - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu 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: -# - 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) if [ -f /etc/environment ]; then set -a @@ -56,24 +25,19 @@ echo "[Redis] Process will run as UID:GID = $PUID:$PGID" # ============================================================================= # START REDIS WITH APPROPRIATE UID:GID HANDLING # ============================================================================= -# Three scenarios: -# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID -# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping -# 3. Non-root fallback: Already running as non-root, run directly +# Two scenarios: +# 1. Default: Running as root, use gosu to switch to PUID:PGID +# 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping) 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 is_user_namespace_root; then - # Rootless container (e.g., rootless Podman) - # Skip gosu - the user namespace already maps our "root" to the correct host UID - 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)" + if [ "${ROOTLESS_CONTAINER}" = "true" ]; then + # Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user + echo "[Redis] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)" exec $REDIS_CMD else - # Normal container (Docker or rootful Podman) - # Use gosu to switch to the specified PUID:PGID + # Default: Use gosu to switch to the specified PUID:PGID echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..." exec gosu "$PUID:$PGID" $REDIS_CMD fi diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index bccafe6..81958da 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -13,11 +13,13 @@ import { RequestActionsDropdown } from './RequestActionsDropdown'; import { mutate } from 'swr'; import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; import { useToast } from '@/components/ui/Toast'; +import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; interface RecentRequest { requestId: string; title: string; author: string; + asin?: string | null; status: string; type?: 'audiobook' | 'ebook'; userId: string; @@ -43,6 +45,7 @@ interface RequestsResponse { interface RecentRequestsTableProps { ebookSidecarEnabled?: boolean; + annasArchiveBaseUrl?: string; } 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(); // 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 [isFetchingEbook, setIsFetchingEbook] = useState(false); + // View Details modal state + const [viewDetailsAsin, setViewDetailsAsin] = useState(null); + const [viewDetailsStatus, setViewDetailsStatus] = useState(null); + // 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}`; @@ -314,6 +321,11 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque const hasActiveFilters = debouncedSearch || status !== 'all' || userId; // Action handlers + const handleViewDetails = (asin: string, requestStatus?: string) => { + setViewDetailsAsin(asin); + setViewDetailsStatus(requestStatus || null); + }; + const handleDeleteClick = (requestId: string, title: string) => { setSelectedRequest({ id: requestId, title }); setShowDeleteConfirm(true); @@ -659,13 +671,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque author: request.author, status: request.status, type: request.type, + asin: request.asin, torrentUrl: request.torrentUrl, }} onDelete={handleDeleteClick} onManualSearch={handleManualSearch} onCancel={handleCancel} + onViewDetails={(asin) => handleViewDetails(asin, request.status)} onFetchEbook={handleFetchEbook} ebookSidecarEnabled={ebookSidecarEnabled} + annasArchiveBaseUrl={annasArchiveBaseUrl} isLoading={isDeleting || isFetchingEbook} /> @@ -808,6 +823,21 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque onConfirm={handleDeleteConfirm} onCancel={handleDeleteCancel} /> + + {/* Audiobook Details Modal */} + {viewDetailsAsin && ( + { + setViewDetailsAsin(null); + setViewDetailsStatus(null); + }} + isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'} + requestStatus={viewDetailsStatus} + hideRequestActions + /> + )} ); } diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index a653ca6..b9c3453 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -19,13 +19,16 @@ export interface RequestActionsDropdownProps { author: string; status: string; type?: 'audiobook' | 'ebook'; + asin?: string | null; torrentUrl?: string | null; }; onDelete: (requestId: string, title: string) => void; onManualSearch: (requestId: string) => Promise; onCancel: (requestId: string) => Promise; + onViewDetails?: (asin: string) => void; onFetchEbook?: (requestId: string) => Promise; ebookSidecarEnabled?: boolean; + annasArchiveBaseUrl?: string; isLoading?: boolean; } @@ -34,8 +37,10 @@ export function RequestActionsDropdown({ onDelete, onManualSearch, onCancel, + onViewDetails, onFetchEbook, ebookSidecarEnabled = false, + annasArchiveBaseUrl = 'https://annas-archive.li', isLoading = false, }: RequestActionsDropdownProps) { const [isOpen, setIsOpen] = useState(false); @@ -46,6 +51,9 @@ export function RequestActionsDropdown({ // Determine request type 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 // Ebooks don't support manual/interactive search (Anna's Archive only) const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); @@ -64,7 +72,7 @@ export function RequestActionsDropdown({ 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]}`; + viewSourceUrl = `${annasArchiveBaseUrl.replace(/\/+$/, '')}/md5/${md5Match[1]}`; } } } 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) const dropdownMenu = isOpen && style && (
+ {/* View Details */} + {canViewDetails && ( + + )} + + {/* Divider after View Details */} + {canViewDetails && (canSearch || canViewSource || canFetchEbook || canCancel || canDelete) && ( +
+ )} + {/* Manual Search */} {canSearch && (
- {/* Requests Awaiting Approval */} - {pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && ( - - )} - - {/* Active Downloads */} -
-

- Active Downloads -

- -
- - {/* Request Management */} -
-

- Request Management -

- -
- {/* Quick Actions */} -
+
+ + {/* Requests Awaiting Approval */} + {pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && ( + + )} + + {/* Active Downloads */} +
+

+ Active Downloads +

+ +
+ + {/* Request Management */} +
+

+ Request Management +

+ +
+ )}
diff --git a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts index 8d0b359..c27c259 100644 --- a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts +++ b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts @@ -51,7 +51,10 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', { method: 'POST', 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(); diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts index 9ecb571..774c3d1 100644 --- a/src/app/api/admin/requests/route.ts +++ b/src/app/api/admin/requests/route.ts @@ -101,6 +101,7 @@ export async function GET(request: NextRequest) { id: true, title: true, author: true, + audibleAsin: true, }, }, user: { @@ -129,6 +130,7 @@ export async function GET(request: NextRequest) { requestId: request.id, title: request.audiobook.title, author: request.audiobook.author, + asin: request.audiobook.audibleAsin || null, status: request.status, type: request.type || 'audiobook', // Include request type for UI display userId: request.user.id, diff --git a/src/app/api/admin/settings/ebook/test-flaresolverr/route.ts b/src/app/api/admin/settings/ebook/test-flaresolverr/route.ts index f9b7f67..346dd8d 100644 --- a/src/app/api/admin/settings/ebook/test-flaresolverr/route.ts +++ b/src/app/api/admin/settings/ebook/test-flaresolverr/route.ts @@ -14,7 +14,7 @@ export async function POST(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - const { url } = await request.json(); + const { url, baseUrl } = await request.json(); if (!url) { 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); } catch (error) { diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 10814f7..841eff7 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -9,10 +9,8 @@ 'use client'; -import React, { useState } from 'react'; -import { Modal } from '@/components/ui/Modal'; -import { Button } from '@/components/ui/Button'; -import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm'; import { useInteractiveSearch, @@ -40,6 +38,46 @@ interface InteractiveTorrentSearchModalProps { 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({ isOpen, onClose, @@ -66,9 +104,15 @@ export function InteractiveTorrentSearchModal({ const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin(); 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(null); 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 const isEbookMode = searchMode === 'ebook'; @@ -89,58 +133,72 @@ export function InteractiveTorrentSearchModal({ ? (searchByRequestError || selectTorrentError) : (searchByAudiobookError || requestWithTorrentError)); + // Mount tracking for portal + useEffect(() => { setMounted(true); }, []); + // Reset search title when modal opens/closes or audiobook changes - React.useEffect(() => { + useEffect(() => { setSearchTitle(audiobook.title); setResults([]); }, [isOpen, audiobook.title]); // Perform search when modal opens - React.useEffect(() => { + useEffect(() => { if (isOpen && results.length === 0) { performSearch(); } }, [isOpen]); - const performSearch = async () => { - // Clear existing results while searching - setResults([]); + // ESC key and body scroll lock + // ESC dismisses confirmation first, then closes modal + 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 { let data; if (isEbookMode) { - // Ebook mode: search Anna's Archive + indexers const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; if (useAsinMode && asin) { - // ASIN-based ebook search (user flow from details modal) data = await searchEbooksByAsin(asin, customTitle); } else if (requestId) { - // Request ID-based ebook search (admin flow) data = await searchEbooks(requestId, customTitle); } else { console.error('Ebook search requires either requestId or asin'); return; } } else if (hasRequestId) { - // Existing audiobook flow: search by requestId with optional custom title const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; data = await searchByRequestId(requestId, customTitle); } else { - // New audiobook flow: search by custom title + original author + optional ASIN for size scoring const audiobookAsin = fullAudiobook?.asin; data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin); } setResults(data || []); } catch (err) { - // Error already handled by hook console.error('Search failed:', err); } }; - const handleSearchKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - performSearch(); - } + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') performSearch(); }; const handleDownloadClick = (torrent: TorrentResult) => { @@ -149,270 +207,385 @@ export function InteractiveTorrentSearchModal({ const handleConfirmDownload = async () => { if (!confirmTorrent) return; - try { if (isEbookMode) { - // Ebook flow if (useAsinMode && asin) { - // ASIN-based ebook selection (user flow from details modal) await selectEbookByAsin(asin, confirmTorrent); } else if (requestId) { - // Request ID-based ebook selection (admin flow) await selectEbook(requestId, confirmTorrent); } else { throw new Error('Request ID or ASIN required for ebook selection'); } } else if (hasRequestId) { - // Existing audiobook flow: select torrent for existing request await selectTorrent(requestId, confirmTorrent); } 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); } - // Notify parent of successful selection onSuccess?.(); - // Close modals on success setConfirmTorrent(null); onClose(); - // Request list will auto-refresh via SWR } catch (err) { - // Error already handled by hook console.error('Failed to download:', err); 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 - const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent'; - const searchLabel = isEbookMode ? 'Search Title' : 'Search Title'; - 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 modalTitle = isEbookMode ? 'Find Ebook' : 'Find Audiobook'; + const noResultsText = isEbookMode ? 'No ebooks found' : 'No results found'; const resultCountText = (count: number) => isEbookMode - ? `Found ${count} ebook${count !== 1 ? 's' : ''}` - : `Found ${count} torrent${count !== 1 ? 's' : ''}`; - const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent'; + ? `${count} ebook${count !== 1 ? 's' : ''} found` + : `${count} result${count !== 1 ? 's' : ''} found`; + const confirmModalTitle = isEbookMode ? 'Download Ebook' : 'Confirm Download'; - return ( - <> - -
- {/* Search customization - editable for ALL modes */} -
- -
- setSearchTitle(e.target.value)} - onKeyPress={handleSearchKeyPress} - placeholder={searchPlaceholder} - disabled={isSearching} - 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" - /> - + if (!isOpen || !mounted) return null; + + const modalContent = ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

{modalTitle}

+ +
+ + {/* Scrollable Content */} +
+
+ + {/* Search Bar */} +
+
+ + + + 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 ? ( +
+ ) : ( + + )} +
+

+ by {audiobook.author} +

-

By {audiobook.author}

-
- {/* Error message */} - {error && ( -
-

{error}

-
- )} + {/* Error */} + {error && ( +
+ + + +

{error}

+
+ )} - {/* Loading state */} - {isSearching && ( -
-
- {loadingText} -
- )} + {/* Loading Skeleton */} + {isSearching && ( +
+ {skeletonRows.map((widths, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} - {/* No results */} - {!isSearching && results.length === 0 && ( -
-

{noResultsText}

- -
- )} + {/* Empty State */} + {!isSearching && results.length === 0 && !error && ( +
+
+ + + +
+

{noResultsText}

+

Try adjusting your search terms

+ +
+ )} - {/* Results table */} - {!isSearching && results.length > 0 && ( -
-
- - - - - - - - - - - - - - - {results.map((result) => ( - - - - - - - - - - - ))} - -
- # - - Title - - Size - - Score - - Bonus - - Seeds - - {isEbookMode ? 'Source' : 'Indexer'} - - Action -
- {result.rank} - - -
- {/* Anna's Archive badge for ebook mode */} - {isEbookMode && result.source === 'annas_archive' && ( - - Anna's Archive - - )} - {result.format && ( - - {result.format} - - )} - - {result.size > 0 ? formatSize(result.size) : 'Unknown'} - - {/* Hide seeds badge for Anna's Archive results */} - {!(isEbookMode && result.source === 'annas_archive') && ( - - {result.seeders} seeds - - )} -
-
- {result.size > 0 ? formatSize(result.size) : '—'} - - - {Math.round(result.score)} - - - {result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'} - - {isEbookMode && result.source === 'annas_archive' ? ( - N/A - ) : ( - - - - - {result.seeders} - - )} - - {isEbookMode && result.source === 'annas_archive' ? ( - Anna's Archive - ) : ( - result.indexer - )} - - -
+ {result.title} + +
+ + {/* Metadata Row */} +
+ {/* Rank */} + #{result.rank} + · + + {/* Indexer / Source */} + {isAnnasArchive ? ( + Anna's Archive + ) : ( + {result.indexer} + )} + + {/* Size */} + {result.size > 0 && ( + <> + · + {formatSize(result.size)} + + )} + + {/* Format */} + {displayFormat && ( + <> + · + + {displayFormat} + + + )} + + {/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */} + {!isAnnasArchive && ( + <> + · + {isUsenet ? ( + + + + + NZB + + ) : ( + + + + + {result.seeders ?? 0} + + )} + + )} + + {/* Age */} + {result.publishDate && ( + <> + · + {formatAge(result.publishDate)} + + )} + + {/* Bonus Points */} + {result.bonusPoints > 0 && ( + <> + · + +{Math.round(result.bonusPoints)} + + )} +
+
+ + {/* Action Button */} + +
+ ); + })} +
+ )} +
+
+ + {/* Sticky Footer */} + {!isSearching && results.length > 0 && ( +
+

+ {resultCountText(results.length)} +

+ +
+ )} + + {/* Inline Confirmation Overlay */} + {confirmTorrent && ( +
!isDownloading && setConfirmTorrent(null)} + > +
e.stopPropagation()} + > + {/* Confirm Header */} +
+
+
+ + + +
+
+

+ {confirmModalTitle} +

+

+ This will start the download +

+
+
+ + {/* Selected Item Preview */} +
+

+ {confirmTorrent.title} +

+
+ {confirmTorrent.indexer} + {confirmTorrent.size > 0 && ( + <> + · + {formatSize(confirmTorrent.size)} + + )} + {confirmTorrent.format && ( + <> + · + {confirmTorrent.format} + + )} + {confirmTorrent.protocol === 'usenet' ? ( + <> + · + NZB + + ) : confirmTorrent.seeders !== undefined && ( + <> + · + {confirmTorrent.seeders} seeds + + )} +
+
+
+ + {/* Confirm Actions */} +
+ +
- )} - - {/* Footer with result count */} - {!isSearching && results.length > 0 && ( -
-

- {resultCountText(results.length)} -

- -
- )} -
- - - {/* Confirmation Modal */} - setConfirmTorrent(null)} - onConfirm={handleConfirmDownload} - title={confirmTitle} - message={`Download "${confirmTorrent?.title}"?`} - confirmText="Download" - isLoading={isDownloading} - variant="primary" - /> - +
+ )} +
+
); + + return createPortal(modalContent, document.body); } diff --git a/src/lib/services/ebook-scraper.ts b/src/lib/services/ebook-scraper.ts index 7eb87f9..610254a 100644 --- a/src/lib/services/ebook-scraper.ts +++ b/src/lib/services/ebook-scraper.ts @@ -127,13 +127,14 @@ async function fetchHtml( * Test FlareSolverr connection */ export async function testFlareSolverrConnection( - flaresolverrUrl: string + flaresolverrUrl: string, + baseUrl: string = 'https://annas-archive.li' ): Promise<{ success: boolean; message: string; responseTime?: number }> { const startTime = Date.now(); try { - // Test with a simple request to Anna's Archive homepage - const testUrl = 'https://annas-archive.li/'; + // Test with a simple request to the configured Anna's Archive base URL + const testUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const html = await fetchViaFlareSolverr(testUrl, flaresolverrUrl, 30000); const responseTime = Date.now() - startTime; diff --git a/src/lib/services/library/AudiobookshelfLibraryService.ts b/src/lib/services/library/AudiobookshelfLibraryService.ts index 3a79fcd..dff5b06 100644 --- a/src/lib/services/library/AudiobookshelfLibraryService.ts +++ b/src/lib/services/library/AudiobookshelfLibraryService.ts @@ -21,6 +21,9 @@ import { } from '../audiobookshelf/api'; import { ABSLibraryItem } from '../audiobookshelf/types'; import { getConfigService } from '@/lib/services/config.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('AudiobookshelfLibrary'); export class AudiobookshelfLibraryService implements ILibraryService { private configService = getConfigService(); @@ -63,17 +66,26 @@ export class AudiobookshelfLibraryService implements ILibraryService { async getLibraryItems(libraryId: string): Promise { 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 { const items = await getABSRecentItems(libraryId, limit); - return items.map(this.mapABSItemToLibraryItem); + return items.filter(this.hasAudioContent).map(this.mapABSItemToLibraryItem); } async getItem(itemId: string): Promise { try { 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); } catch { return null; @@ -82,7 +94,9 @@ export class AudiobookshelfLibraryService implements ILibraryService { async searchItems(libraryId: string, query: string): Promise { 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 { @@ -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 { const metadata = item.media.metadata; return { diff --git a/tests/api/admin-settings-tests.routes.test.ts b/tests/api/admin-settings-tests.routes.test.ts index 08c58f2..2e9f88b 100644 --- a/tests/api/admin-settings-tests.routes.test.ts +++ b/tests/api/admin-settings-tests.routes.test.ts @@ -331,13 +331,14 @@ describe('Admin settings test routes', () => { it('tests FlareSolverr connection', async () => { 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 response = await POST(request as any); const payload = await response.json(); expect(payload.success).toBe(true); + expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.li'); }); 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 () => { 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 response = await POST(request as any); diff --git a/tests/app/admin/components/RequestActionsDropdown.test.tsx b/tests/app/admin/components/RequestActionsDropdown.test.tsx index be8c7af..88bf9ae 100644 --- a/tests/app/admin/components/RequestActionsDropdown.test.tsx +++ b/tests/app/admin/components/RequestActionsDropdown.test.tsx @@ -74,6 +74,29 @@ describe('RequestActionsDropdown', () => { expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book'); }); + it('uses configured base URL for ebook View Source link', () => { + render( + + ); + + 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 () => { const onFetchEbook = vi.fn().mockResolvedValue(undefined); const onDelete = vi.fn(); diff --git a/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx b/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx index e3f4da5..3133dab 100644 --- a/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx +++ b/tests/app/admin/settings/tabs/EbookTab/useEbookSettings.test.tsx @@ -75,7 +75,7 @@ describe('useEbookSettings', () => { 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({ ok: true, json: async () => ({ success: true, message: 'OK' }), @@ -91,6 +91,10 @@ describe('useEbookSettings', () => { }); 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 () => { diff --git a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx index 3b22cf4..d169e03 100644 --- a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx +++ b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx @@ -98,9 +98,8 @@ describe('InteractiveTorrentSearchModal', () => { expect(await screen.findByText('Test Torrent')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Download' })); - const downloadButtons = screen.getAllByRole('button', { name: 'Download' }); - fireEvent.click(downloadButtons[downloadButtons.length - 1]); + fireEvent.click(screen.getByRole('button', { name: 'Get' })); + fireEvent.click(await screen.findByRole('button', { name: 'Download' })); await waitFor(() => { expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult); @@ -129,9 +128,8 @@ describe('InteractiveTorrentSearchModal', () => { expect(searchByAudiobookMock).toHaveBeenCalledWith('Test Book', 'Test Author', 'ASIN-1'); }); - fireEvent.click(screen.getByRole('button', { name: 'Download' })); - const downloadButtons = screen.getAllByRole('button', { name: 'Download' }); - fireEvent.click(downloadButtons[downloadButtons.length - 1]); + fireEvent.click(screen.getByRole('button', { name: 'Get' })); + fireEvent.click(await screen.findByRole('button', { name: 'Download' })); await waitFor(() => { expect(requestWithTorrentMock).toHaveBeenCalledWith(fullAudiobook, baseResult); @@ -157,9 +155,9 @@ describe('InteractiveTorrentSearchModal', () => { 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.keyPress(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); await waitFor(() => { expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title'); diff --git a/tests/services/ebook-scraper.test.ts b/tests/services/ebook-scraper.test.ts index ea73524..d31ed40 100644 --- a/tests/services/ebook-scraper.test.ts +++ b/tests/services/ebook-scraper.test.ts @@ -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.responseTime).toBeTypeOf('number'); }); + it('uses configured base URL for FlareSolverr test', async () => { + const longHtml = `${'Anna'.padEnd(1200, 'A')}`; + 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 () => { axiosMock.post.mockResolvedValue({ 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); }); @@ -85,7 +103,7 @@ describe('E-book sidecar', () => { it('returns error details when FlareSolverr request fails', async () => { 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.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.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.message).toContain('FlareSolverr returned HTTP 403'); diff --git a/tests/services/library/audiobookshelf-library.service.test.ts b/tests/services/library/audiobookshelf-library.service.test.ts index 3caaaeb..f32ecf2 100644 --- a/tests/services/library/audiobookshelf-library.service.test.ts +++ b/tests/services/library/audiobookshelf-library.service.test.ts @@ -26,6 +26,70 @@ vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); +// --- Test data helpers --- + +/** Creates a mock ABS item with audio files (audiobook) */ +function makeAudiobookItem(overrides: Record = {}) { + 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 = {}) { + 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', () => { beforeEach(() => { vi.clearAllMocks(); @@ -71,24 +135,18 @@ describe('AudiobookshelfLibraryService', () => { it('maps library items to generic fields', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ - { + makeAudiobookItem({ id: 'item-1', - addedAt: 1700000000000, - updatedAt: 1700000100000, - media: { - duration: 3600, - coverPath: '/covers/1.jpg', - metadata: { - title: 'Title', - authorName: 'Author', - narratorName: 'Narrator', - description: 'Desc', - asin: 'ASIN1', - isbn: 'ISBN1', - publishedYear: '2020', - }, - }, - }, + title: 'Title', + authorName: 'Author', + narratorName: 'Narrator', + description: 'Desc', + asin: 'ASIN1', + isbn: 'ISBN1', + publishedYear: '2020', + duration: 3600, + coverPath: '/covers/1.jpg', + }), ]); const service = new AudiobookshelfLibraryService(); @@ -123,20 +181,18 @@ describe('AudiobookshelfLibraryService', () => { it('searches items and maps results', async () => { apiMock.searchABSItems.mockResolvedValue([ { - libraryItem: { + libraryItem: makeAudiobookItem({ id: 'item-2', - addedAt: 1700000000000, - updatedAt: 1700000000000, - media: { - duration: 200, - metadata: { - title: 'Search Title', - authorName: 'Search Author', - narratorName: '', - description: '', - }, - }, - }, + title: 'Search Title', + authorName: 'Search Author', + narratorName: '', + description: '', + duration: 200, + coverPath: undefined, + asin: undefined, + isbn: undefined, + publishedYear: undefined, + }), }, ]); @@ -193,4 +249,269 @@ describe('AudiobookshelfLibraryService', () => { 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'); + }); + }); });