mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0e3c9c665 | |||
| 95e63dfc36 | |||
| 03371be81d | |||
| 4c1d1c89e8 | |||
| d25d93680e | |||
| 312421a96b |
@@ -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
@@ -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,12 +68,9 @@ 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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">·</span>
|
||||||
|
|
||||||
|
{/* Indexer / Source */}
|
||||||
|
{isAnnasArchive ? (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||||
|
) : (
|
||||||
|
<span>{result.indexer}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
{result.size > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||||
|
<span>{formatSize(result.size)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
{displayFormat && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600 select-none">·</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">·</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">·</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">·</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">·</span>
|
||||||
|
<span>{formatSize(confirmTorrent.size)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{confirmTorrent.format && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="uppercase font-medium">{confirmTorrent.format}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{confirmTorrent.protocol === 'usenet' ? (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span className="text-sky-600 dark:text-sky-400">NZB</span>
|
||||||
|
</>
|
||||||
|
) : confirmTorrent.seeders !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">·</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user