Compare commits

...

6 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
kikootwo 4c1d1c89e8 Audible regions: isEnglish flag + UI warnings
Add an isEnglish flag to AUDIBLE_REGIONS and update region handling across the app. UI: populate Audible region selects from AUDIBLE_REGIONS and mark non-English regions with a '*' and an amber warning explaining limited feature support. Service: set axios default param language=english on Audible requests (simplifies/fixes locale handling) and remove the previous locale-correction flow. API: validate regions dynamically from AUDIBLE_REGIONS. Also bump package version to 1.0.2. These changes make region metadata explicit and inform users about limited support for non-English regions while forcing English content where supported.
2026-02-06 11:48:00 -05:00
kikootwo d25d93680e Merge pull request #36 from aronjanosch/feature/german-audible-region-and-regional-title
Add German Audible region
2026-02-06 11:10:14 -05:00
Aron Wiederkehr 312421a96b Add German Audible region 2026-02-05 20:09:21 +01:00
27 changed files with 1174 additions and 570 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
# ======================================================================== # ========================================================================
+10 -49
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,13 +68,10 @@ 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
if [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; then
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)" echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
fi fi
fi fi
fi
# Wait for server process (keeps the script running as long as the server is alive) # Wait for server process (keeps the script running as long as the server is alive)
wait $SERVER_PID wait $SERVER_PID
+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
+18 -15
View File
@@ -26,11 +26,18 @@ Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallbac
Configurable Audible region for accurate metadata matching across different international Audible stores. Configurable Audible region for accurate metadata matching across different international Audible stores.
**Supported Regions:** **Supported Regions:**
- United States (`us`) - `audible.com` (default) - United States (`us`) - `audible.com` (default, English)
- Canada (`ca`) - `audible.ca` - Canada (`ca`) - `audible.ca` (English)
- United Kingdom (`uk`) - `audible.co.uk` - United Kingdom (`uk`) - `audible.co.uk` (English)
- Australia (`au`) - `audible.com.au` - Australia (`au`) - `audible.com.au` (English)
- India (`in`) - `audible.in` - India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English)
**`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *")
**Why Regions Matter:** **Why Regions Matter:**
- Each Audible region uses different ASINs for the same audiobook - Each Audible region uses different ASINs for the same audiobook
@@ -48,7 +55,7 @@ Configurable Audible region for accurate metadata matching across different inte
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl` - Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
- Audnexus API calls include region parameter: `?region={code}` - Audnexus API calls include region parameter: `?region={code}`
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only) - IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
- **Locale enforcement:** Cookie `lc-acbus=en_US` + `handleLocaleRedirect()` detects non-English culture codes in response URLs and re-requests using the English URL from Audible's locale picker - **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
- Configuration service helper: `getAudibleRegion()` returns configured region - Configuration service helper: `getAudibleRegion()` returns configured region
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed - **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared - **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
@@ -228,12 +235,8 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
- **Affects:** All Audiobookshelf metadata matching operations - **Affects:** All Audiobookshelf metadata matching operations
**Non-English locale pages served to users outside US (2026-02-05)** **Non-English locale pages served to users outside US (2026-02-05)**
- **Problem:** Audible uses IP geolocation to add culture codes (e.g., `es_US`, `fr_CA`) to URLs, serving locale-specific pages. `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale redirects within the same region. - **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes.
- **Impact:** Users self-hosting from non-English-speaking countries (e.g., Dominican Republic) got Spanish bestsellers/new releases on their homepage because the `audible_refresh` job scraped locale-redirected pages. - **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
- **Fix:** Three-layer defense in `AudibleService`: - **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available.
1. **Cookie:** `lc-acbus=en_US` header hints English locale preference - **Location:** `src/lib/integrations/audible.service.ts``initialize()` (axios default params)
2. **Locale picker detection (primary):** After every request, checks response URL for non-`en_*` culture codes (`xx_YY` pattern). If found, parses page HTML for Audible's `<adbl-toggle-chip>` locale picker, extracts the English option's `data-value` URL, and re-requests. Data-driven — uses Audible's own English URL rather than guessing. - **Affects:** All Audible scraping: popular, new releases, search, detail pages
3. **Fallback URL rewrite:** If no locale picker found, strips the culture code from the path and adds `language=en_US` query param (mirrors picker pattern).
- **Verification:** After correction, validates the response URL no longer contains a non-English culture code and logs success/failure.
- **Location:** `src/lib/integrations/audible.service.ts``handleLocaleRedirect()`, `initialize()`
- **Affects:** All Audible scraping: popular, new releases, search, detail pages (via `fetchWithRetry`)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.1", "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();
@@ -6,6 +6,7 @@
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Settings, ABSLibrary } from '../../lib/types'; import { Settings, ABSLibrary } from '../../lib/types';
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
interface AudiobookshelfSectionProps { interface AudiobookshelfSectionProps {
settings: Settings; settings: Settings;
@@ -161,12 +162,39 @@ export function AudiobookshelfSection({
onChange={(e) => handleAudibleRegionChange(e.target.value)} onChange={(e) => handleAudibleRegionChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="us">United States</option> {Object.values(AUDIBLE_REGIONS).map((region) => (
<option value="ca">Canada</option> <option key={region.code} value={region.code}>
<option value="uk">United Kingdom</option> {region.name}{!region.isEnglish ? ' *' : ''}
<option value="au">Australia</option> </option>
<option value="in">India</option> ))}
</select> </select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Non-English Region
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Many features such as search, discovery, and metadata matching are not yet fully
supported for non-English regions. You may still proceed, but expect limited
functionality.
</p>
</div>
</div>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Audiobookshelf. This ensures accurate book matching and metadata. configuration in Audiobookshelf. This ensures accurate book matching and metadata.
@@ -6,6 +6,7 @@
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Settings, PlexLibrary } from '../../lib/types'; import { Settings, PlexLibrary } from '../../lib/types';
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
interface PlexSectionProps { interface PlexSectionProps {
settings: Settings; settings: Settings;
@@ -161,12 +162,39 @@ export function PlexSection({
onChange={(e) => handleAudibleRegionChange(e.target.value)} onChange={(e) => handleAudibleRegionChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="us">United States</option> {Object.values(AUDIBLE_REGIONS).map((region) => (
<option value="ca">Canada</option> <option key={region.code} value={region.code}>
<option value="uk">United Kingdom</option> {region.name}{!region.isEnglish ? ' *' : ''}
<option value="au">Australia</option> </option>
<option value="in">India</option> ))}
</select> </select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Non-English Region
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Many features such as search, discovery, and metadata matching are not yet fully
supported for non-English regions. You may still proceed, but expect limited
functionality.
</p>
</div>
</div>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Plex. This ensures accurate book matching and metadata. configuration in Plex. This ensures accurate book matching and metadata.
+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,
+3 -2
View File
@@ -8,11 +8,12 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
import { getConfigService } from '@/lib/services/config.service'; import { getConfigService } from '@/lib/services/config.service';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { getJobQueueService } from '@/lib/services/job-queue.service'; import { getJobQueueService } from '@/lib/services/job-queue.service';
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Audible'); const logger = RMABLogger.create('API.Admin.Settings.Audible');
const VALID_REGIONS = ['us', 'ca', 'uk', 'au', 'in']; const VALID_REGIONS = Object.keys(AUDIBLE_REGIONS);
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
@@ -24,7 +25,7 @@ export async function PUT(request: NextRequest) {
if (!region || !VALID_REGIONS.includes(region)) { if (!region || !VALID_REGIONS.includes(region)) {
logger.warn('Invalid region provided', { region }); logger.warn('Invalid region provided', { region });
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Invalid Audible region. Must be one of: us, ca, uk, au, in' }, { success: false, error: `Invalid Audible region. Must be one of: ${VALID_REGIONS.join(', ')}` },
{ status: 400 } { status: 400 }
); );
} }
@@ -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) {
+33 -6
View File
@@ -6,7 +6,7 @@
'use client'; 'use client';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { AudibleRegion } from '@/lib/types/audible'; import { AudibleRegion, AUDIBLE_REGIONS } from '@/lib/types/audible';
interface BackendSelectionStepProps { interface BackendSelectionStepProps {
value: 'plex' | 'audiobookshelf'; value: 'plex' | 'audiobookshelf';
@@ -113,12 +113,39 @@ export function BackendSelectionStep({
onChange={(e) => onAudibleRegionChange(e.target.value as AudibleRegion)} onChange={(e) => onAudibleRegionChange(e.target.value as AudibleRegion)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="us">United States</option> {Object.values(AUDIBLE_REGIONS).map((region) => (
<option value="ca">Canada</option> <option key={region.code} value={region.code}>
<option value="uk">United Kingdom</option> {region.name}{!region.isEnglish ? ' *' : ''}
<option value="au">Australia</option> </option>
<option value="in">India</option> ))}
</select> </select>
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3">
<svg
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Non-English Region
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Many features such as search, discovery, and metadata matching are not yet fully
supported for non-English regions. You may still proceed, but expect limited
functionality.
</p>
</div>
</div>
</div>
)}
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in {value === 'plex' ? 'Plex' : 'Audiobookshelf'}. This ensures accurate book matching and metadata. configuration in {value === 'plex' ? 'Plex' : 'Audiobookshelf'}. This ensures accurate book matching and metadata.
@@ -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"
style={{
maxHeight: 'calc(100dvh - env(safe-area-inset-top, 0px) - 1rem)',
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<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
onClick={handleClose}
className="p-1.5 -mr-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</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 <input
type="text" type="text"
value={searchTitle} value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)} onChange={(e) => setSearchTitle(e.target.value)}
onKeyPress={handleSearchKeyPress} onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder} placeholder="Search title..."
disabled={isSearching} 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" 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"
/> />
<Button {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} onClick={performSearch}
disabled={isSearching || !searchTitle.trim()} disabled={!searchTitle.trim()}
variant="primary" 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"
size="sm"
> >
Search Search
</Button> </button>
)}
</div> </div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5 ml-1 truncate">
by {audiobook.author}
</p>
</div> </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">
<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> </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 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> </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>
<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> </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
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{results.map((result) => (
<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 <a
href={result.infoUrl || result.guid} href={result.infoUrl || result.guid}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline" className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={result.title} title={result.title}
> >
{result.title} {result.title}
</a> </a>
</div> </div>
<div className="flex gap-2 mt-1 flex-wrap">
{/* Anna's Archive badge for ebook mode */} {/* Metadata Row */}
{isEbookMode && result.source === 'annas_archive' && ( <div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<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"> {/* Rank */}
Anna's Archive <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> </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'} {/* Age */}
</span> {result.publishDate && (
{/* Hide seeds badge for Anna's Archive results */} <>
{!(isEbookMode && result.source === 'annas_archive') && ( <span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<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"> <span>{formatAge(result.publishDate)}</span>
{result.seeders} seeds </>
</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>
</td> </div>
<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) : '—'} {/* Action Button */}
</td> <button
<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)} onClick={() => handleDownloadClick(result)}
disabled={isDownloading} disabled={isDownloading}
size="sm" 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"
variant="primary"
> >
Download Get
</Button> </button>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
);
})}
</div> </div>
)} )}
</div>
</div>
{/* Footer with result count */} {/* Sticky Footer */}
{!isSearching && results.length > 0 && ( {!isSearching && results.length > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700"> <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-sm text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-400 dark:text-gray-500">
{resultCountText(results.length)} {resultCountText(results.length)}
</p> </p>
<Button onClick={performSearch} variant="outline" size="sm"> <button
Refresh Results onClick={performSearch}
</Button> 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> </div>
</Modal> </div>
{/* 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);
} }
+3 -108
View File
@@ -88,10 +88,10 @@ export class AudibleService {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9', 'Accept-Language': 'en-US,en;q=0.9',
'Cookie': 'lc-acbus=en_US', // Force English locale (prevents IP-based language redirect for non-US IPs)
}, },
params: { params: {
ipRedirectOverride: 'true', // Prevent IP-based region redirects ipRedirectOverride: 'true', // Prevent IP-based region redirects
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs)
}, },
}); });
@@ -108,118 +108,16 @@ export class AudibleService {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9', 'Accept-Language': 'en-US,en;q=0.9',
'Cookie': 'lc-acbus=en_US', // Force English locale
}, },
params: { params: {
ipRedirectOverride: 'true', ipRedirectOverride: 'true',
language: 'english',
}, },
}); });
this.initialized = true; this.initialized = true;
} }
} }
/**
* Detect and correct non-English locale pages from Audible.
*
* Audible uses IP geolocation to serve locale-specific pages by adding culture
* codes to URLs (e.g., /adblbestsellers → /es_US/charts/best for Spanish-speaking IPs).
* ipRedirectOverride only prevents region redirects (audible.com → audible.co.uk),
* NOT language/locale redirects within the same region.
*
* Strategy (data-driven):
* 1. Check response URL for any non-English culture code (xx_YY where xx != 'en')
* 2. Parse the page's locale picker (adbl-toggle-chip elements) to find the English URL
* 3. Re-request using Audible's own English URL (from the picker's data-value attribute)
* 4. Fallback: strip culture code from URL + add language=en_US param if no picker found
*
* Returns corrected response, or null if no correction needed.
*/
private async handleLocaleRedirect(response: any): Promise<any | null> {
try {
// Extract final URL after all redirects (Node.js http internals)
const finalUrl: string = response.request?.res?.responseUrl ||
response.request?._redirectable?._currentUrl || '';
if (!finalUrl) return null;
// Check for non-English culture code in URL path
// Culture codes: xx_YY (e.g., es_US, fr_CA, pt_BR, de_DE, ja_JP)
// Match in path segment: must follow a / and be followed by / or end-of-path or query string
const localeMatch = finalUrl.match(/\/([a-z]{2}_[A-Z]{2})(\/|$|\?)/);
if (!localeMatch || localeMatch[1].startsWith('en')) {
return null; // No culture code found, or already English
}
const detectedLocale = localeMatch[1];
logger.warn(`Detected non-English locale (${detectedLocale}) in Audible response URL: ${finalUrl}`);
// --- Primary strategy: parse the locale picker from the page HTML ---
// Audible pages include a locale picker with <adbl-toggle-chip> web components:
// <adbl-toggle-chip data-locale="en_CA" data-value="/charts/best?language=en_CA">English</adbl-toggle-chip>
// <adbl-toggle-chip data-locale="fr_CA" data-value="/fr_CA/charts/best?language=fr_CA">Français</adbl-toggle-chip>
// The English option's data-value gives us the exact correct English URL for this page.
const $ = cheerio.load(response.data);
const englishChip = $('adbl-toggle-chip[data-locale^="en"]').first();
if (englishChip.length > 0) {
const englishPath = englishChip.attr('data-value');
const englishLocale = englishChip.attr('data-locale');
if (englishPath) {
logger.info(`Found English option (${englishLocale}) in locale picker: ${englishPath}`);
// Re-request using the English URL from the picker
// data-value is a relative path (e.g., "/charts/best?language=en_CA")
// Client defaults add ipRedirectOverride=true automatically
const correctedResponse = await this.client.get(englishPath);
// Verify the correction actually resolved to English
const correctedUrl: string = correctedResponse.request?.res?.responseUrl ||
correctedResponse.request?._redirectable?._currentUrl || '';
if (correctedUrl) {
const verifyMatch = correctedUrl.match(/\/([a-z]{2}_[A-Z]{2})(\/|$|\?)/);
if (verifyMatch && !verifyMatch[1].startsWith('en')) {
logger.warn(`Locale correction incomplete — corrected URL still contains non-English locale (${verifyMatch[1]}): ${correctedUrl}`);
} else {
logger.info(`Locale correction successful (${detectedLocale}${englishLocale})`);
}
}
return correctedResponse;
}
logger.warn('English locale chip found but missing data-value attribute');
} else {
logger.warn('No locale picker found on page, attempting fallback URL rewrite');
}
// --- Fallback strategy: URL rewrite ---
// Strip the non-English culture code from the path and add language=en_US param.
// This mirrors the locale picker pattern: English URLs have no prefix + language param.
try {
const urlObj = new URL(finalUrl);
urlObj.pathname = urlObj.pathname.replace(`/${detectedLocale}`, '');
urlObj.searchParams.set('language', 'en_US');
// Build relative path (client will prepend baseURL)
const fallbackPath = urlObj.pathname + urlObj.search;
logger.info(`Fallback: re-requesting with URL rewrite: ${fallbackPath}`);
return await this.client.get(fallbackPath);
} catch (urlError) {
logger.warn('Fallback URL rewrite failed', {
error: urlError instanceof Error ? urlError.message : String(urlError),
});
}
} catch (error) {
logger.debug('Locale correction failed entirely, using original response', {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
/** /**
* Fetch with retry logic and exponential backoff * Fetch with retry logic and exponential backoff
* Retries on network errors and rate limiting (503, 429) * Retries on network errors and rate limiting (503, 429)
@@ -233,10 +131,7 @@ export class AudibleService {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
const response = await this.client.get(url, config); return await this.client.get(url, config);
// Check if redirected to non-English locale (e.g., /es_US/) and correct it
return await this.handleLocaleRedirect(response) || response;
} catch (error: any) { } catch (error: any) {
lastError = error; lastError = error;
const status = error.response?.status; const status = error.response?.status;
+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 {
+14 -1
View File
@@ -3,13 +3,14 @@
* Documentation: documentation/integrations/audible.md * Documentation: documentation/integrations/audible.md
*/ */
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in'; export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de';
export interface AudibleRegionConfig { export interface AudibleRegionConfig {
code: AudibleRegion; code: AudibleRegion;
name: string; name: string;
baseUrl: string; baseUrl: string;
audnexusParam: string; audnexusParam: string;
isEnglish: boolean;
} }
export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = { export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
@@ -18,30 +19,42 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
name: 'United States', name: 'United States',
baseUrl: 'https://www.audible.com', baseUrl: 'https://www.audible.com',
audnexusParam: 'us', audnexusParam: 'us',
isEnglish: true,
}, },
ca: { ca: {
code: 'ca', code: 'ca',
name: 'Canada', name: 'Canada',
baseUrl: 'https://www.audible.ca', baseUrl: 'https://www.audible.ca',
audnexusParam: 'ca', audnexusParam: 'ca',
isEnglish: true,
}, },
uk: { uk: {
code: 'uk', code: 'uk',
name: 'United Kingdom', name: 'United Kingdom',
baseUrl: 'https://www.audible.co.uk', baseUrl: 'https://www.audible.co.uk',
audnexusParam: 'uk', audnexusParam: 'uk',
isEnglish: true,
}, },
au: { au: {
code: 'au', code: 'au',
name: 'Australia', name: 'Australia',
baseUrl: 'https://www.audible.com.au', baseUrl: 'https://www.audible.com.au',
audnexusParam: 'au', audnexusParam: 'au',
isEnglish: true,
}, },
in: { in: {
code: 'in', code: 'in',
name: 'India', name: 'India',
baseUrl: 'https://www.audible.in', baseUrl: 'https://www.audible.in',
audnexusParam: 'in', audnexusParam: 'in',
isEnglish: true,
},
de: {
code: 'de',
name: 'Germany',
baseUrl: 'https://www.audible.de',
audnexusParam: 'de',
isEnglish: false,
}, },
}; };
@@ -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,14 +135,8 @@ 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,
updatedAt: 1700000100000,
media: {
duration: 3600,
coverPath: '/covers/1.jpg',
metadata: {
title: 'Title', title: 'Title',
authorName: 'Author', authorName: 'Author',
narratorName: 'Narrator', narratorName: 'Narrator',
@@ -86,9 +144,9 @@ describe('AudiobookshelfLibraryService', () => {
asin: 'ASIN1', asin: 'ASIN1',
isbn: 'ISBN1', isbn: 'ISBN1',
publishedYear: '2020', publishedYear: '2020',
}, duration: 3600,
}, coverPath: '/covers/1.jpg',
}, }),
]); ]);
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,
updatedAt: 1700000000000,
media: {
duration: 200,
metadata: {
title: 'Search Title', title: 'Search Title',
authorName: 'Search Author', authorName: 'Search Author',
narratorName: '', narratorName: '',
description: '', 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'); 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');
});
});
}); });