Compare commits

...

32 Commits

Author SHA1 Message Date
kikootwo b15a472bab Centralize download client timeout constant
Add DOWNLOAD_CLIENT_TIMEOUT (60000ms) in src/lib/constants/download-timeouts.ts and replace hardcoded 60000ms timeouts across Deluge, Prowlarr, qBittorrent and Transmission integrations. This centralizes the download/API timeout (gives headroom for indexers that enforce ~30s waits) and makes future adjustments easier without changing behavior.
2026-02-24 01:09:58 -05:00
kikootwo 3c680f2f38 Merge pull request #102 from Kikipeuk/ygg_timeout2
Extend the default timeout to add a torrent (Qbit, Transmission, Deluge)
2026-02-24 00:56:37 -05:00
kikootwo 16cd606421 Merge pull request #107 from kikootwo/feature-france-region
Feature france region
2026-02-24 00:53:01 -05:00
kikootwo 40d5363dc4 Fix French stopWords spacing and region name
Trim whitespace in the French stopWords array (add missing space after comma) to keep formatting consistent, and rename AUDIBLE_REGIONS.fr.name from "French" to "France" to better reflect the region label used for the Audible configuration.
2026-02-24 00:51:55 -05:00
kikootwo c138d8e642 Merge pull request #100 from Kikipeuk/french-traduction
Add French as Audible region
2026-02-24 00:40:50 -05:00
kikootwo 3d590b38cc Bump package version to 1.0.11
Update package.json version from 1.0.10 to 1.0.11 to mark a new patch release.
2026-02-24 00:20:15 -05:00
kikootwo aa7ba8a76d Remove legacy config API routes and tests
Delete legacy configuration API handlers and their tests. Removes src/app/api/config/route.ts (GET/PUT for config), src/app/api/config/[category]/route.ts (category GET), and tests/api/config.routes.test.ts. This cleans up deprecated/duplicated config endpoints and associated tests from the codebase.
2026-02-24 00:19:52 -05:00
root 328fd8392b ygg_timeout2 2026-02-21 14:30:51 +01:00
root 9a460f808d french-Traduction 2026-02-21 13:57:47 +01:00
root c60b6214ce French Traduction 2026-02-21 12:44:56 +01:00
root aff5faaa58 French Traduction 2026-02-21 11:43:06 +01:00
root c43ce7ba8f French Traduction 2026-02-21 11:40:48 +01:00
root f570b87343 French Traduction 2026-02-21 10:48:24 +01:00
root dfa7a11674 French Traduction 2026-02-21 10:43:49 +01:00
kikootwo 7a1a8ffa50 Bump package version to 1.0.10
Update package.json version from 1.0.9 to 1.0.10 to prepare a new release.
2026-02-20 20:44:47 -05:00
kikootwo d70f6c9957 Add Deluge integration; revamp admin Jobs & Logs UI
Introduce Deluge download client service and tests, remove obsolete rdtclient service, and update qbittorrent integration/tests and download-client interfaces/manager. Large UI refactor for admin pages: Jobs and Logs were redesigned to be responsive (mobile card views + desktop tables), improved headers, dialogs, controls, and better status/detail rendering. Also updated DownloadClient components (card, management, modal), organize-files processor, audible-series integration, and related unit tests to align with integration changes. Minor UX and accessibility tweaks, cron handling/validation adjustments, and a few formatting/cleanup fixes throughout.
2026-02-20 20:44:26 -05:00
kikootwo 04dbb05a6e Bump package version to 1.0.9
Update package.json version from 1.0.8 to 1.0.9 to mark a new patch release. No other changes in this diff.
2026-02-20 10:19:50 -05:00
kikootwo cb9f1b81bc Add series browsing, search, and detail UI
Introduce full support for Audible series exploration: API routes, frontend pages, components, hooks, and integrations. Key changes:

- Prisma: add Audiobook.seriesAsin for linking audiobooks to series detail pages.
- Backend: add /api/series/search and /api/series/[asin] routes that require auth; scrape Audible series data and enrich books with library availability.
- Integrations/services: add audible-series integration and update request/HTTP services to support the workflow.
- Frontend: add /series and /series/[asin] pages, new components (SeriesCard, SeriesGrid, SeriesDetailCard, SimilarSeriesRow) and wire them to a new useSeries hook; update AudiobookDetailsModal to show/link series; add Series link to Header.
- Misc: extend audiobook types with series fields and add seriesLabels to language-config for scraping.

These changes enable users to search for series, view series metadata and books, and navigate between audiobook and series detail pages.
2026-02-20 10:19:30 -05:00
kikootwo 5d8ac2f73d Add language config and locale-aware parsing
Introduce centralized language configuration and wire locale-aware behavior across scraping and ranking. Adds src/lib/constants/language-config.ts with per-language scraping rules, stop words, and character replacements; replaces AudibleRegion.isEnglish with a language field in types and AUDIBLE_REGIONS. Update AudibleService, ebook scraper, processors, and API routes to use getLanguageForRegion so Anna's Archive searches, scraping selectors, runtime/rating parsing, and ranking use language-specific params and filters. Extend ranking algorithm to accept stopWords and characterReplacements and apply them during normalization and matching. Update UI selects to mark non-English regions and adjust tests accordingly.
2026-02-20 06:32:44 -05:00
kikootwo c146383735 Don't coerce customPath to undefined
Pass sanitizedCustomPath through directly instead of using `sanitizedCustomPath || undefined`. The previous fallback converted falsy values (e.g. empty string) to undefined; this change preserves explicit empty or falsy values so downstream logic can distinguish them from undefined.
2026-02-18 17:51:35 -05:00
kikootwo 3820b9b21d Add DB pooling, throttling and monitor backoff
Add connection pool params to DATABASE_URL and configure Prisma to use the pooled URL (connection_limit=20, pool_timeout=30) to reduce connection exhaustion. Introduce safeguards and throttling across processors: limit in-flight progress DB updates in direct-download, add short delays when processing RSS, retry-failed-imports, and retry-missing-torrents, and stagger scheduler triggers to avoid bursts. Implement adaptive monitor-download polling with stallCount/lastProgress and exponential backoff, and thread these fields through JobQueueService (including reduced worker concurrency for several queues). Batch audiobook enrichment queries to small parallel batches to limit DB load. Update tests to reflect new monitor payload parameters. Overall intent: reduce DB connection pool pressure and smooth load spikes during startup and heavy processing.
2026-02-18 02:43:00 -05:00
kikootwo 20798b3dc0 Add RDT-Client support and Prowlarr prompt
Introduce RDT-Client integration and related UI/behavior changes.

- Add RDTClientService extending QBittorrentService with RDT-specific behavior (stale-torrent deletion, postProcess cleanup, no-op categories).
- Register 'rdtclient' in supported client types, display names, and protocol mapping; create RDT client factory in DownloadClientManager.
- Add RDT-Client card to DownloadClientManagement UI and placeholder URL in DownloadClientModal.
- Update qbittorrent service: omit per-torrent savepath/sequential options (favor category/automatic management), make several methods protected, and clean up related comments.
- Make organize-files.processor treat rdtclient as a special-case for cleanup (remove local torrent entries after organize).
- Add prowlarr service singleton invalidation and call it when Prowlarr settings are updated so background jobs pick up new credentials.
- Add confirmation flow when changing Prowlarr URL/API key: new useIndexersSettings logic to detect credential changes, prompt ConfirmModal from IndexersTab, and optionally clear configured indexers on confirmed change.

These changes ensure Real-Debrid-backed qBittorrent-compatible clients are supported correctly and that switching Prowlarr credentials is handled safely.
2026-02-17 17:03:21 -05:00
kikootwo 3f8180a246 Add server readiness check & init retries
Wait for the Next.js server and DB to be healthy before initializing services in docker/unified/app-start.sh. Adds a health probe with configurable timeout and retries, backoff retries for the /api/init call, improved logging, and error handling when the server process exits.

In src/lib/services/scheduler.service.ts, make re-encryption of notification backends non-fatal by catching and logging errors, and make creation of default scheduled jobs robust by creating each job independently with per-job error handling and logging. Summary counts are logged for created/failed defaults so failures don't block the scheduler from starting.
2026-02-13 14:03:21 -05:00
kikootwo c97df7798a Merge pull request #78 from gtronset/feature/unraid-template-v2
Update Unriad Template to fix permissions and fix `WebUI` link
2026-02-12 23:11:02 -05:00
Gavin Tronset c0096cda1a Update Unriad Template to fix permisions and WebUI link 2026-02-12 16:20:59 -08:00
kikootwo 98a2cc2813 Mock getBaseUrl in Audible service tests
Add a getBaseUrl mock to audibleServiceMock in audiobooks-browse route tests that returns 'https://www.audible.com'. This ensures tests have a defined base URL for Audible service calls and prevents issues caused by an undefined method during test execution.
2026-02-12 16:09:55 -05:00
kikootwo 4df49633b4 Bump package version to 1.0.8
Update package.json version from 1.0.7 to 1.0.8 to prepare a patch release.
2026-02-12 16:00:45 -05:00
kikootwo 6f0d71ee9b Detect external DB/Redis via flags; sanitize URLs
Improve entrypoint handling for external services and startup wrappers. entrypoint.sh now more robustly parses REDIS_URL (handles optional :password@host) and masks credentials when printing DATABASE_URL/REDIS_URL. It exports USE_EXTERNAL_POSTGRES and USE_EXTERNAL_REDIS so supervisor wrappers can decide behavior without re-parsing URLs. The temporary PostgreSQL shutdown was moved to after Prisma migrations and a warning was added when pushing schema to an external DB. postgres-start.sh and redis-start.sh were simplified to check the USE_EXTERNAL_* flags and sleep if an external service is configured. Also cleaned up formatting of the PostgreSQL ownership error message.
2026-02-12 15:59:09 -05:00
kikootwo a145dc9877 Merge pull request #75 from sudo-kraken/main
fix: Add support for external PostgreSQL and Redis instances
2026-02-12 15:24:37 -05:00
kikootwo 89422fc77a Add authors pages and requestType notifications
Introduce full authors browsing/detail feature and enhance notifications to support type-specific titles.

- Add server APIs: authors search, author detail, and author books routes (audnexus integration) that require auth and enrich results with library matches.
- Add frontend pages/components: /authors listing and /authors/[asin] detail pages; AuthorCard, AuthorGrid, AuthorDetailCard, SimilarAuthorsRow, and related skeletons.
- Add hook and integration stubs: new useAuthors hook and audnexus-authors integration; update audible service to expose audibleBaseUrl.
- Update AudiobookDetailsModal to use audibleBaseUrl and link author names to author detail pages.
- Add header navigation link to Authors.
- Notifications: extend docs and code to include requestType (audiobook|ebook), add getEventTitle/getEventMeta helpers, update queue signature and providers/processors/tests to pass/handle requestType so titles can be resolved per request type.
- Misc: job queue, processors, provider tests and notification tests updated to reflect new behavior.

This change enables browsing authors and provides type-aware notification titles without per-provider changes.
2026-02-12 15:21:42 -05:00
kikootwo e40e77c8fe Retry Audible search without series info
Try the full Goodreads title first; if no ASIN is found, strip trailing parenthetical series info (e.g. "(Series, #2)"), retry the Audible search with the cleaned title, and add informative logs. This fixes failed Audible lookups caused by Goodreads titles that include series metadata.
2026-02-12 11:13:06 -05:00
Joe Harrison 7addb1dc70 fix: Add support for external PostgreSQL and Redis instances
Implements smart detection that allows users to provide external DATABASE_URL
or REDIS_URL. When external services are detected, internal instances are
automatically disabled to save resources. Maintains full backward compatibility
with existing setup
2026-02-12 15:04:09 +00:00
112 changed files with 6260 additions and 1117 deletions
+18
View File
@@ -53,6 +53,24 @@ 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: External PostgreSQL and Redis
# ========================================================================
# To use external PostgreSQL or Redis instances instead of the internal ones,
# uncomment and configure the appropriate URL(s):
#
# External PostgreSQL example:
# DATABASE_URL: "postgresql://username:password@postgres.example.com:5432/readmeabook"
#
# External Redis example:
# REDIS_URL: "redis://redis.example.com:6379"
# REDIS_URL: "redis://:password@redis.example.com:6379" # With password
#
# Note: When using external services:
# - The internal PostgreSQL/Redis will NOT start (smart detection)
# - You do NOT need to mount ./pgdata or ./redis volumes
# - Ensure your external services are accessible from the container
# ======================================================================== # ========================================================================
# OPTIONAL: Rootless Podman Support # OPTIONAL: Rootless Podman Support
# ======================================================================== # ========================================================================
+66 -5
View File
@@ -53,14 +53,75 @@ start_server() {
start_server start_server
SERVER_PID=$! SERVER_PID=$!
echo "[App] Waiting for server to be ready..." # =============================================================================
sleep 5 # WAIT FOR SERVER READINESS
# =============================================================================
# The health endpoint (/api/health) checks both the Next.js server AND database
# connectivity. We must wait for both before initializing scheduled jobs.
HEALTH_URL="http://localhost:3030/api/health"
INIT_URL="http://localhost:3030/api/init"
READY_TIMEOUT=${APP_READY_TIMEOUT:-60}
INIT_RETRIES=${APP_INIT_RETRIES:-5}
echo "[App] Waiting for server to be ready (timeout: ${READY_TIMEOUT}s)..."
READY=false
for i in $(seq 1 "$READY_TIMEOUT"); do
# Check if the server process is still alive
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "[App] ERROR: Server process (PID $SERVER_PID) exited unexpectedly"
exit 1
fi
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
READY=true
echo "[App] Server is healthy (took ${i}s)"
break
fi
# Log progress every 10 seconds
if [ $((i % 10)) -eq 0 ]; then
echo "[App] Still waiting for server... (${i}/${READY_TIMEOUT}s)"
fi
sleep 1
done
if [ "$READY" = "false" ]; then
echo "[App] ERROR: Server did not become healthy within ${READY_TIMEOUT}s"
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
else
# =========================================================================
# INITIALIZE APPLICATION SERVICES
# =========================================================================
# Creates default scheduled jobs, runs credential migration, etc.
# Retry with backoff to handle transient failures during startup.
# Initialize application services (creates default scheduled jobs)
echo "[App] Initializing application services..." echo "[App] Initializing application services..."
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
echo "[App] Server ready with PID $SERVER_PID" INIT_SUCCESS=false
for attempt in $(seq 1 "$INIT_RETRIES"); do
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$INIT_URL" 2>/dev/null) || HTTP_CODE="000"
if [ "$HTTP_CODE" = "200" ]; then
INIT_SUCCESS=true
echo "[App] Services initialized successfully"
break
fi
echo "[App] Init attempt $attempt/$INIT_RETRIES failed (HTTP $HTTP_CODE), retrying in ${attempt}s..."
sleep "$attempt"
done
if [ "$INIT_SUCCESS" = "false" ]; then
echo "[App] ERROR: Failed to initialize services after $INIT_RETRIES attempts"
echo "[App] Scheduled jobs may be missing - check application logs for details"
fi
fi
echo "[App] Server running with PID $SERVER_PID"
# Verify the process is running with correct UID:GID (for debugging) # Verify the process is running with correct UID:GID (for debugging)
if [ -f "/proc/$SERVER_PID/status" ]; then if [ -f "/proc/$SERVER_PID/status" ]; then
+79 -7
View File
@@ -157,8 +157,38 @@ export PLEX_PRODUCT_NAME="${PLEX_PRODUCT_NAME:-ReadMeABook}"
export LOG_LEVEL="${LOG_LEVEL:-info}" export LOG_LEVEL="${LOG_LEVEL:-info}"
# ============================================================================ # ============================================================================
# INITIALIZE POSTGRESQL # DETECT EXTERNAL SERVICES
# ============================================================================ # ============================================================================
# Check if user provided external DATABASE_URL or REDIS_URL
USE_EXTERNAL_POSTGRES=false
USE_EXTERNAL_REDIS=false
if [ -n "$DATABASE_URL" ]; then
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
if [ "$DB_HOST" != "127.0.0.1" ] && [ "$DB_HOST" != "localhost" ]; then
USE_EXTERNAL_POSTGRES=true
echo "️ External PostgreSQL detected at $DB_HOST"
fi
fi
if [ -n "$REDIS_URL" ]; then
# Extract host from REDIS_URL - handles both redis://host:port and redis://:password@host:port
if echo "$REDIS_URL" | grep -q '@'; then
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
else
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|redis://\([^:/]*\).*|\1|p')
fi
if [ -n "$REDIS_HOST" ] && [ "$REDIS_HOST" != "127.0.0.1" ] && [ "$REDIS_HOST" != "localhost" ]; then
USE_EXTERNAL_REDIS=true
echo "️ External Redis detected at $REDIS_HOST"
fi
fi
# ============================================================================
# INITIALIZE POSTGRESQL (only if using internal PostgreSQL)
# ============================================================================
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "📦 Configuring internal PostgreSQL..."
PGDATA="/var/lib/postgresql/data" PGDATA="/var/lib/postgresql/data"
PG_WAS_EMPTY=0 PG_WAS_EMPTY=0
@@ -208,8 +238,12 @@ else
chmod 700 "$PGDATA" chmod 700 "$PGDATA"
chmod 775 /var/run/postgresql chmod 775 /var/run/postgresql
fi fi
else
echo "⏭️ Skipping internal PostgreSQL setup (using external database)"
fi
# Redis directory - owned by redis user (remapped to PUID:PGID if set) # Redis directory - owned by redis user (remapped to PUID:PGID if set)
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
echo "" echo ""
echo "❌ ERROR: Failed to set ownership on Redis directory" echo "❌ ERROR: Failed to set ownership on Redis directory"
@@ -218,6 +252,9 @@ if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
exit 1 exit 1
fi fi
chmod 770 /var/lib/redis chmod 770 /var/lib/redis
else
echo "⏭️ Skipping internal Redis setup (using external Redis)"
fi
# App directories - owned by node user (remapped to PUID:PGID if set) # App directories - owned by node user (remapped to PUID:PGID if set)
# These need group write permissions for shared access # These need group write permissions for shared access
@@ -232,6 +269,8 @@ chmod 775 /app/config /app/cache
echo "✅ Directory permissions configured" echo "✅ Directory permissions configured"
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
# Only initialize/setup PostgreSQL if using internal instance
if [ ! -f "$PGDATA/PG_VERSION" ]; then if [ ! -f "$PGDATA/PG_VERSION" ]; then
PG_WAS_EMPTY=1 PG_WAS_EMPTY=1
echo "📦 Initializing PostgreSQL database..." echo "📦 Initializing PostgreSQL database..."
@@ -259,9 +298,9 @@ else
echo "✅ PostgreSQL data directory already exists" echo "✅ PostgreSQL data directory already exists"
fi fi
# ============================================================================ # ========================================================================
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE # START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
# ============================================================================ # ========================================================================
echo "🔧 Starting PostgreSQL for setup..." echo "🔧 Starting PostgreSQL for setup..."
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'" su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'"
@@ -302,13 +341,30 @@ else
echo "✅ Database user and permissions verified" echo "✅ Database user and permissions verified"
fi fi
fi
# ============================================================================ # ============================================================================
# SET ENVIRONMENT VARIABLES FOR APP # SET ENVIRONMENT VARIABLES FOR APP
# ============================================================================ # ============================================================================
# Set DATABASE_URL and REDIS_URL based on whether we're using internal or external services
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
# URL-encode the password to handle special characters # URL-encode the password to handle special characters
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD") ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB" export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
echo "✅ Using internal PostgreSQL (127.0.0.1:5432)"
else
# DATABASE_URL already set by user - do not modify
echo "✅ Using external DATABASE_URL: $(echo "$DATABASE_URL" | sed 's|//.*@|//***@|')"
fi
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
export REDIS_URL="redis://127.0.0.1:6379" export REDIS_URL="redis://127.0.0.1:6379"
echo "✅ Using internal Redis (127.0.0.1:6379)"
else
# REDIS_URL already set by user - do not modify
echo "✅ Using external REDIS_URL: $(echo "$REDIS_URL" | sed 's|//.*@|//***@|')"
fi
export NODE_ENV="production" export NODE_ENV="production"
export PORT="3030" export PORT="3030"
export HOSTNAME="0.0.0.0" export HOSTNAME="0.0.0.0"
@@ -318,6 +374,8 @@ export HOSTNAME="0.0.0.0"
cat > /etc/environment <<EOF cat > /etc/environment <<EOF
DATABASE_URL=$DATABASE_URL DATABASE_URL=$DATABASE_URL
REDIS_URL=$REDIS_URL REDIS_URL=$REDIS_URL
USE_EXTERNAL_POSTGRES=$USE_EXTERNAL_POSTGRES
USE_EXTERNAL_REDIS=$USE_EXTERNAL_REDIS
JWT_SECRET=$JWT_SECRET JWT_SECRET=$JWT_SECRET
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
@@ -335,15 +393,21 @@ EOF
echo "✅ Environment configured" echo "✅ Environment configured"
# ============================================================================ # ============================================================================
# RUN PRISMA MIGRATIONS (while PostgreSQL is still running) # RUN PRISMA MIGRATIONS
# ============================================================================ # ============================================================================
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
echo "⚠️ Running schema sync against EXTERNAL database - prisma db push --accept-data-loss"
echo " This runs on every container start. Ensure your external database is backed up."
fi
echo "🔄 Running Prisma migrations..." echo "🔄 Running Prisma migrations..."
cd /app cd /app
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..." su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
# Stop PostgreSQL (supervisord will start it) # Stop internal PostgreSQL (supervisord will restart it via wrapper)
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "🔧 Stopping temporary PostgreSQL instance..." echo "🔧 Stopping temporary PostgreSQL instance..."
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast" su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
fi
# ============================================================================ # ============================================================================
# DISPLAY STARTUP INFO # DISPLAY STARTUP INFO
@@ -361,8 +425,16 @@ if [ "$POSTGRES_PASSWORD" = "$(generate_secret)" ]; then
fi fi
echo "" echo ""
echo "📊 Services starting:" echo "📊 Services starting:"
echo " - PostgreSQL (internal, user=postgres)" if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})" echo " - PostgreSQL (internal, 127.0.0.1:5432)"
else
echo " - PostgreSQL (external - local instance disabled)"
fi
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
echo " - Redis (internal, 127.0.0.1:6379, UID:GID=${PUID:-102}:${PGID:-102})"
else
echo " - Redis (external - local instance disabled)"
fi
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 [ "${ROOTLESS_CONTAINER}" = "true" ]; then if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
echo "" echo ""
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# PostgreSQL startup wrapper for unified container
# Checks USE_EXTERNAL_POSTGRES flag (set by entrypoint) to decide whether
# to start the local instance or sleep to keep supervisord happy.
set -e
# Load environment from /etc/environment (set by entrypoint)
if [ -f /etc/environment ]; then
set -a
source /etc/environment
set +a
fi
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
echo "[PostgreSQL] External database configured - skipping local instance"
exec sleep infinity
fi
echo "[PostgreSQL] Starting local PostgreSQL server..."
exec /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
+10 -1
View File
@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
# Redis startup wrapper for unified container # Redis startup wrapper for unified container
# Checks USE_EXTERNAL_REDIS flag (set by entrypoint) to decide whether
# to start the local instance or sleep to keep supervisord happy.
#
# Uses gosu to ensure correct PUID:PGID for file operations # Uses gosu to ensure correct PUID:PGID for file operations
# #
# Supports: # Supports:
@@ -15,11 +18,17 @@ if [ -f /etc/environment ]; then
set +a set +a
fi fi
if [ "$USE_EXTERNAL_REDIS" = "true" ]; then
echo "[Redis] External Redis configured - skipping local instance"
exec sleep infinity
fi
echo "[Redis] Starting local Redis server..."
# Get PUID/PGID (default to redis user's current IDs if not set) # Get PUID/PGID (default to redis user's current IDs if not set)
PUID=${PUID:-$(id -u redis)} PUID=${PUID:-$(id -u redis)}
PGID=${PGID:-$(id -g redis)} PGID=${PGID:-$(id -g redis)}
echo "[Redis] Starting Redis server..."
echo "[Redis] Process will run as UID:GID = $PUID:$PGID" echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
# ============================================================================= # =============================================================================
+1 -1
View File
@@ -7,7 +7,7 @@ loglevel=info
pidfile=/var/run/supervisord.pid pidfile=/var/run/supervisord.pid
[program:postgresql] [program:postgresql]
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data command=/app/postgres-start.sh
user=postgres user=postgres
autostart=true autostart=true
autorestart=true autorestart=true
+5
View File
@@ -115,6 +115,11 @@ COPY --chown=root:root docker/unified/redis-start.sh /app/redis-start.sh
# Convert line endings and make executable # Convert line endings and make executable
RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh
# Copy postgres startup wrapper
COPY --chown=root:root docker/unified/postgres-start.sh /app/postgres-start.sh
# Convert line endings and make executable
RUN sed -i 's/\r$//' /app/postgres-start.sh && chmod +x /app/postgres-start.sh
# Expose app port # Expose app port
EXPOSE 3030 EXPOSE 3030
@@ -33,10 +33,16 @@ model NotificationBackend {
|-------|---------|------------------------| |-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval | | request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_available | Plex/ABS scan completes | Audiobook available in library | | request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage | | request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook | | issue_reported | User reports issue | User reports problem with available audiobook |
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
- `request_available` + no requestType → "Request Available" (fallback)
- Use `getEventTitle(event, requestType?)` to resolve titles in providers
## Notification Triggers ## Notification Triggers
**Request Creation (POST /api/requests)** **Request Creation (POST /api/requests)**
@@ -60,10 +66,14 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved - Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification - Deny: No notification
**Request Available (processors: scan-plex, plex-recently-added)** **Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available - After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername) - Includes user info in query (plexUsername)
**Ebook Available (processor: organize-files)**
- After ebook `status: 'downloaded'` (terminal) → request_available (requestType: 'ebook')
- Ebooks don't transition to 'available' via Plex matching
**Request Error (processors: monitor-download, organize-files)** **Request Error (processors: monitor-download, organize-files)**
- After `status: 'failed'` or `status: 'warn'` update → request_error - After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload - Includes error message in payload
@@ -166,6 +176,7 @@ model NotificationBackend {
author: string, author: string,
userName: string, userName: string,
message?: string, message?: string,
requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
timestamp: Date timestamp: Date
} }
``` ```
@@ -174,7 +185,7 @@ model NotificationBackend {
- Calls NotificationService.sendNotification() - Calls NotificationService.sendNotification()
- Non-blocking error handling (logs but doesn't throw) - Non-blocking error handling (logs but doesn't throw)
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)` **Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?, requestType?)`
## Architecture ## Architecture
@@ -203,10 +214,15 @@ src/lib/services/notification/
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }` **ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }` **ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
**Helper functions:** **Helper functions (notification.service.ts):**
- `getRegisteredProviderTypes(): string[]` — all registered type keys - `getRegisteredProviderTypes(): string[]` — all registered type keys
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers - `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
**Helper functions (notification-events.ts):**
- `getEventMeta(event)` — raw event metadata (label, title, emoji, severity, priority)
- `getEventTitle(event, requestType?)` — resolved title (checks `titleByRequestType` first, falls back to `title`)
- `getEventLabel(event)` — human-readable label for UI
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only) **API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
## Extensibility ## Extensibility
@@ -221,10 +237,10 @@ src/lib/services/notification/
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata. No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
**Adding New Event (e.g., download_complete):** **Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum 1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
2. Add to event labels in UI 2. Optionally add `titleByRequestType` for type-specific titles
3. Add trigger point in processor 3. Add trigger point in processor, passing `requestType` if relevant
4. Add message formatting in Discord/Pushover formatters 4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
## Tech Stack ## Tech Stack
- Bull (job queue) - Bull (job queue)
+1
View File
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
- India (`in`) - `audible.in` (English) - India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English) - Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English) - Spain (`es`) - `audible.es` (non-English)
- French (`fr`) - `audible.fr` (non-English)
**`isEnglish` Flag:** **`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig` - Each region has `isEnglish: boolean` in `AudibleRegionConfig`
+1 -1
View File
@@ -243,7 +243,7 @@ type TorrentState =
- `forcedUP``seeding`/`completed` enables monitor to trigger import - `forcedUP``seeding`/`completed` enables monitor to trigger import
- `stoppedDL``paused` ensures qBittorrent v5.x compatibility - `stoppedDL``paused` ensures qBittorrent v5.x compatibility
**16. pausedUP/stoppedUP mapped as paused instead of completed** - RDT-Client (and qBittorrent after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by: **16. pausedUP/stoppedUP mapped as paused instead of completed** - qBittorrent (after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
- `pausedUP``seeding` (unified) / `completed` (legacy) — triggers completion in monitor - `pausedUP``seeding` (unified) / `completed` (legacy) — triggers completion in monitor
- `stoppedUP``seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x - `stoppedUP``seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
- `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused - `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
+1 -1
View File
@@ -271,7 +271,7 @@ src/app/admin/settings/
**PUT /api/admin/settings/audible** **PUT /api/admin/settings/audible**
- Updates Audible region - Updates Audible region
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es) - Body: `{ region: string }` (one of: us, ca, uk, au, in, es, fr)
- No validation required - No validation required
**PUT /api/admin/settings/prowlarr/indexers** **PUT /api/admin/settings/prowlarr/indexers**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.7", "version": "1.0.11",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1
View File
@@ -176,6 +176,7 @@ model Audiobook {
year Int? // Release year extracted from releaseDate year Int? // Release year extracted from releaseDate
series String? // Book series name (e.g., "The Mistborn Saga") series String? // Book series name (e.g., "The Mistborn Saga")
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1") seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
// Request tracking // Request tracking
status String @default("requested") // requested, downloading, processing, completed, failed status String @default("requested") // requested, downloading, processing, completed, failed
+178 -92
View File
@@ -78,13 +78,11 @@ function AdminJobsPageContent() {
const showEditDialog = (job: ScheduledJob) => { const showEditDialog = (job: ScheduledJob) => {
setEditForm({ schedule: job.schedule, enabled: job.enabled }); setEditForm({ schedule: job.schedule, enabled: job.enabled });
// Check if it's a preset
const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule); const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule);
if (preset) { if (preset) {
setScheduleMode('preset'); setScheduleMode('preset');
setSelectedPreset(preset.cron); setSelectedPreset(preset.cron);
} else { } else {
// Try to parse as custom schedule
const parsed = cronToCustomSchedule(job.schedule); const parsed = cronToCustomSchedule(job.schedule);
if (parsed.type === 'custom') { if (parsed.type === 'custom') {
setScheduleMode('advanced'); setScheduleMode('advanced');
@@ -111,7 +109,7 @@ function AdminJobsPageContent() {
method: 'POST', method: 'POST',
}); });
toast.success(`Job "${jobName}" triggered successfully`); toast.success(`Job "${jobName}" triggered successfully`);
fetchJobs(); // Refresh list fetchJobs();
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job'; const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job';
toast.error(errorMsg); toast.error(errorMsg);
@@ -124,7 +122,6 @@ function AdminJobsPageContent() {
const saveJobSchedule = async () => { const saveJobSchedule = async () => {
if (!editDialog.job) return; if (!editDialog.job) return;
// Calculate final cron expression based on mode
let finalCron: string; let finalCron: string;
if (scheduleMode === 'preset') { if (scheduleMode === 'preset') {
finalCron = selectedPreset; finalCron = selectedPreset;
@@ -134,7 +131,6 @@ function AdminJobsPageContent() {
finalCron = editForm.schedule; finalCron = editForm.schedule;
} }
// Validate cron expression
if (!isValidCron(finalCron)) { if (!isValidCron(finalCron)) {
toast.error('Invalid cron expression. Please check your schedule.'); toast.error('Invalid cron expression. Please check your schedule.');
return; return;
@@ -151,7 +147,7 @@ function AdminJobsPageContent() {
}); });
toast.success(`Job "${editDialog.job.name}" updated successfully`); toast.success(`Job "${editDialog.job.name}" updated successfully`);
hideEditDialog(); hideEditDialog();
fetchJobs(); // Refresh list fetchJobs();
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update job'; const errorMsg = err instanceof Error ? err.message : 'Failed to update job';
toast.error(errorMsg); toast.error(errorMsg);
@@ -173,36 +169,131 @@ function AdminJobsPageContent() {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800"> {/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
Scheduled Jobs Scheduled Jobs
</h1> </h1>
<p className="text-gray-600 dark:text-gray-400 mt-2"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage recurring tasks and automated jobs Manage recurring tasks and automated jobs
</p> </p>
</div> </div>
<Link <Link
href="/admin" href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
<span>Back to Dashboard</span> <span>Back to Dashboard</span>
</Link> </Link>
</div> </div>
</div>
{error && ( {error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"> <div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200">{error}</p> <p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
</div> </div>
)} )}
{/* Jobs Table */} {/* Jobs — Card layout on mobile, Table on sm+ */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> <div className="space-y-3 sm:hidden">
{jobs.map((job) => (
<div
key={job.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{job.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type}
</div>
</div>
<span
className={`flex-shrink-0 mt-0.5 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full ${
job.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{/* Card body */}
<div className="px-4 pb-3 space-y-2 border-t border-gray-100 dark:border-gray-700/60 pt-3">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Schedule
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronToHuman(job.schedule)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{job.schedule}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Run
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
</div>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60 flex gap-2">
<button
onClick={() => showEditDialog(job)}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
onClick={() => showConfirmDialog(job.id, job.name)}
disabled={triggering === job.id}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-400 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggering === job.id ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Running...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Trigger
</>
)}
</button>
</div>
</div>
))}
{jobs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
</div>
)}
</div>
{/* Jobs Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900"> <thead className="bg-gray-50 dark:bg-gray-900">
<tr> <tr>
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1"> <ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li> <li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
<li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li> <li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
<li> Trigger jobs manually using the "Trigger Now" button</li> <li> Trigger jobs manually using the &quot;Trigger Now&quot; button</li>
<li> Schedule format follows cron syntax (minute hour day month weekday)</li> <li> Schedule format follows cron syntax (minute hour day month weekday)</li>
</ul> </ul>
</div> </div>
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
{confirmDialog.isOpen && ( {confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"> <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white dark:bg-gray-800 rounded-2xl sm:rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Confirm Job Trigger Confirm Job Trigger
</h3> </h3>
<p className="text-gray-600 dark:text-gray-400 mb-6"> <p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
Are you sure you want to trigger &quot;{confirmDialog.jobName}&quot; now? Are you sure you want to trigger &quot;{confirmDialog.jobName}&quot; now?
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex gap-3">
<button <button
onClick={hideConfirmDialog} onClick={hideConfirmDialog}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors" className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={triggerJob} onClick={triggerJob}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors" className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium"
> >
Trigger Job Trigger Job
</button> </button>
@@ -347,12 +438,27 @@ function AdminJobsPageContent() {
{/* Edit Job Dialog */} {/* Edit Job Dialog */}
{editDialog.isOpen && editDialog.job && ( {editDialog.isOpen && editDialog.job && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"> <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"> <div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> {/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit Job Schedule Edit Job Schedule
</h3> </h3>
<div className="space-y-4 mb-6"> <button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-5" 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>
</div>
<div className="px-5 py-5 space-y-5">
{/* Job Name */} {/* Job Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -362,46 +468,29 @@ function AdminJobsPageContent() {
type="text" type="text"
value={editDialog.job.name} value={editDialog.job.name}
disabled disabled
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed" className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed text-sm"
/> />
</div> </div>
{/* Schedule Mode Tabs */} {/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Schedule Type Schedule Type
</label> </label>
<div className="flex gap-2 mb-3"> <div className="grid grid-cols-3 gap-1 p-1 bg-gray-100 dark:bg-gray-700/60 rounded-xl mb-4">
{(['preset', 'custom', 'advanced'] as const).map((mode) => (
<button <button
onClick={() => setScheduleMode('preset')} key={mode}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ onClick={() => setScheduleMode(mode)}
scheduleMode === 'preset' className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
? 'bg-blue-600 text-white' scheduleMode === mode
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`} }`}
> >
Common Schedules {mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
</button>
<button
onClick={() => setScheduleMode('custom')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'custom'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Custom Schedule
</button>
<button
onClick={() => setScheduleMode('advanced')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'advanced'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Advanced (Cron)
</button> </button>
))}
</div> </div>
{/* Preset Mode */} {/* Preset Mode */}
@@ -418,16 +507,16 @@ function AdminJobsPageContent() {
value={preset.cron} value={preset.cron}
checked={selectedPreset === preset.cron} checked={selectedPreset === preset.cron}
onChange={(e) => setSelectedPreset(e.target.value)} onChange={(e) => setSelectedPreset(e.target.value)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/> />
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{preset.label} {preset.label}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{preset.description} {preset.description}
</div> </div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1"> <div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{preset.cron} {preset.cron}
</div> </div>
</div> </div>
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
</label> </label>
<select <select
value={customSchedule.type} value={customSchedule.type}
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as any })} onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as CustomSchedule['type'] })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
> >
<option value="minutes">Every X minutes</option> <option value="minutes">Every X minutes</option>
<option value="hours">Every X hours</option> <option value="hours">Every X hours</option>
@@ -456,7 +545,6 @@ function AdminJobsPageContent() {
</select> </select>
</div> </div>
{/* Minutes/Hours Interval */}
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && ( {(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -468,7 +556,7 @@ function AdminJobsPageContent() {
max={customSchedule.type === 'minutes' ? 59 : 23} max={customSchedule.type === 'minutes' ? 59 : 23}
value={customSchedule.interval || 1} value={customSchedule.interval || 1}
onChange={(e) => setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })} onChange={(e) => setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Run every {customSchedule.interval || 1} {customSchedule.type} Run every {customSchedule.interval || 1} {customSchedule.type}
@@ -476,12 +564,11 @@ function AdminJobsPageContent() {
</div> </div>
)} )}
{/* Daily/Weekly/Monthly Time */}
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && ( {(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Hour (0-23) Hour (023)
</label> </label>
<input <input
type="number" type="number"
@@ -494,12 +581,12 @@ function AdminJobsPageContent() {
time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 }, time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 },
}) })
} }
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minute (0-59) Minute (059)
</label> </label>
<input <input
type="number" type="number"
@@ -512,13 +599,12 @@ function AdminJobsPageContent() {
time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) }, time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) },
}) })
} }
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/> />
</div> </div>
</div> </div>
)} )}
{/* Weekly Day Selection */}
{customSchedule.type === 'weekly' && ( {customSchedule.type === 'weekly' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -527,7 +613,7 @@ function AdminJobsPageContent() {
<select <select
value={customSchedule.dayOfWeek || 0} value={customSchedule.dayOfWeek || 0}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: parseInt(e.target.value, 10) })} onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: parseInt(e.target.value, 10) })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
> >
<option value="0">Sunday</option> <option value="0">Sunday</option>
<option value="1">Monday</option> <option value="1">Monday</option>
@@ -540,11 +626,10 @@ function AdminJobsPageContent() {
</div> </div>
)} )}
{/* Monthly Day Selection */}
{customSchedule.type === 'monthly' && ( {customSchedule.type === 'monthly' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Day of Month (1-31) Day of Month (131)
</label> </label>
<input <input
type="number" type="number"
@@ -552,12 +637,11 @@ function AdminJobsPageContent() {
max="31" max="31"
value={customSchedule.dayOfMonth || 1} value={customSchedule.dayOfMonth || 1}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })} onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
/> />
</div> </div>
)} )}
{/* Preview */}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"> <div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200"> <div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(customScheduleToCron(customSchedule))} Preview: {cronToHuman(customScheduleToCron(customSchedule))}
@@ -571,6 +655,7 @@ function AdminJobsPageContent() {
{/* Advanced Mode */} {/* Advanced Mode */}
{scheduleMode === 'advanced' && ( {scheduleMode === 'advanced' && (
<div className="space-y-3">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cron Expression Cron Expression
@@ -580,21 +665,22 @@ function AdminJobsPageContent() {
value={editForm.schedule} value={editForm.schedule}
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })} onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
placeholder="0 */6 * * *" placeholder="0 */6 * * *"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday Format: minute hour day month weekday
</p> </p>
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1"> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div> */15 * * * * = Every 15 minutes</div> <div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
<div> 0 */6 * * * = Every 6 hours</div> <div>*/15 * * * * = Every 15 minutes</div>
<div> 0 0 * * * = Daily at midnight</div> <div>0 */6 * * * = Every 6 hours</div>
<div> 0 0 * * 0 = Weekly on Sunday</div> <div>0 0 * * * = Daily at midnight</div>
<div>0 0 * * 0 = Weekly on Sunday</div>
</div> </div>
</div> </div>
{editForm.schedule && ( {editForm.schedule && (
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"> <div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200"> <div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(editForm.schedule)} Preview: {cronToHuman(editForm.schedule)}
</div> </div>
@@ -604,34 +690,34 @@ function AdminJobsPageContent() {
)} )}
</div> </div>
{/* Enabled Checkbox */} {/* Enabled toggle */}
<div className="flex items-center gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<input <input
type="checkbox" type="checkbox"
id="enabled" id="enabled"
checked={editForm.enabled} checked={editForm.enabled}
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })} onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/> />
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300"> <label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Enable this job Enable this job
</label> </label>
</div> </div>
</div> </div>
{/* Actions */} {/* Dialog footer */}
<div className="flex justify-end gap-3"> <div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button <button
onClick={hideEditDialog} onClick={hideEditDialog}
disabled={saving} disabled={saving}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 sm:flex-none px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={saveJobSchedule} onClick={saveJobSchedule}
disabled={saving} disabled={saving}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 sm:flex-none px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</button> </button>
+224 -136
View File
@@ -56,6 +56,119 @@ interface LogsData {
}; };
} }
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { dot: string; text: string; bg: string }> = {
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
};
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function LogDetails({ log }: { log: Log }) {
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-baseline">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
</div>
)}
{log.events.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Event Log
</h4>
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{log.events.map((event) => {
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
const levelColor = event.level === 'error'
? 'text-red-400'
: event.level === 'warn'
? 'text-amber-400'
: 'text-emerald-400';
return (
<div key={event.id} className="text-gray-300 leading-relaxed">
<span className={levelColor}>[{event.context}]</span>
{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Job Result
</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.errorMessage && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Error
</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
{log.errorMessage}
</div>
</div>
)}
</div>
);
}
function formatDuration(startedAt: string | null, completedAt: string | null) {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running…';
const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
function formatType(type: string) {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
function formatDateShort(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export default function AdminLogsPage() { export default function AdminLogsPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
@@ -65,9 +178,7 @@ export default function AdminLogsPage() {
const { data, error } = useSWR<LogsData>( const { data, error } = useSWR<LogsData>(
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`, `/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
authenticatedFetcher, authenticatedFetcher,
{ { refreshInterval: 10000 }
refreshInterval: 10000, // Refresh every 10 seconds
}
); );
const isLoading = !data && !error; const isLoading = !data && !error;
@@ -87,9 +198,7 @@ export default function AdminLogsPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200"> <h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
Error Loading Logs
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1"> <p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'} {error?.message || 'Failed to load system logs'}
</p> </p>
@@ -101,80 +210,45 @@ export default function AdminLogsPage() {
const logs = data?.logs || []; const logs = data?.logs || [];
const pagination = data?.pagination; const pagination = data?.pagination;
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'failed':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'active':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'delayed':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'stuck':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
}
};
const formatDuration = (startedAt: string | null, completedAt: string | null) => {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running...';
const start = new Date(startedAt).getTime();
const end = new Date(completedAt).getTime();
const durationMs = end - start;
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
};
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800"> {/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs System Logs
</h1> </h1>
<p className="text-gray-600 dark:text-gray-400 mt-2"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity View background jobs and system activity
</p> </p>
</div> </div>
<Link <Link
href="/admin" href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
<span>Back to Dashboard</span> <span>Back to Dashboard</span>
</Link> </Link>
</div> </div>
</div>
{/* Filters */} {/* Filters — full-width stacked on mobile */}
<div className="mb-6 flex flex-wrap gap-4"> <div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Status Status
</label> </label>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => { onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
setStatusFilter(e.target.value); className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
setPage(1);
}}
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
> >
<option value="all">All Statuses</option> <option value="all">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
@@ -186,16 +260,13 @@ export default function AdminLogsPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Job Type Job Type
</label> </label>
<select <select
value={typeFilter} value={typeFilter}
onChange={(e) => { onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
setTypeFilter(e.target.value); className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
setPage(1);
}}
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
> >
<option value="all">All Types</option> <option value="all">All Types</option>
<option value="search_indexers">Search Indexers</option> <option value="search_indexers">Search Indexers</option>
@@ -215,8 +286,77 @@ export default function AdminLogsPage() {
</div> </div>
</div> </div>
{/* Logs Table */} {/* Mobile card list — hidden on sm+ */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> <div className="space-y-3 sm:hidden">
{logs.map((log) => (
<div
key={log.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{formatType(log.type)}
</div>
<StatusBadge status={log.status} />
</div>
{/* Related item */}
{log.request?.audiobook ? (
<div className="text-sm mb-2">
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">
by {log.request.audiobook.author} &middot; {log.request.user.plexUsername}
</div>
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateShort(log.createdAt)}</span>
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{/* Expandable details */}
{hasDetails(log) && (
<>
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedLog === log.id && (
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
<LogDetails log={log} />
</div>
)}
</>
)}
</div>
))}
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Desktop table — hidden on mobile */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900"> <thead className="bg-gray-50 dark:bg-gray-900">
@@ -253,13 +393,11 @@ export default function AdminLogsPage() {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{log.type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())} {formatType(log.type)}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(log.status)}`}> <StatusBadge status={log.status} />
{log.status.toUpperCase()}
</span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{log.request?.audiobook ? ( {log.request?.audiobook ? (
@@ -285,7 +423,7 @@ export default function AdminLogsPage() {
{log.attempts}/{log.maxAttempts} {log.attempts}/{log.maxAttempts}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(log.events.length > 0 || log.errorMessage || log.bullJobId || log.result) && ( {hasDetails(log) && (
<button <button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)} onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300" className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
@@ -298,63 +436,7 @@ export default function AdminLogsPage() {
{expandedLog === log.id && ( {expandedLog === log.id && (
<tr> <tr>
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900"> <td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<div className="space-y-4"> <LogDetails log={log} />
{log.bullJobId && (
<div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bull Job ID: </span>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{log.bullJobId}</span>
</div>
)}
{/* Event Logs */}
{log.events.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Event Log</h4>
<div className="space-y-1 max-h-96 overflow-y-auto bg-black/5 dark:bg-black/30 rounded p-3 font-mono text-xs">
{log.events.map((event) => {
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
const levelColor = event.level === 'error'
? 'text-red-500'
: event.level === 'warn'
? 'text-yellow-500'
: 'text-green-500';
return (
<div key={event.id} className="text-gray-800 dark:text-gray-200">
<span className={levelColor}>[{event.context}]</span> {event.message}
<span className="text-gray-500 dark:text-gray-400 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-600 dark:text-gray-400 text-xs">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{/* Result Data */}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Job Result</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{/* Error Message */}
{log.errorMessage && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Error</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap">
{log.errorMessage}
</div>
</div>
)}
</div>
</td> </td>
</tr> </tr>
)} )}
@@ -373,24 +455,31 @@ export default function AdminLogsPage() {
{/* Pagination */} {/* Pagination */}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-between"> <div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs) Page {pagination.page} of {pagination.totalPages}
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 order-1 sm:order-2">
<button <button
onClick={() => setPage(page - 1)} onClick={() => setPage(page - 1)}
disabled={page === 1} disabled={page === 1}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Previous Previous
</button> </button>
<button <button
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
disabled={page === pagination.totalPages} disabled={page === pagination.totalPages}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
Next Next
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
</h3> </h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1"> <ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Logs are automatically refreshed every 10 seconds</li> <li> Logs are automatically refreshed every 10 seconds</li>
<li> Click "Show Details" to view detailed event logs, job results, and error messages</li> <li> Tap &quot;Show Details&quot; to view event logs, job results, and errors</li>
<li> Event logs show all internal operations with timestamps (similar to Docker logs)</li> <li> Event logs show all internal operations with timestamps</li>
<li> Jobs are retried automatically based on their max attempts setting</li> <li> Jobs are retried automatically based on their max attempts setting</li>
<li> Use filters to find specific job types or statuses</li> <li> Use filters to find specific job types or statuses</li>
<li> All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more</li>
</ul> </ul>
</div> </div>
</div> </div>
+1
View File
@@ -295,6 +295,7 @@ export default function AdminSettings() {
{activeTab === 'prowlarr' && ( {activeTab === 'prowlarr' && (
<IndexersTab <IndexersTab
settings={settings} settings={settings}
originalSettings={originalSettings}
indexers={configuredIndexers} indexers={configuredIndexers}
flagConfigs={flagConfigs} flagConfigs={flagConfigs}
onChange={setSettings} onChange={setSettings}
@@ -7,6 +7,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement'; import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
import { FlagConfigRow } from '@/components/admin/FlagConfigRow'; import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
interface IndexersTabProps { interface IndexersTabProps {
settings: Settings; settings: Settings;
originalSettings: Settings | null;
indexers: SavedIndexerConfig[]; indexers: SavedIndexerConfig[];
flagConfigs: IndexerFlagConfig[]; flagConfigs: IndexerFlagConfig[];
onChange: (settings: Settings) => void; onChange: (settings: Settings) => void;
@@ -27,6 +29,7 @@ interface IndexersTabProps {
export function IndexersTab({ export function IndexersTab({
settings, settings,
originalSettings,
indexers, indexers,
flagConfigs, flagConfigs,
onChange, onChange,
@@ -35,11 +38,23 @@ export function IndexersTab({
onValidationChange, onValidationChange,
onRefreshIndexers, onRefreshIndexers,
}: IndexersTabProps) { }: IndexersTabProps) {
const { testing, testResult, testConnection } = useIndexersSettings({ const {
testing,
testResult,
testConnection,
showConnectionChangeConfirm,
confirmConnectionChange,
cancelConnectionChange,
configuredIndexersCount,
} = useIndexersSettings({
prowlarrUrl: settings.prowlarr.url, prowlarrUrl: settings.prowlarr.url,
prowlarrApiKey: settings.prowlarr.apiKey, prowlarrApiKey: settings.prowlarr.apiKey,
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
configuredIndexersCount: indexers.length,
onValidationChange, onValidationChange,
onRefreshIndexers, onRefreshIndexers,
onClearIndexers: () => onIndexersChange([]),
}); });
// Auto-load indexers when component mounts if prowlarr is configured // Auto-load indexers when component mounts if prowlarr is configured
@@ -96,7 +111,7 @@ export function IndexersTab({
placeholder="Enter API key" placeholder="Enter API key"
/> />
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Found in Prowlarr Settings General Security API Key Found in Prowlarr Settings &rarr; General &rarr; Security &rarr; API Key
</p> </p>
</div> </div>
@@ -178,6 +193,19 @@ export function IndexersTab({
</p> </p>
)} )}
</div> </div>
{/* Confirmation modal for Prowlarr connection change */}
<ConfirmModal
isOpen={showConnectionChangeConfirm}
onClose={cancelConnectionChange}
onConfirm={confirmConnectionChange}
title="Prowlarr Connection Change"
message={`Changing your Prowlarr connection will remove your ${configuredIndexersCount} configured indexer${configuredIndexersCount === 1 ? '' : 's'}. Indexer IDs are specific to each Prowlarr instance, so existing configurations cannot be preserved. You will need to re-add indexers from the new instance after saving.`}
confirmText="Continue"
cancelText="Cancel"
variant="danger"
isLoading={testing}
/>
</div> </div>
); );
} }
@@ -5,30 +5,50 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import type { TestResult } from '../../lib/types'; import type { TestResult } from '../../lib/types';
interface UseIndexersSettingsProps { interface UseIndexersSettingsProps {
prowlarrUrl: string; prowlarrUrl: string;
prowlarrApiKey: string; prowlarrApiKey: string;
originalProwlarrUrl: string;
originalProwlarrApiKey: string;
configuredIndexersCount: number;
onValidationChange: (isValid: boolean) => void; onValidationChange: (isValid: boolean) => void;
onRefreshIndexers?: () => Promise<void>; onRefreshIndexers?: () => Promise<void>;
onClearIndexers: () => void;
} }
export function useIndexersSettings({ export function useIndexersSettings({
prowlarrUrl, prowlarrUrl,
prowlarrApiKey, prowlarrApiKey,
originalProwlarrUrl,
originalProwlarrApiKey,
configuredIndexersCount,
onValidationChange, onValidationChange,
onRefreshIndexers, onRefreshIndexers,
onClearIndexers,
}: UseIndexersSettingsProps) { }: UseIndexersSettingsProps) {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null); const [testResult, setTestResult] = useState<TestResult | null>(null);
const [showConnectionChangeConfirm, setShowConnectionChangeConfirm] = useState(false);
/** /**
* Test Prowlarr connection * Detect if the Prowlarr URL or API key has changed from the saved values.
* A masked API key (starting with dots) means the user hasn't touched it.
*/ */
const testConnection = async () => { const hasConnectionChanged = useCallback((): boolean => {
const urlChanged = prowlarrUrl.trim() !== originalProwlarrUrl.trim();
const apiKeyChanged = !prowlarrApiKey.startsWith('••••') &&
prowlarrApiKey !== originalProwlarrApiKey;
return urlChanged || apiKeyChanged;
}, [prowlarrUrl, prowlarrApiKey, originalProwlarrUrl, originalProwlarrApiKey]);
/**
* Execute the actual Prowlarr connection test
*/
const executeTest = async (shouldClearIndexers: boolean) => {
setTesting(true); setTesting(true);
setTestResult(null); setTestResult(null);
@@ -46,6 +66,14 @@ export function useIndexersSettings({
if (data.success) { if (data.success) {
onValidationChange(true); onValidationChange(true);
if (shouldClearIndexers) {
onClearIndexers();
setTestResult({
success: true,
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. Previous indexer configurations have been removed — please re-add indexers from the new instance.`,
});
} else {
setTestResult({ setTestResult({
success: true, success: true,
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`, message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
@@ -55,6 +83,7 @@ export function useIndexersSettings({
if (onRefreshIndexers) { if (onRefreshIndexers) {
await onRefreshIndexers(); await onRefreshIndexers();
} }
}
} else { } else {
onValidationChange(false); onValidationChange(false);
setTestResult({ setTestResult({
@@ -74,9 +103,41 @@ export function useIndexersSettings({
} }
}; };
/**
* Handle test connection click — shows confirmation if credentials changed
* and there are existing configured indexers.
*/
const testConnection = async () => {
if (hasConnectionChanged() && configuredIndexersCount > 0) {
setShowConnectionChangeConfirm(true);
return;
}
await executeTest(false);
};
/**
* User confirmed the credential change — proceed with test and clear indexers on success
*/
const confirmConnectionChange = async () => {
setShowConnectionChangeConfirm(false);
await executeTest(true);
};
/**
* User cancelled the credential change confirmation
*/
const cancelConnectionChange = () => {
setShowConnectionChangeConfirm(false);
};
return { return {
testing, testing,
testResult, testResult,
testConnection, testConnection,
showConnectionChangeConfirm,
confirmConnectionChange,
cancelConnectionChange,
configuredIndexersCount,
}; };
} }
@@ -164,11 +164,11 @@ export function AudiobookshelfSection({
> >
{Object.values(AUDIBLE_REGIONS).map((region) => ( {Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}> <option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''} {region.name}{region.language !== 'en' ? ' *' : ''}
</option> </option>
))} ))}
</select> </select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && ( {AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
<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="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"> <div className="flex gap-3">
<svg <svg
@@ -164,11 +164,11 @@ export function PlexSection({
> >
{Object.values(AUDIBLE_REGIONS).map((region) => ( {Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}> <option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''} {region.name}{region.language !== 'en' ? ' *' : ''}
</option> </option>
))} ))}
</select> </select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && ( {AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
<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="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"> <div className="flex gap-3">
<svg <svg
+339 -218
View File
@@ -41,6 +41,144 @@ interface PendingUser {
createdAt: string; createdAt: string;
} }
// Tinted-dot status badge following admin design system
function RoleBadge({ role, isSetupAdmin }: { role: 'user' | 'admin'; isSetupAdmin: boolean }) {
if (isSetupAdmin) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-700 dark:text-blue-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-blue-500" />
Setup Admin
</span>
);
}
if (role === 'admin') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700 dark:text-purple-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-purple-500" />
Admin
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-400" />
User
</span>
);
}
function PermissionBadge({
user,
globalAutoApprove,
onClick,
}: {
user: User;
globalAutoApprove: boolean;
onClick: () => void;
}) {
let badge: React.ReactNode;
if (user.role === 'admin') {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Full Access
</span>
);
} else if (globalAutoApprove) {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-700 dark:text-blue-400">
Global Default
</span>
);
} else if (user.autoApproveRequests ?? false) {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
Auto-Approve
</span>
);
} else {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-500/10 text-gray-600 dark:text-gray-400">
Manual
</span>
);
}
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 text-sm transition-opacity hover:opacity-70"
>
{badge}
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
);
}
function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) {
if (user.isSetupAdmin) {
return (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Protected</span>
</span>
);
}
if (user.authProvider === 'oidc') {
return (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>OIDC Managed</span>
</span>
);
}
if (user.authProvider === 'local') {
return (
<div className="flex items-center gap-3">
<button
onClick={() => onEdit(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => onDelete(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</div>
);
}
// plex or other
return (
<button
onClick={() => onEdit(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
);
}
function AdminUsersPageContent() { function AdminUsersPageContent() {
const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher); const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher);
const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR( const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR(
@@ -86,7 +224,6 @@ function AdminUsersPageContent() {
if (globalAutoApproveData?.autoApproveRequests !== undefined) { if (globalAutoApproveData?.autoApproveRequests !== undefined) {
setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests); setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests);
} else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) { } else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) {
// API returned but no value - default to true
setGlobalAutoApprove(true); setGlobalAutoApprove(true);
} }
}, [globalAutoApproveData]); }, [globalAutoApproveData]);
@@ -101,9 +238,7 @@ function AdminUsersPageContent() {
}, [globalInteractiveSearchData]); }, [globalInteractiveSearchData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => { const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalAutoApprove(newValue); setGlobalAutoApprove(newValue);
try { try {
await fetchJSON('/api/admin/settings/auto-approve', { await fetchJSON('/api/admin/settings/auto-approve', {
method: 'PATCH', method: 'PATCH',
@@ -111,20 +246,16 @@ function AdminUsersPageContent() {
}); });
toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`); toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalAutoApprove(); mutateGlobalAutoApprove();
mutate(); // Refresh users list to show updated state mutate();
} catch (err) { } catch (err) {
// Revert on error
setGlobalAutoApprove(!newValue); setGlobalAutoApprove(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting'; const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting';
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} }
}; };
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => { const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalInteractiveSearch(newValue); setGlobalInteractiveSearch(newValue);
try { try {
await fetchJSON('/api/admin/settings/interactive-search', { await fetchJSON('/api/admin/settings/interactive-search', {
method: 'PATCH', method: 'PATCH',
@@ -132,74 +263,51 @@ function AdminUsersPageContent() {
}); });
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`); toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalInteractiveSearch(); mutateGlobalInteractiveSearch();
mutate(); // Refresh users list to show updated state mutate();
} catch (err) { } catch (err) {
// Revert on error
setGlobalInteractiveSearch(!newValue); setGlobalInteractiveSearch(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting'; const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} }
}; };
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => { const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
// Optimistic update
const previousUsers = data?.users || []; const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) => const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, autoApproveRequests: newValue } : u u.id === user.id ? { ...u, autoApproveRequests: newValue } : u
); );
console.log('[AutoApprove] Applying optimistic update');
mutate({ users: optimisticUsers }, false); mutate({ users: optimisticUsers }, false);
try { try {
console.log('[AutoApprove] Sending API request...'); await fetchJSON(`/api/admin/users/${user.id}`, {
const response = await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({ role: user.role, autoApproveRequests: newValue }),
role: user.role,
autoApproveRequests: newValue
}),
}); });
console.log('[AutoApprove] API response received:', response);
toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
console.log('[AutoApprove] Triggering cache revalidation...'); mutate();
mutate(); // Refresh users list
} catch (err) { } catch (err) {
// Revert on error
console.error('[AutoApprove] Error occurred, reverting:', err);
mutate({ users: previousUsers }, false); mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting'; const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting';
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} }
}; };
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => { const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
// Optimistic update
const previousUsers = data?.users || []; const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) => const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
); );
mutate({ users: optimisticUsers }, false); mutate({ users: optimisticUsers }, false);
try { try {
await fetchJSON(`/api/admin/users/${user.id}`, { await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({ role: user.role, interactiveSearchAccess: newValue }),
role: user.role,
interactiveSearchAccess: newValue
}),
}); });
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate(); // Refresh users list mutate();
} catch (err) { } catch (err) {
// Revert on error
mutate({ users: previousUsers }, false); mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting'; const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} }
}; };
@@ -214,7 +322,6 @@ function AdminUsersPageContent() {
const saveUserRole = async () => { const saveUserRole = async () => {
if (!editDialog.user) return; if (!editDialog.user) return;
try { try {
setSaving(true); setSaving(true);
await fetchJSON(`/api/admin/users/${editDialog.user.id}`, { await fetchJSON(`/api/admin/users/${editDialog.user.id}`, {
@@ -223,11 +330,10 @@ function AdminUsersPageContent() {
}); });
toast.success(`User "${editDialog.user.plexUsername}" updated successfully`); toast.success(`User "${editDialog.user.plexUsername}" updated successfully`);
hideEditDialog(); hideEditDialog();
mutate(); // Refresh users list mutate();
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update user'; const errorMsg = err instanceof Error ? err.message : 'Failed to update user';
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -242,13 +348,12 @@ function AdminUsersPageContent() {
}; };
const closeConfirmDialog = () => { const closeConfirmDialog = () => {
if (processingUserId) return; // Don't close while processing if (processingUserId) return;
setConfirmDialog({ isOpen: false, type: null, user: null }); setConfirmDialog({ isOpen: false, type: null, user: null });
}; };
const handleConfirmAction = async () => { const handleConfirmAction = async () => {
if (!confirmDialog.user) return; if (!confirmDialog.user) return;
const isApprove = confirmDialog.type === 'approve'; const isApprove = confirmDialog.type === 'approve';
try { try {
setProcessingUserId(confirmDialog.user.id); setProcessingUserId(confirmDialog.user.id);
@@ -261,13 +366,12 @@ function AdminUsersPageContent() {
? `User "${confirmDialog.user.plexUsername}" has been approved` ? `User "${confirmDialog.user.plexUsername}" has been approved`
: `User "${confirmDialog.user.plexUsername}" has been rejected` : `User "${confirmDialog.user.plexUsername}" has been rejected`
); );
mutatePending(); // Refresh pending users list mutatePending();
if (isApprove) mutate(); // Refresh approved users list if (isApprove) mutate();
closeConfirmDialog(); closeConfirmDialog();
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`; const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`;
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} finally { } finally {
setProcessingUserId(null); setProcessingUserId(null);
} }
@@ -278,25 +382,23 @@ function AdminUsersPageContent() {
}; };
const closeDeleteDialog = () => { const closeDeleteDialog = () => {
if (deleting) return; // Don't close while processing if (deleting) return;
setDeleteDialog({ isOpen: false, user: null }); setDeleteDialog({ isOpen: false, user: null });
}; };
const handleDeleteUser = async () => { const handleDeleteUser = async () => {
if (!deleteDialog.user) return; if (!deleteDialog.user) return;
try { try {
setDeleting(true); setDeleting(true);
const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, { const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, {
method: 'DELETE', method: 'DELETE',
}); });
toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`); toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`);
mutate(); // Refresh users list mutate();
closeDeleteDialog(); closeDeleteDialog();
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to delete user'; const errorMsg = err instanceof Error ? err.message : 'Failed to delete user';
toast.error(errorMsg); toast.error(errorMsg);
console.error(err);
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -307,7 +409,6 @@ function AdminUsersPageContent() {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`); toast.success(`${label} copied to clipboard`);
} catch (err) { } catch (err) {
console.error('Failed to copy to clipboard:', err);
toast.error('Failed to copy to clipboard'); toast.error('Failed to copy to clipboard');
} }
}; };
@@ -327,9 +428,7 @@ function AdminUsersPageContent() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200"> <h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Users</h3>
Error Loading Users
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1"> <p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load users'} {error?.message || 'Failed to load users'}
</p> </p>
@@ -344,80 +443,81 @@ function AdminUsersPageContent() {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800"> {/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
User Management User Management
</h1> </h1>
<p className="text-gray-600 dark:text-gray-400 mt-2"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage user roles and permissions Manage user roles and permissions
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 self-start sm:self-auto flex-shrink-0">
<button <button
onClick={() => setGlobalSettingsOpen(true)} onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors" className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
<span>Global User Permissions</span> <span className="hidden sm:inline">Global User Permissions</span>
<span className="sm:hidden">Permissions</span>
</button> </button>
<Link <Link
href="/admin" href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
<span>Back to Dashboard</span> <span>Back</span>
</Link> </Link>
</div> </div>
</div> </div>
</div>
{/* Pending Users Section */} {/* Pending Users Section */}
{pendingUsers.length > 0 && ( {pendingUsers.length > 0 && (
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4"> <div className="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/60 rounded-xl p-4">
<h2 className="text-lg font-semibold text-yellow-900 dark:text-yellow-200 mb-4 flex items-center gap-2"> <h2 className="text-base font-semibold text-amber-900 dark:text-amber-200 mb-1 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg> </svg>
Pending Registrations ({pendingUsers.length}) Pending Registrations ({pendingUsers.length})
</h2> </h2>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-4"> <p className="text-xs text-amber-700 dark:text-amber-300/80 mb-4">
The following users are awaiting approval to access the system. The following users are awaiting approval to access the system.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
{pendingUsers.map((user) => ( {pendingUsers.map((user) => (
<div <div
key={user.id} key={user.id}
className="bg-white dark:bg-gray-800 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 flex items-center justify-between" className="bg-white dark:bg-gray-800 border border-amber-200 dark:border-amber-800/40 rounded-xl overflow-hidden"
> >
<div className="flex-1"> {/* Pending card — info */}
<div className="flex items-center gap-3"> <div className="px-4 py-3">
<div> <div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
<div className="font-medium text-gray-900 dark:text-gray-100">
{user.plexUsername} {user.plexUsername}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'} {user.plexEmail || 'No email'}
</div> </div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1"> <div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Registered: {new Date(user.createdAt).toLocaleString()} Registered: {new Date(user.createdAt).toLocaleString()} &middot; Provider: {user.authProvider}
Provider: {user.authProvider}
</div> </div>
</div> </div>
</div> {/* Pending card — actions, full-width on mobile */}
</div> <div className="px-4 py-3 border-t border-amber-100 dark:border-amber-800/30 flex gap-2">
<div className="flex items-center gap-2">
<button <button
onClick={() => showApproveDialog(user)} onClick={() => showApproveDialog(user)}
disabled={processingUserId === user.id} disabled={processingUserId === user.id}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -427,7 +527,7 @@ function AdminUsersPageContent() {
<button <button
onClick={() => showRejectDialog(user)} onClick={() => showRejectDialog(user)}
disabled={processingUserId === user.id} disabled={processingUserId === user.id}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-red-50 dark:bg-red-500/10 hover:bg-red-100 dark:hover:bg-red-500/20 text-red-700 dark:text-red-400 border border-red-200/60 dark:border-red-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -442,8 +542,104 @@ function AdminUsersPageContent() {
</div> </div>
)} )}
{/* Users Table */} {/* Users — Mobile card list (sm:hidden) */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto"> <div className="space-y-3 sm:hidden">
{users.map((user) => (
<div
key={user.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header — avatar + name + role badge */}
<div className="px-4 py-3 flex items-start gap-3">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5"
/>
) : (
<div className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug truncate">
{user.plexUsername}
</div>
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{user.plexEmail || 'No email'}
</div>
</div>
</div>
{/* Card body — labeled fields */}
<div className="px-4 pb-3 pt-2 space-y-2 border-t border-gray-100 dark:border-gray-700/60">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Permissions
</div>
<PermissionBadge
user={user}
globalAutoApprove={globalAutoApprove}
onClick={() => setPermissionsUserId(user.id)}
/>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Requests
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{user._count.requests}
</div>
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Login
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
User ID
</div>
<button
onClick={() => copyToClipboard(user.plexId, 'User ID')}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{user.plexId.length > 16 ? `${user.plexId.substring(0, 16)}` : user.plexId}
</button>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60">
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
</div>
</div>
))}
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No users found</p>
</div>
)}
</div>
{/* Users Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900"> <thead className="bg-gray-50 dark:bg-gray-900">
<tr> <tr>
@@ -472,15 +668,21 @@ function AdminUsersPageContent() {
</thead> </thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => ( {users.map((user) => (
<tr key={user.id}> <tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
{user.avatarUrl && ( {user.avatarUrl ? (
<img <img
src={user.avatarUrl} src={user.avatarUrl}
alt={user.plexUsername} alt={user.plexUsername}
className="h-10 w-10 rounded-full mr-3" className="h-10 w-10 rounded-full mr-3 flex-shrink-0"
/> />
) : (
<div className="h-10 w-10 rounded-full mr-3 flex-shrink-0 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)} )}
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -507,52 +709,14 @@ function AdminUsersPageContent() {
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2"> <RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{user.role.toUpperCase()}
</span>
{user.isSetupAdmin && (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
SETUP ADMIN
</span>
)}
</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<button <PermissionBadge
user={user}
globalAutoApprove={globalAutoApprove}
onClick={() => setPermissionsUserId(user.id)} onClick={() => setPermissionsUserId(user.id)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" />
>
{user.role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Full Access
</span>
) : globalAutoApprove ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
Global Default
</span>
) : (user.autoApproveRequests ?? false) ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Auto-Approve
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Manual
</span>
)}
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user._count.requests} {user._count.requests}
@@ -563,65 +727,7 @@ function AdminUsersPageContent() {
: 'Never'} : 'Never'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-3"> <UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
{user.isSetupAdmin ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Protected</span>
</span>
) : user.authProvider === 'oidc' ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>OIDC Managed</span>
</span>
) : user.authProvider === 'plex' ? (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
) : user.authProvider === 'local' ? (
<>
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => showDeleteDialog(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</>
) : (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
)}
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -643,31 +749,50 @@ function AdminUsersPageContent() {
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1"> <ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li> <li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
<li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li> <li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
<li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li> <li> <strong>Setup Admin:</strong> The initial admin account protected, cannot be changed or deleted</li>
<li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li> <li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li> <li> <strong>OIDC Users:</strong> Role management is handled by the identity provider. Cannot be deleted.</li>
<li> <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li> <li> <strong>Plex Users:</strong> Role can be changed, but cannot be deleted (access managed by Plex).</li>
<li> <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li> <li> <strong>Local Users:</strong> Can have roles freely assigned. Can be deleted (requests are preserved).</li>
<li> You cannot change your own role or delete yourself for security reasons</li> <li> You cannot change your own role or delete yourself for security reasons</li>
</ul> </ul>
</div> </div>
{/* Edit User Dialog */} {/* Edit User Dialog — bottom sheet on mobile */}
{editDialog.isOpen && editDialog.user && ( {editDialog.isOpen && editDialog.user && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"> <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-md">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> {/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit User Role Edit User Role
</h3> </h3>
<div className="space-y-4 mb-6"> <button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-5" 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>
<div className="px-5 py-5 space-y-4">
{/* User Info */} {/* User Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> <div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
{editDialog.user.avatarUrl && ( {editDialog.user.avatarUrl ? (
<img <img
src={editDialog.user.avatarUrl} src={editDialog.user.avatarUrl}
alt={editDialog.user.plexUsername} alt={editDialog.user.plexUsername}
className="h-12 w-12 rounded-full" className="h-12 w-12 rounded-full flex-shrink-0"
/> />
) : (
<div className="h-12 w-12 rounded-full flex-shrink-0 bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)} )}
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -685,38 +810,34 @@ function AdminUsersPageContent() {
Role Role
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"> <label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<input <input
type="radio" type="radio"
name="role" name="role"
value="user" value="user"
checked={editRole === 'user'} checked={editRole === 'user'}
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')} onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/> />
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">User</div>
User <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Can request audiobooks and view own requests Can request audiobooks and view own requests
</div> </div>
</div> </div>
</label> </label>
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"> <label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<input <input
type="radio" type="radio"
name="role" name="role"
value="admin" value="admin"
checked={editRole === 'admin'} checked={editRole === 'admin'}
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')} onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/> />
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">Admin</div>
Admin <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Full system access including settings and user management Full system access including settings and user management
</div> </div>
</div> </div>
@@ -725,19 +846,19 @@ function AdminUsersPageContent() {
</div> </div>
</div> </div>
{/* Actions */} {/* Dialog footer */}
<div className="flex justify-end gap-3"> <div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button <button
onClick={hideEditDialog} onClick={hideEditDialog}
disabled={saving} disabled={saving}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={saveUserRole} onClick={saveUserRole}
disabled={saving} disabled={saving}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</button> </button>
@@ -68,6 +68,7 @@ export async function POST(request: NextRequest) {
title: "The Hitchhiker's Guide to the Galaxy", title: "The Hitchhiker's Guide to the Galaxy",
author: 'Douglas Adams', author: 'Douglas Adams',
userName: 'Test User', userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date(), timestamp: new Date(),
}; };
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getEncryptionService } from '@/lib/services/encryption.service'; import { getEncryptionService } from '@/lib/services/encryption.service';
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr'); const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
@@ -42,6 +43,9 @@ export async function PUT(request: NextRequest) {
}); });
} }
// Invalidate cached singleton so background jobs use new credentials
invalidateProwlarrService();
logger.info('Prowlarr settings updated'); logger.info('Prowlarr settings updated');
return NextResponse.json({ return NextResponse.json({
@@ -18,6 +18,8 @@ import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { import {
searchByAsin, searchByAsin,
searchByTitle, searchByTitle,
@@ -227,6 +229,11 @@ export async function POST(
const format = preferredFormat || 'epub'; const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li'; const annasBaseUrl = baseUrl || 'https://annas-archive.li';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json( return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
@@ -250,7 +257,8 @@ export async function POST(
audiobook.author, audiobook.author,
format, format,
annasBaseUrl, annasBaseUrl,
flaresolverrUrl || undefined flaresolverrUrl || undefined,
languageCode
).catch((err) => { ).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`); logger.error(`Anna's Archive search failed: ${err.message}`);
return null; return null;
@@ -322,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
author: string, author: string,
preferredFormat: string, preferredFormat: string,
baseUrl: string, baseUrl: string,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookSearchResult[]> { ): Promise<EbookSearchResult[]> {
let md5: string | null = null; let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title'; let searchMethod: 'asin' | 'title' = 'title';
@@ -330,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
// Try ASIN search first // Try ASIN search first
if (asin) { if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`); logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
searchMethod = 'asin'; searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`); logger.info(`Found via ASIN: ${md5}`);
@@ -340,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
// Fallback to title search // Fallback to title search
if (!md5) { if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`); logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via title: ${md5}`); logger.info(`Found via title: ${md5}`);
} }
@@ -461,6 +470,10 @@ async function searchIndexersForInteractive(
return []; return [];
} }
// Get language-specific stop words for ranking
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
const rankLangConfig = getLanguageForRegion(rankRegion);
// Rank results with ebook scoring // Rank results with ebook scoring
const rankedResults = rankEbookTorrents(allResults, { const rankedResults = rankEbookTorrents(allResults, {
title, title,
@@ -470,6 +483,8 @@ async function searchIndexersForInteractive(
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false, requireAuthor: false,
stopWords: rankLangConfig.stopWords,
characterReplacements: rankLangConfig.characterReplacements,
}); });
// Convert to unified result type // Convert to unified result type
+1
View File
@@ -46,6 +46,7 @@ export async function GET(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
audiobook, audiobook,
audibleBaseUrl: audibleService.getBaseUrl(),
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
@@ -10,6 +10,8 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { z } from 'zod'; import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
@@ -140,13 +142,19 @@ export async function POST(request: NextRequest) {
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`); logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
} }
// Get language-specific stop words for ranking
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally // Note: rankTorrents now filters out results < 20 MB internally
// requireAuthor: false - interactive search, show all results for user decision // requireAuthor: false - interactive search, show all results for user decision
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, { const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false // Interactive mode - let user decide requireAuthor: false, // Interactive mode - let user decide
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
}); });
// Log filter results // Log filter results
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Author Books API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Authors.Books');
/**
* GET /api/authors/{asin}/books?name=Author+Name
* Scrape Audible for all books by this author, filtered by ASIN and English language.
* Enriched with library availability and request status.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const { asin } = await params;
const authorName = request.nextUrl.searchParams.get('name');
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
{ status: 400 }
);
}
if (!authorName || authorName.trim().length === 0) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Author name is required' },
{ status: 400 }
);
}
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
const audibleService = getAudibleService();
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
// Enrich with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
return NextResponse.json({
success: true,
books: enrichedBooks,
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
});
} catch (error) {
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch author books' },
{ status: 500 }
);
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Component: Author Detail API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger';
import {
AudnexusAuthorDetail,
fetchAuthorDetail,
} from '@/lib/integrations/audnexus-authors';
const logger = RMABLogger.create('API.Authors.Detail');
const SIMILAR_AUTHORS_LIMIT = 15;
/**
* GET /api/authors/{asin}
* Fetch author detail from Audnexus with enriched similar author images
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const { asin } = await params;
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
{ status: 400 }
);
}
const configService = getConfigService();
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
const regionConfig = AUDIBLE_REGIONS[audibleRegion] || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION];
const region = regionConfig.audnexusParam;
logger.info(`Fetching author detail: ${asin} (region: ${region})`);
// Fetch the primary author detail
const detail = await fetchAuthorDetail(asin, region);
if (!detail) {
return NextResponse.json(
{ error: 'NotFound', message: 'Author not found' },
{ status: 404 }
);
}
// Fetch images for similar authors in parallel (capped)
const similarSlice = (detail.similar || []).slice(0, SIMILAR_AUTHORS_LIMIT);
const similarDetails = await Promise.all(
similarSlice.map(s => fetchAuthorDetail(s.asin, region))
);
const similarAuthors = similarSlice.map((s, i) => ({
asin: s.asin,
name: s.name,
image: similarDetails[i]?.image || undefined,
}));
const author = {
asin: detail.asin,
name: detail.name,
description: detail.description || undefined,
image: detail.image || undefined,
genres: detail.genres?.map(g => g.name) || [],
similar: similarAuthors,
audibleUrl: `${regionConfig.baseUrl}/author/${asin}`,
};
logger.info(`Author detail complete: "${detail.name}" (${similarAuthors.length} similar authors)`);
return NextResponse.json({ success: true, author });
} catch (error) {
logger.error('Failed to fetch author detail', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch author details' },
{ status: 500 }
);
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Component: Author Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger';
import {
AudnexusAuthorDetail,
searchAuthors,
fetchAuthorDetail,
} from '@/lib/integrations/audnexus-authors';
const logger = RMABLogger.create('API.Authors.Search');
/**
* GET /api/authors/search?name=Brandon Sanderson
* Search for authors on Audnexus, deduplicate, and return enriched details
*/
export async function GET(request: NextRequest) {
try {
// Require authentication
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const name = request.nextUrl.searchParams.get('name');
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Author name is required' },
{ status: 400 }
);
}
// Get configured Audible region
const configService = getConfigService();
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
const region = AUDIBLE_REGIONS[audibleRegion]?.audnexusParam || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].audnexusParam;
logger.info(`Searching authors: "${name}" (region: ${region})`);
// Step 1: Search for authors (returns list with potential duplicates)
const searchResults = await searchAuthors(name.trim(), region);
if (searchResults.length === 0) {
return NextResponse.json({
success: true,
authors: [],
query: name.trim(),
});
}
// Step 2: Fetch details for all unique authors in parallel
const detailPromises = searchResults.map(author => fetchAuthorDetail(author.asin, region));
const detailResults = await Promise.all(detailPromises);
// Step 3: Build enriched results, filtering out any failed fetches
const authors = detailResults
.filter((detail): detail is AudnexusAuthorDetail => detail !== null)
.map(detail => ({
asin: detail.asin,
name: detail.name,
description: detail.description || undefined,
image: detail.image || undefined,
genres: detail.genres?.map(g => g.name).slice(0, 3) || [],
similarCount: detail.similar?.length || 0,
}));
logger.info(`Author search complete: "${name}" → ${authors.length} results`);
return NextResponse.json({
success: true,
authors,
query: name.trim(),
});
} catch (error) {
logger.error('Failed to search authors', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'SearchError', message: 'Failed to search authors' },
{ status: 500 }
);
}
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Component: Configuration API Routes (by category)
* Documentation: documentation/backend/services/config.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Config.Category');
// GET /api/config/:category - Get all config for a category
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ category: string }> }
) {
try {
// TODO: Add authentication middleware - admin only
const { category } = await params;
const configService = getConfigService();
const config = await configService.getCategory(category);
return NextResponse.json({
category,
config,
});
} catch (error) {
logger.error('Failed to get config for category', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{
error: 'Failed to get configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
-84
View File
@@ -1,84 +0,0 @@
/**
* Component: Configuration API Routes
* Documentation: documentation/backend/services/config.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfigService, ConfigUpdate } from '@/lib/services/config.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Config');
const ConfigUpdateSchema = z.object({
updates: z.array(
z.object({
key: z.string(),
value: z.string(),
encrypted: z.boolean().optional(),
category: z.string().optional(),
description: z.string().optional(),
})
),
});
// PUT /api/config - Update multiple configuration values
export async function PUT(request: NextRequest) {
try {
// TODO: Add authentication middleware - admin only
const body = await request.json();
const { updates } = ConfigUpdateSchema.parse(body);
const configService = getConfigService();
await configService.setMany(updates as ConfigUpdate[]);
return NextResponse.json({
success: true,
updated: updates.length,
});
} catch (error) {
logger.error('Failed to update configuration', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation error',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'Failed to update configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
// GET /api/config - Get all configuration (masked sensitive values)
export async function GET() {
try {
// TODO: Add authentication middleware - admin only
const configService = getConfigService();
const allConfig = await configService.getAll();
return NextResponse.json({
config: allConfig,
});
} catch (error) {
logger.error('Failed to get all configuration', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{
error: 'Failed to get configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
@@ -14,6 +14,8 @@ import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm'; import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { import {
searchByAsin, searchByAsin,
searchByTitle, searchByTitle,
@@ -121,6 +123,11 @@ export async function POST(
const format = preferredFormat || 'epub'; const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li'; const annasBaseUrl = baseUrl || 'https://annas-archive.li';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json( return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
@@ -145,7 +152,8 @@ export async function POST(
audiobook.author, audiobook.author,
format, format,
annasBaseUrl, annasBaseUrl,
flaresolverrUrl || undefined flaresolverrUrl || undefined,
languageCode
).catch((err) => { ).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`); logger.error(`Anna's Archive search failed: ${err.message}`);
return null; return null;
@@ -217,7 +225,8 @@ async function searchAnnasArchiveForInteractive(
author: string, author: string,
preferredFormat: string, preferredFormat: string,
baseUrl: string, baseUrl: string,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookSearchResult[]> { ): Promise<EbookSearchResult[]> {
let md5: string | null = null; let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title'; let searchMethod: 'asin' | 'title' = 'title';
@@ -225,7 +234,7 @@ async function searchAnnasArchiveForInteractive(
// Try ASIN search first // Try ASIN search first
if (asin) { if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`); logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
searchMethod = 'asin'; searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`); logger.info(`Found via ASIN: ${md5}`);
@@ -235,7 +244,7 @@ async function searchAnnasArchiveForInteractive(
// Fallback to title search // Fallback to title search
if (!md5) { if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`); logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via title: ${md5}`); logger.info(`Found via title: ${md5}`);
} }
@@ -356,6 +365,10 @@ async function searchIndexersForInteractive(
return []; return [];
} }
// Get language-specific stop words for ranking
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
const rankLangConfig = getLanguageForRegion(rankRegion);
// Rank results with ebook scoring // Rank results with ebook scoring
// Use requireAuthor=false for interactive mode (let user decide) // Use requireAuthor=false for interactive mode (let user decide)
const rankedResults = rankEbookTorrents(allResults, { const rankedResults = rankEbookTorrents(allResults, {
@@ -366,6 +379,8 @@ async function searchIndexersForInteractive(
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false, requireAuthor: false,
stopWords: rankLangConfig.stopWords,
characterReplacements: rankLangConfig.characterReplacements,
}); });
// Log ranking debug info (same format as search-ebook.processor.ts) // Log ranking debug info (same format as search-ebook.processor.ts)
@@ -9,6 +9,8 @@ import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
@@ -189,6 +191,10 @@ export async function POST(
} }
} }
// Get language-specific stop words for ranking
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query) // Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision // requireAuthor: false - interactive mode, show all results for user decision
@@ -199,7 +205,9 @@ export async function POST(
}, { }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false // Interactive mode - let user decide requireAuthor: false, // Interactive mode - let user decide
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
}); });
// No threshold filtering for interactive search - show all results // No threshold filtering for interactive search - show all results
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: Series Detail API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
const logger = RMABLogger.create('API.Series.Detail');
/**
* GET /api/series/{asin}
* Fetch series detail: metadata + books (enriched with availability) + similar series
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const { asin } = await params;
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Valid series ASIN is required' },
{ status: 400 }
);
}
logger.info(`Fetching series detail: ${asin}`);
const detail = await scrapeSeriesPage(asin);
if (!detail) {
return NextResponse.json(
{ error: 'NotFound', message: 'Series not found' },
{ status: 404 }
);
}
// Enrich books with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
return NextResponse.json({
success: true,
series: {
...detail,
books: enrichedBooks,
},
});
} catch (error) {
logger.error('Failed to fetch series detail', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch series details' },
{ status: 500 }
);
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Component: Series Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { searchForSeries } from '@/lib/integrations/audible-series';
const logger = RMABLogger.create('API.Series.Search');
/**
* GET /api/series/search?q=game+of+thrones
* Search for audiobook series on Audible, de-duplicate, and return enriched summaries
*/
export async function GET(request: NextRequest) {
try {
// Require authentication
const currentUser = getCurrentUser(request);
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'Authentication required' },
{ status: 401 }
);
}
const query = request.nextUrl.searchParams.get('q');
if (!query || query.trim().length === 0) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Search query is required' },
{ status: 400 }
);
}
logger.info(`Searching series: "${query}"`);
const series = await searchForSeries(query.trim());
logger.info(`Series search complete: "${query}" -> ${series.length} results`);
return NextResponse.json({
success: true,
series,
query: query.trim(),
});
} catch (error) {
logger.error('Failed to search series', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'SearchError', message: 'Failed to search series' },
{ status: 500 }
);
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Component: Author Detail Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { use, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function AuthorDetailPage({
params,
}: {
params: Promise<{ asin: string }>;
}) {
const { asin } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const fromAuthorName = searchParams.get('from');
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
asin,
author?.name || null
);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /authors
if (window.history.length > 1) {
router.back();
} else {
router.push('/authors');
}
}, [router]);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
{/* Back navigation */}
<button
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{fromAuthorName ? `Back to ${fromAuthorName}` : 'Back to Authors'}
</button>
{/* Author Detail Card */}
{authorLoading ? (
<AuthorDetailSkeleton />
) : author ? (
<AuthorDetailCard author={author} />
) : (
<div className="text-center py-16 space-y-4">
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">Author not found</p>
</div>
)}
{/* Similar Authors */}
{authorLoading ? (
<SimilarAuthorsSkeleton />
) : author && author.similar.length > 0 ? (
<SimilarAuthorsRow authors={author.similar} currentAuthorName={author.name} />
) : null}
{/* Books Section */}
{author && (
<div className="space-y-6">
{/* Sticky Books Header */}
<div className="sticky top-14 sm:top-16 z-30">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books
</h2>
{!booksLoading && totalBooks > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={books}
isLoading={booksLoading}
emptyMessage={`No books found for ${author.name}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Component: Authors Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { Suspense, useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AuthorGrid } from '@/components/authors/AuthorGrid';
import { useAuthorSearch } from '@/lib/hooks/useAuthors';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { usePreferences } from '@/contexts/PreferencesContext';
function AuthorsPageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const initialQuery = searchParams.get('q') || '';
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const { cardSize, setCardSize } = usePreferences();
// Debounce search query and sync to URL
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
// Update URL without adding history entries
const trimmed = query.trim();
if (trimmed) {
router.replace(`/authors?q=${encodeURIComponent(trimmed)}`, { scroll: false });
} else {
router.replace('/authors', { scroll: false });
}
}, 500);
return () => clearTimeout(timer);
}, [query, router]);
const { authors, isLoading } = useAuthorSearch(debouncedQuery);
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
}, []);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* Page Header */}
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
Browse Authors
</h1>
<p className="text-gray-600 dark:text-gray-400">
Search for your favorite audiobook authors
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
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>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by author name..."
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
autoFocus
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="h-5 w-5" 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>
</form>
{/* Results */}
{debouncedQuery ? (
<div className="space-y-6">
{/* Sticky Results Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Authors
</h2>
{!isLoading && authors.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({authors.length} result{authors.length !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Author Grid */}
<AuthorGrid
authors={authors}
isLoading={!!isLoading}
emptyMessage={`No authors found for "${debouncedQuery}"`}
cardSize={cardSize}
/>
</div>
) : (
/* Empty State */
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start typing to search for authors
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Search by author name to discover their works
</p>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
export default function AuthorsPage() {
return (
<Suspense>
<AuthorsPageContent />
</Suspense>
);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Component: Series Detail Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { use, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
import { useSeriesDetail } from '@/lib/hooks/useSeries';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function SeriesDetailPage({
params,
}: {
params: Promise<{ asin: string }>;
}) {
const { asin } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const fromSeriesTitle = searchParams.get('from');
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /series
if (window.history.length > 1) {
router.back();
} else {
router.push('/series');
}
}, [router]);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
{/* Back navigation */}
<button
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{fromSeriesTitle ? `Back to ${fromSeriesTitle}` : 'Back to Series'}
</button>
{/* Series Detail Card */}
{seriesLoading ? (
<SeriesDetailSkeleton squareCovers={squareCovers} />
) : series ? (
<SeriesDetailCard series={series} squareCovers={squareCovers} />
) : (
<div className="text-center py-16 space-y-4">
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">Series not found</p>
</div>
)}
{/* Similar Series */}
{seriesLoading ? (
<SimilarSeriesSkeleton squareCovers={squareCovers} />
) : series && series.similarSeries.length > 0 ? (
<SimilarSeriesRow series={series.similarSeries} currentSeriesTitle={series.title} squareCovers={squareCovers} />
) : null}
{/* Books Section */}
{series && (
<div className="space-y-6">
{/* Sticky Books Header */}
<div className="sticky top-14 sm:top-16 z-30">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books in Series
</h2>
{series.books.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.books.length} title{series.books.length !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Books Grid */}
<AudiobookGrid
audiobooks={series.books}
isLoading={seriesLoading}
emptyMessage={`No books found for ${series.title}`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Component: Series Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { Suspense, useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { SeriesGrid } from '@/components/series/SeriesGrid';
import { useSeriesSearch } from '@/lib/hooks/useSeries';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
function SeriesPageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const initialQuery = searchParams.get('q') || '';
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
// Debounce search query and sync to URL
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
// Update URL without adding history entries
const trimmed = query.trim();
if (trimmed) {
router.replace(`/series?q=${encodeURIComponent(trimmed)}`, { scroll: false });
} else {
router.replace('/series', { scroll: false });
}
}, 500);
return () => clearTimeout(timer);
}, [query, router]);
const { series, isLoading } = useSeriesSearch(debouncedQuery);
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
}, []);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* Page Header */}
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
Browse Series
</h1>
<p className="text-gray-600 dark:text-gray-400">
Search for your favorite audiobook series
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
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>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by series name..."
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
autoFocus
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="h-5 w-5" 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>
</form>
{/* Results */}
{debouncedQuery ? (
<div className="space-y-6">
{/* Sticky Results Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Series
</h2>
{!isLoading && series.length > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.length} result{series.length !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
</div>
</div>
{/* Series Grid */}
<SeriesGrid
series={series}
isLoading={!!isLoading}
emptyMessage={`No series found for "${debouncedQuery}"`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
</div>
) : (
/* Empty State */
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start typing to search for series
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Search by series name to discover audiobook collections
</p>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
export default function SeriesPage() {
return (
<Suspense>
<SeriesPageContent />
</Suspense>
);
}
+2 -2
View File
@@ -115,11 +115,11 @@ export function BackendSelectionStep({
> >
{Object.values(AUDIBLE_REGIONS).map((region) => ( {Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}> <option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''} {region.name}{region.language !== 'en' ? ' *' : ''}
</option> </option>
))} ))}
</select> </select>
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && ( {AUDIBLE_REGIONS[audibleRegion]?.language !== 'en' && (
<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="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"> <div className="flex gap-3">
<svg <svg
@@ -29,6 +29,7 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300', nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
deluge: 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300',
}; };
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent; const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
@@ -253,7 +253,7 @@ export function DownloadClientManagement({
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Add Download Client Add Download Client
</h3> </h3>
<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-3 xl:grid-cols-5 gap-4">
{/* qBittorrent Card */} {/* qBittorrent Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}> <div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
@@ -316,6 +316,37 @@ export function DownloadClientManagement({
)} )}
</div> </div>
{/* Deluge Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Deluge
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Torrent downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium">
Torrent
</span>
</div>
{hasTorrentClient ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Protocol already configured
</div>
) : (
<Button
onClick={() => handleAddClient('deluge')}
variant="primary"
size="sm"
disabled={loading}
>
Add Deluge
</Button>
)}
</div>
{/* SABnzbd Card */} {/* SABnzbd Card */}
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}> <div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
@@ -278,7 +278,7 @@ export function DownloadClientModal({
type, type,
name, name,
url, url,
username: type !== 'sabnzbd' ? username : undefined, username: type !== 'sabnzbd' && type !== 'deluge' ? username : undefined,
password: password === '********' ? undefined : password, // Don't send masked password on edit password: password === '********' ? undefined : password, // Don't send masked password on edit
enabled, enabled,
disableSSLVerify, disableSSLVerify,
@@ -286,7 +286,7 @@ export function DownloadClientModal({
remotePath: remotePathMappingEnabled ? remotePath : undefined, remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined,
category, category,
customPath: sanitizedCustomPath || undefined, customPath: sanitizedCustomPath,
postImportCategory, postImportCategory,
}; };
@@ -338,7 +338,7 @@ export function DownloadClientModal({
<Input <Input
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'} placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'deluge' ? 'http://localhost:8112' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
error={errors.url} error={errors.url}
/> />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
@@ -346,8 +346,8 @@ export function DownloadClientModal({
</p> </p>
</div> </div>
{/* Username (qBittorrent and Transmission) */} {/* Username (qBittorrent, Transmission, NZBGet — not SABnzbd or Deluge) */}
{type !== 'sabnzbd' && ( {type !== 'sabnzbd' && type !== 'deluge' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username Username
@@ -383,6 +383,11 @@ export function DownloadClientModal({
Configured in NZBGet under Settings Security ControlPassword Configured in NZBGet under Settings Security ControlPassword
</p> </p>
)} )}
{type === 'deluge' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Web UI password configured in Deluge under Preferences Interface
</p>
)}
</div> </div>
{/* SSL Verification */} {/* SSL Verification */}
@@ -448,7 +453,7 @@ export function DownloadClientModal({
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Post-Import Category Post-Import Category
</label> </label>
{type === 'qbittorrent' && availableCategories.length > 0 ? ( {(type === 'qbittorrent' || type === 'deluge') && availableCategories.length > 0 ? (
<select <select
value={postImportCategory} value={postImportCategory}
onChange={(e) => setPostImportCategory(e.target.value)} onChange={(e) => setPostImportCategory(e.target.value)}
@@ -10,6 +10,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks'; import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests'; import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
@@ -71,7 +72,7 @@ export function AudiobookDetailsModal({
}: AudiobookDetailsModalProps) { }: AudiobookDetailsModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null); const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest(); const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null); const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin(); const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
@@ -286,13 +287,44 @@ export function AudiobookDetailsModal({
{audiobook.title} {audiobook.title}
</h2> </h2>
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300"> <p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
{audiobook.authorAsin ? (
<Link
href={`/authors/${audiobook.authorAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
{audiobook.author} {audiobook.author}
</Link>
) : (
audiobook.author
)}
</p> </p>
{audiobook.narrator && ( {audiobook.narrator && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Narrated by {audiobook.narrator} Narrated by {audiobook.narrator}
</p> </p>
)} )}
{audiobook.series && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{audiobook.seriesAsin ? (
<Link
href={`/series/${audiobook.seriesAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
>
{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
</Link>
) : (
<span>{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}</span>
)}
</p>
)}
{/* Status Badge */} {/* Status Badge */}
{status.type !== 'none' && ( {status.type !== 'none' && (
@@ -418,7 +450,7 @@ export function AudiobookDetailsModal({
<div> <div>
<p className="text-gray-500 dark:text-gray-400">Source</p> <p className="text-gray-500 dark:text-gray-400">Source</p>
<a <a
href={`https://www.audible.com/pd/${asin}`} href={`${audibleBaseUrl}/pd/${asin}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline" className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
+87
View File
@@ -0,0 +1,87 @@
/**
* Component: Author Card
* Documentation: documentation/frontend/components.md
*
* Premium circular portrait design - distinguishes authors from audiobook covers.
* Hover effects and typography match the AudiobookCard aesthetic.
* Clicking navigates to the author's detail page.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Author } from '@/lib/hooks/useAuthors';
interface AuthorCardProps {
author: Author;
}
export function AuthorCard({ author }: AuthorCardProps) {
return (
<Link
href={`/authors/${author.asin}`}
className="group outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded-2xl"
aria-label={`View details for ${author.name}`}
>
{/* Circular Portrait Container */}
<div className="flex justify-center">
<div
className="
relative overflow-hidden rounded-full
w-full aspect-square
shadow-lg shadow-black/20 dark:shadow-black/40
group-hover:shadow-xl group-hover:shadow-black/25 dark:group-hover:shadow-black/50
transform group-hover:scale-[1.04] group-hover:-translate-y-1
transition-all duration-300 ease-out
"
>
{author.image ? (
<Image
src={author.image}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
{/* Subtle hover overlay */}
<div className="
absolute inset-0 rounded-full
bg-black/0 group-hover:bg-black/10
transition-colors duration-300
" />
</div>
</div>
{/* Author Info */}
<div className="mt-3 px-1 text-center">
<h3 className="font-semibold text-[15px] leading-snug text-gray-900 dark:text-gray-100 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
{author.name}
</h3>
{/* Genre Pills */}
{author.genres.length > 0 && (
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
{author.genres.map(genre => (
<span
key={genre}
className="inline-block px-2 py-0.5 text-[11px] font-medium rounded-full bg-gray-100 dark:bg-gray-700/60 text-gray-500 dark:text-gray-400"
>
{genre}
</span>
))}
</div>
)}
</div>
</Link>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Component: Author Detail Card
* Documentation: documentation/frontend/components.md
*
* Hero section for the author detail page with circular portrait,
* name, collapsible biography, and genre pills.
*/
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { AuthorDetail } from '@/lib/hooks/useAuthors';
interface AuthorDetailCardProps {
author: AuthorDetail;
}
export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
const [expanded, setExpanded] = useState(false);
const hasLongDescription = (author.description?.length || 0) > 300;
return (
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Circular Portrait */}
<div className="flex-shrink-0">
<div className="relative w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40">
{author.image ? (
<Image
src={author.image}
alt={author.name}
fill
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
</div>
</div>
{/* Author Info */}
<div className="flex-1 min-w-0 text-center sm:text-left">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
{author.name}
</h1>
{/* Genre Pills */}
{author.genres.length > 0 && (
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
{author.genres.map(genre => (
<span
key={genre}
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
>
{genre}
</span>
))}
</div>
)}
{/* Audible Link */}
{author.audibleUrl && (
<a
href={author.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Description */}
{author.description && (
<div className="mt-4">
<p
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
!expanded && hasLongDescription ? 'line-clamp-4' : ''
}`}
>
{author.description}
</p>
{hasLongDescription && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
)}
</div>
</div>
);
}
export function AuthorDetailSkeleton() {
return (
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Portrait skeleton */}
<div className="flex-shrink-0">
<div className="w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
<div className="w-full h-full rounded-full relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
</div>
{/* Info skeleton */}
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
</div>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Component: Author Grid
* Documentation: documentation/frontend/components.md
*
* Premium grid layout for author cards with loading skeletons and empty state.
* Mirrors AudiobookGrid patterns with author-appropriate column counts.
*/
'use client';
import React from 'react';
import { AuthorCard } from './AuthorCard';
import { Author } from '@/lib/hooks/useAuthors';
interface AuthorGridProps {
authors: Author[];
isLoading?: boolean;
emptyMessage?: string;
cardSize?: number;
}
// Authors use wider spacing since circular portraits need room to breathe.
// Slightly fewer columns than AudiobookGrid at each breakpoint since circles
// are visually wider than 2:3 portrait covers.
function getGridClasses(size: number): string {
const sizeMap: Record<number, string> = {
1: 'grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
2: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6',
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
7: 'grid-cols-2 md:grid-cols-3',
8: 'grid-cols-2',
9: 'grid-cols-1',
};
return sizeMap[size] || sizeMap[5];
}
export function AuthorGrid({
authors,
isLoading = false,
emptyMessage = 'No authors found',
cardSize = 5,
}: AuthorGridProps) {
const gridClasses = getGridClasses(cardSize);
if (isLoading) {
return (
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
{Array.from({ length: 10 }).map((_, i) => (
<AuthorSkeletonCard key={i} index={i} />
))}
</div>
);
}
if (authors.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
{authors.map(author => (
<AuthorCard key={author.asin} author={author} />
))}
</div>
);
}
function AuthorSkeletonCard({ index = 0 }: { index?: number }) {
return (
<div
className="animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Circular portrait skeleton */}
<div className="flex justify-center">
<div className="relative overflow-hidden rounded-full w-full aspect-square bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
{/* Text skeleton */}
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
</div>
</div>
);
}
@@ -0,0 +1,168 @@
/**
* Component: Similar Authors Row
* Documentation: documentation/frontend/components.md
*
* Horizontal scrollable carousel of similar author cards.
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
* Each card navigates to the author's detail page.
*/
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SimilarAuthor } from '@/lib/hooks/useAuthors';
interface SimilarAuthorsRowProps {
authors: SimilarAuthor[];
currentAuthorName?: string;
}
export function SimilarAuthorsRow({ authors, currentAuthorName }: SimilarAuthorsRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
}, []);
useEffect(() => {
checkScroll();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', checkScroll, { passive: true });
const observer = new ResizeObserver(checkScroll);
observer.observe(el);
return () => {
el.removeEventListener('scroll', checkScroll);
observer.disconnect();
};
}, [checkScroll, authors]);
const scroll = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const scrollAmount = el.clientWidth * 0.7;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
};
if (authors.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
Similar Authors
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({authors.length})
</span>
</div>
<div className="relative group">
{/* Left arrow */}
{canScrollLeft && (
<button
onClick={() => scroll('left')}
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Scrollable row */}
<div
ref={scrollRef}
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{authors.map(author => (
<Link
key={author.asin}
href={`/authors/${author.asin}${currentAuthorName ? `?from=${encodeURIComponent(currentAuthorName)}` : ''}`}
className="flex-shrink-0 w-24 sm:w-28 group/card outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-xl"
>
{/* Circular portrait */}
<div className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300">
{author.image ? (
<Image
src={author.image}
alt=""
fill
className="object-cover"
sizes="112px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
<span className="text-xl font-bold text-blue-400 dark:text-blue-300">
{author.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Name */}
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-indigo-600 dark:group-hover/card:text-indigo-400 transition-colors">
{author.name}
</p>
</Link>
))}
</div>
{/* Right arrow */}
{canScrollRight && (
<button
onClick={() => scroll('right')}
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll right"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Fade edges */}
{canScrollLeft && (
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
{canScrollRight && (
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
</div>
</div>
);
}
export function SimilarAuthorsSkeleton() {
return (
<div className="space-y-3 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex gap-4 sm:gap-5 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-24 sm:w-28" style={{ animationDelay: `${i * 50}ms` }}>
<div className="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
</div>
))}
</div>
</div>
);
}
+26
View File
@@ -160,6 +160,18 @@ export function Header() {
> >
Search Search
</Link> </Link>
<Link
href="/authors"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Authors
</Link>
<Link
href="/series"
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Series
</Link>
{showBookDate && ( {showBookDate && (
<Link <Link
href="/bookdate" href="/bookdate"
@@ -264,6 +276,20 @@ export function Header() {
> >
Search Search
</Link> </Link>
<Link
href="/authors"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Authors
</Link>
<Link
href="/series"
onClick={() => setShowMobileMenu(false)}
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Series
</Link>
{showBookDate && ( {showBookDate && (
<Link <Link
href="/bookdate" href="/bookdate"
+153
View File
@@ -0,0 +1,153 @@
/**
* Component: Series Card
* Documentation: documentation/frontend/components.md
*
* Premium "Cover First" design - metadata integrated into the cover overlay.
* Rating badge top-left, book count top-right, tags in bottom gradient overlay.
* Only the title lives below the cover, ensuring consistent row heights in the grid.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SeriesSummary } from '@/lib/hooks/useSeries';
interface SeriesCardProps {
series: SeriesSummary;
squareCovers?: boolean;
}
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
const visibleTags = series.tags.slice(0, 2);
const hasTags = visibleTags.length > 0;
const hasRating = series.rating != null && series.rating > 0;
return (
<Link
href={`/series/${series.asin}`}
className="group outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded-2xl block"
aria-label={`View ${series.title} series`}
>
{/* Cover Container — The Hero */}
<div
className={`
relative overflow-hidden rounded-xl
w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'}
shadow-lg shadow-black/20 dark:shadow-black/40
group-hover:shadow-xl group-hover:shadow-black/30 dark:group-hover:shadow-black/55
transform group-hover:scale-[1.02] group-hover:-translate-y-0.5
transition-all duration-300 ease-out
`}
>
{/* Cover Art or Fallback */}
{series.coverArtUrl ? (
<Image
src={series.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
<svg
className="w-1/3 h-1/3 text-white/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
</div>
)}
{/* Top-row badges — Rating (left) + Book count (right) */}
{/* Rating Badge — top-left, matches AudiobookCard pattern exactly */}
{hasRating && (
<div className="
absolute top-2.5 left-2.5
flex items-center gap-1 px-2 py-1
rounded-lg bg-black/50 backdrop-blur-md
text-white text-xs font-medium
transition-opacity duration-300 group-hover:opacity-0
">
<svg className="w-3.5 h-3.5 text-amber-400 shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{series.rating!.toFixed(1)}</span>
</div>
)}
{/* Book count badge — top-right */}
{series.bookCount > 0 && (
<div className="
absolute top-2.5 right-2.5
px-2 py-1
text-[11px] font-bold rounded-lg
bg-black/50 backdrop-blur-md
text-white
transition-opacity duration-300 group-hover:opacity-0
">
{series.bookCount} {series.bookCount === 1 ? 'Book' : 'Books'}
</div>
)}
{/* Bottom gradient overlay — always present, deepens on hover */}
<div className={`
absolute inset-x-0 bottom-0
transition-all duration-300
${hasTags
? 'h-20 bg-gradient-to-t from-black/75 via-black/30 to-transparent group-hover:h-24 group-hover:from-black/85'
: 'h-10 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100'
}
`} />
{/* Tag pills — pinned to bottom of cover, inside gradient */}
{hasTags && (
<div className="
absolute inset-x-0 bottom-0
flex items-end gap-1.5 p-2.5
pointer-events-none
">
{visibleTags.map(tag => (
<span
key={tag}
className="
inline-block px-2.5 py-0.5
text-[10px] font-medium
rounded-full
bg-black/30 backdrop-blur-md
text-white/90
ring-1 ring-white/15
transition-opacity duration-300
"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Below-cover: title only — fixed, predictable height across all cards */}
<div className="mt-2.5 px-0.5">
<h3 className="
font-semibold text-[14px] leading-snug
text-gray-900 dark:text-gray-100
line-clamp-2
group-hover:text-emerald-600 dark:group-hover:text-emerald-400
transition-colors duration-200
">
{series.title}
</h3>
</div>
</Link>
);
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Component: Series Detail Card
* Documentation: documentation/frontend/components.md
*
* Hero section for the series detail page with rectangular cover image,
* title, book count, rating, collapsible description, and tag pills.
*/
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { SeriesDetail } from '@/lib/hooks/useSeries';
interface SeriesDetailCardProps {
series: SeriesDetail;
squareCovers?: boolean;
}
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
const [expanded, setExpanded] = useState(false);
const hasLongDescription = (series.description?.length || 0) > 300;
return (
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Rectangular Cover */}
<div className="flex-shrink-0">
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
{series.books[0]?.coverArtUrl ? (
<Image
src={series.books[0].coverArtUrl}
alt={series.title}
fill
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
)}
</div>
</div>
{/* Series Info */}
<div className="flex-1 min-w-0 text-center sm:text-left">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
{series.title}
</h1>
{/* Meta row: book count + rating */}
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
{series.bookCount > 0 && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
{series.bookCount} Book{series.bookCount !== 1 ? 's' : ''}
</span>
)}
{series.rating != null && series.rating > 0 && (
<span className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{series.rating.toFixed(1)}
{series.ratingCount != null && series.ratingCount > 0 && (
<span className="text-gray-400 dark:text-gray-500">
({series.ratingCount.toLocaleString()})
</span>
)}
</span>
)}
</div>
{/* Tag Pills */}
{series.tags.length > 0 && (
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
{series.tags.map(tag => (
<span
key={tag}
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
>
{tag}
</span>
))}
</div>
)}
{/* Audible Link */}
{series.audibleUrl && (
<a
href={series.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Description */}
{series.description && (
<div className="mt-4">
<p
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
!expanded && hasLongDescription ? 'line-clamp-4' : ''
}`}
>
{series.description}
</p>
{hasLongDescription && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
)}
</div>
</div>
);
}
export function SeriesDetailSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
return (
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Cover skeleton */}
<div className="flex-shrink-0">
<div className={`w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
{/* Info skeleton */}
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-7 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-7 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
</div>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Component: Series Grid
* Documentation: documentation/frontend/components.md
*
* Grid layout for series cards with loading skeletons and empty state.
* Uses the same responsive column system as AudiobookGrid since
* series cards use rectangular (2:3) aspect ratios like book covers.
*/
'use client';
import React from 'react';
import { SeriesCard } from './SeriesCard';
import { SeriesSummary } from '@/lib/hooks/useSeries';
interface SeriesGridProps {
series: SeriesSummary[];
isLoading?: boolean;
emptyMessage?: string;
cardSize?: number;
squareCovers?: boolean;
}
function getGridClasses(size: number): string {
const sizeMap: Record<number, string> = {
1: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
2: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
3: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
7: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
8: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
9: 'grid-cols-1 sm:grid-cols-2',
};
return sizeMap[size] || sizeMap[5];
}
export function SeriesGrid({
series,
isLoading = false,
emptyMessage = 'No series found',
cardSize = 5,
squareCovers = false,
}: SeriesGridProps) {
const gridClasses = getGridClasses(cardSize);
if (isLoading) {
return (
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
{Array.from({ length: 10 }).map((_, i) => (
<SeriesSkeletonCard key={i} index={i} squareCovers={squareCovers} />
))}
</div>
);
}
if (series.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
{series.map(s => (
<SeriesCard key={s.asin} series={s} squareCovers={squareCovers} />
))}
</div>
);
}
function SeriesSkeletonCard({ index = 0, squareCovers = false }: { index?: number; squareCovers?: boolean }) {
return (
<div
className="animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Rectangular cover skeleton */}
<div className={`relative overflow-hidden rounded-xl w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800`}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
{/* Text skeleton */}
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Component: Similar Series Row
* Documentation: documentation/frontend/components.md
*
* Horizontal scrollable carousel of similar series cards.
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
* Each card navigates to the series detail page.
*/
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SimilarSeries } from '@/lib/hooks/useSeries';
interface SimilarSeriesRowProps {
series: SimilarSeries[];
currentSeriesTitle?: string;
squareCovers?: boolean;
}
export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = false }: SimilarSeriesRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
}, []);
useEffect(() => {
checkScroll();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', checkScroll, { passive: true });
const observer = new ResizeObserver(checkScroll);
observer.observe(el);
return () => {
el.removeEventListener('scroll', checkScroll);
observer.disconnect();
};
}, [checkScroll, series]);
const scroll = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const scrollAmount = el.clientWidth * 0.7;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
};
if (series.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
Similar Series
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({series.length})
</span>
</div>
<div className="relative group">
{/* Left arrow */}
{canScrollLeft && (
<button
onClick={() => scroll('left')}
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Scrollable row */}
<div
ref={scrollRef}
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{series.map(s => (
<Link
key={s.asin}
href={`/series/${s.asin}${currentSeriesTitle ? `?from=${encodeURIComponent(currentSeriesTitle)}` : ''}`}
className="flex-shrink-0 w-20 sm:w-24 group/card outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-xl"
>
{/* Cover */}
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
{s.coverArtUrl ? (
<Image
src={s.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="96px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
{s.title.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Title */}
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-emerald-600 dark:group-hover/card:text-emerald-400 transition-colors">
{s.title}
</p>
</Link>
))}
</div>
{/* Right arrow */}
{canScrollRight && (
<button
onClick={() => scroll('right')}
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll right"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Fade edges */}
{canScrollLeft && (
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
{canScrollRight && (
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
</div>
</div>
);
}
export function SimilarSeriesSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
return (
<div className="space-y-3 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex gap-4 sm:gap-5 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-20 sm:w-24" style={{ animationDelay: `${i * 50}ms` }}>
<div className={`w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
</div>
))}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Component: Download Client Timeout Constants
* Documentation: documentation/phase3/download-clients.md
*
* Some indexers (e.g. YGGtorrent) enforce a ~30s wait before allowing
* .torrent file downloads. 60s gives sufficient headroom.
*/
/** Timeout for download client API calls and .torrent file fetches (ms) */
export const DOWNLOAD_CLIENT_TIMEOUT = 60000;
+291
View File
@@ -0,0 +1,291 @@
/**
* Component: Centralized Language Configuration
* Documentation: documentation/integrations/audible.md
*
* Single source of truth for all language-specific configuration.
* To add a new language:
* 1. Add code to SupportedLanguage union
* 2. Add full LanguageConfig entry in LANGUAGE_CONFIGS
* 3. Map regions in REGION_LANGUAGE_MAP
* 4. Add region to AUDIBLE_REGIONS in audible.ts with language: 'xx'
*/
import type { AudibleRegion } from '../types/audible';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SupportedLanguage = 'en' | 'de' | 'es' | 'fr';
export interface ScrapingConfig {
/** Audible locale query-param value (e.g. 'english', 'deutsch') */
audibleLocaleParam: string;
/** Author label prefixes to strip (e.g. ['By:', 'Written by:']) */
authorPrefixes: string[];
/** Narrator label prefixes to strip */
narratorPrefixes: string[];
/** Length / duration labels used in Cheerio :contains() selectors */
lengthLabels: string[];
/** Language field labels */
languageLabels: string[];
/** Release date field labels */
releaseDateLabels: string[];
/** Series label prefixes used to find series links in search results */
seriesLabels: string[];
/** Accepted language values for filtering (lowercase) */
acceptedLanguageValues: string[];
/** Regex patterns that match hour portions in runtime strings */
runtimeHourPatterns: RegExp[];
/** Regex patterns that match minute portions in runtime strings */
runtimeMinutePatterns: RegExp[];
/** Regex patterns for extracting numeric rating */
ratingPatterns: RegExp[];
/** Regex patterns for extracting release date text */
releaseDatePatterns: RegExp[];
/** Promotional / non-description text patterns to exclude */
descriptionExcludePatterns: RegExp[];
/** Duration detection pattern for generic element scanning */
durationDetectionPattern: RegExp;
/** Rating text selector pattern (e.g. 'out of 5 stars') */
ratingTextSelector: string;
}
export interface LanguageConfig {
code: SupportedLanguage;
/** Anna's Archive language filter code */
annasArchiveLang: string;
/** EPUB language code */
epubCode: string;
/** Stop words for ranking algorithm (filtered from match scoring) */
stopWords: string[];
/** Character replacements applied before NFD normalization in ranking (e.g. ß→ss) */
characterReplacements: Record<string, string>;
/** All scraping-related config */
scraping: ScrapingConfig;
}
// ---------------------------------------------------------------------------
// Language Configurations
// ---------------------------------------------------------------------------
const ENGLISH_CONFIG: LanguageConfig = {
code: 'en',
annasArchiveLang: 'en',
epubCode: 'en',
stopWords: ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'english',
authorPrefixes: ['By:', 'Written by:'],
narratorPrefixes: ['Narrated by:'],
lengthLabels: ['Length:'],
languageLabels: ['Language:'],
releaseDateLabels: ['Release date:'],
seriesLabels: ['Series:'],
acceptedLanguageValues: ['english'],
runtimeHourPatterns: [/(\d+)\s*hrs?/i, /(\d+)\s*hours?/i],
runtimeMinutePatterns: [/(\d+)\s*mins?/i, /(\d+)\s*minutes?/i],
ratingPatterns: [/(\d+\.?\d*)\s*out of/i],
releaseDatePatterns: [/Release date:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i,
ratingTextSelector: 'out of 5 stars',
},
};
const GERMAN_CONFIG: LanguageConfig = {
code: 'de',
annasArchiveLang: 'de',
epubCode: 'de',
stopWords: ['der', 'die', 'das', 'ein', 'eine', 'und', 'von', 'zu', 'den', 'dem', 'des'],
characterReplacements: { '\u00df': 'ss' },
scraping: {
audibleLocaleParam: 'deutsch',
authorPrefixes: ['Von:', 'Geschrieben von:', 'Autor:'],
narratorPrefixes: ['Gesprochen von:', 'Sprecher:'],
lengthLabels: ['Spieldauer:', 'Dauer:', 'L\u00e4nge:'],
languageLabels: ['Sprache:'],
releaseDateLabels: ['Erscheinungsdatum:'],
seriesLabels: ['Serie:', 'Reihe:'],
acceptedLanguageValues: ['deutsch', 'german'],
runtimeHourPatterns: [/(\d+)\s*Std\.?/i, /(\d+)\s*Stunden?/i],
runtimeMinutePatterns: [/(\d+)\s*Min\.?/i, /(\d+)\s*Minuten?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*von\s*5/i],
releaseDatePatterns: [/Erscheinungsdatum:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/jederzeit k\u00fcndbar/i,
/kostenlos testen/i,
/Mitgliedschaft/i,
/abonnieren/i,
/Angebot.*endet/i,
/^\s*von\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(Std|Stunden?|h)\s*\.?\s*\d*\s*(Min|Minuten?|m)?/i,
ratingTextSelector: 'von 5 Sternen',
},
};
const SPANISH_CONFIG: LanguageConfig = {
code: 'es',
annasArchiveLang: 'es',
epubCode: 'es',
stopWords: ['el', 'la', 'los', 'las', 'un', 'una', 'de', 'del', 'en', 'y', 'por'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'espa\u00f1ol',
authorPrefixes: ['De:', 'Escrito por:', 'Autor:'],
narratorPrefixes: ['Narrado por:'],
lengthLabels: ['Duraci\u00f3n:'],
languageLabels: ['Idioma:'],
releaseDateLabels: ['Fecha de lanzamiento:'],
seriesLabels: ['Serie:'],
acceptedLanguageValues: ['espa\u00f1ol', 'spanish'],
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*horas?/i],
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutos?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
releaseDatePatterns: [/Fecha de lanzamiento:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/cancela cuando quieras/i,
/prueba gratis/i,
/suscripci\u00f3n/i,
/suscr\u00edbete/i,
/oferta.*termina/i,
/^\s*de\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(h|horas?)\s*\d*\s*(min|minutos?)?/i,
ratingTextSelector: 'de 5 estrellas',
},
};
const FRENCH_CONFIG: LanguageConfig = {
code: 'fr',
annasArchiveLang: 'fr',
epubCode: 'fr',
stopWords: ['le', 'la', 'les', 'un', 'une', 'de', 'des', 'sur', 'dans', '\u00e0', 'et', 'par', 'pour'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'français',
authorPrefixes: ['De :', '\u00c9crit par :', 'Auteur :'],
narratorPrefixes: ['Lu par :'],
lengthLabels: ['Dur\u00e9e :'],
languageLabels: ['Langue :'],
releaseDateLabels: ['Date de publication :'],
seriesLabels: ['S\u00e9rie :'],
acceptedLanguageValues: ['français', 'french'],
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*heures?/i],
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutes?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
releaseDatePatterns: [/Date de publication:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/Essayer pour/i,
/R\u00e9siliez \u00e0 tout moment/i,
/Acheter pour/i,
/^\s*de\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(h|heures?)\s*\d*\s*(min|minutes?)?/i,
ratingTextSelector: 'sur 5 étoiles',
},
};
// ---------------------------------------------------------------------------
// Lookup Maps
// ---------------------------------------------------------------------------
export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
en: ENGLISH_CONFIG,
de: GERMAN_CONFIG,
es: SPANISH_CONFIG,
fr: FRENCH_CONFIG,
};
/**
* Maps Audible region codes to language codes.
* All English-speaking regions map to 'en'.
*/
export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
us: 'en',
ca: 'en',
uk: 'en',
au: 'en',
in: 'en',
de: 'de',
es: 'es',
fr: 'fr',
};
// ---------------------------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------------------------
/**
* Get the full language configuration for an Audible region.
*/
export function getLanguageForRegion(region: AudibleRegion): LanguageConfig {
const langCode = REGION_LANGUAGE_MAP[region];
return LANGUAGE_CONFIGS[langCode];
}
/**
* Strip any matching prefixes from text (case-insensitive).
* Returns the text with the first matching prefix removed, trimmed.
*
* Example: stripPrefixes('By: Author Name', ['By:', 'Written by:']) => 'Author Name'
*/
export function stripPrefixes(text: string, prefixes: string[]): string {
const trimmed = text.trim();
for (const prefix of prefixes) {
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
return trimmed.slice(prefix.length).trim();
}
}
return trimmed;
}
/**
* Build a Cheerio selector that matches any of the given labels using :contains().
* Returns a comma-separated selector string.
*
* Example: buildContainsSelector('span', ['Length:', 'Dauer:'])
* => 'span:contains("Length:"), span:contains("Dauer:")'
*/
export function buildContainsSelector(element: string, labels: string[]): string {
return labels.map(label => `${element}:contains("${label}")`).join(', ');
}
/**
* Extract a value from text by trying multiple label patterns.
* Returns the captured group from the first matching pattern, or null.
*/
export function extractByPatterns(text: string, patterns: RegExp[]): string | null {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return null;
}
/**
* Check if a language value matches the accepted values for a language config.
* Comparison is case-insensitive.
*/
export function isAcceptedLanguage(languageValue: string, config: LanguageConfig): boolean {
const normalized = languageValue.toLowerCase().trim();
return config.scraping.acceptedLanguageValues.includes(normalized);
}
+22 -3
View File
@@ -14,7 +14,8 @@ export type NotificationPriority = 'normal' | 'high';
* *
* Each entry defines: * Each entry defines:
* - `label`: Human-readable name shown in the UI * - `label`: Human-readable name shown in the UI
* - `title`: Title used in notification messages * - `title`: Default title used in notification messages
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook "Audiobook Available")
* - `emoji`: Emoji prefix for notification titles * - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags) * - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels) * - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
@@ -35,8 +36,12 @@ export const NOTIFICATION_EVENTS = {
priority: 'normal' as const, priority: 'normal' as const,
}, },
request_available: { request_available: {
label: 'Audiobook Available', label: 'Request Available',
title: 'Audiobook Available', title: 'Request Available',
titleByRequestType: {
audiobook: 'Audiobook Available',
ebook: 'Ebook Available',
} as Record<string, string>,
emoji: '\u{1F389}', emoji: '\u{1F389}',
severity: 'success' as const, severity: 'success' as const,
priority: 'high' as const, priority: 'high' as const,
@@ -71,6 +76,20 @@ export function getEventMeta(event: NotificationEvent) {
return NOTIFICATION_EVENTS[event]; return NOTIFICATION_EVENTS[event];
} }
/**
* Helper: get the resolved notification title for an event.
* If the event has a `titleByRequestType` map and a matching requestType is provided,
* returns the type-specific title. Otherwise falls back to the default `title`.
*/
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
const meta = NOTIFICATION_EVENTS[event];
if (requestType && 'titleByRequestType' in meta) {
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
if (typeTitle) return typeTitle;
}
return meta.title;
}
/** Helper: get the human-readable label for an event */ /** Helper: get the human-readable label for an event */
export function getEventLabel(event: NotificationEvent): string { export function getEventLabel(event: NotificationEvent): string {
return NOTIFICATION_EVENTS[event].label; return NOTIFICATION_EVENTS[event].label;
+28
View File
@@ -5,6 +5,29 @@
import { PrismaClient } from '@/generated/prisma/client'; import { PrismaClient } from '@/generated/prisma/client';
/**
* Append connection pool parameters to DATABASE_URL if not already present.
* - connection_limit=20: up from default 9, fits 22 max workers + API routes
* - pool_timeout=30: up from default 10s, gives queued requests time
*/
function getPooledDatabaseUrl(): string {
const baseUrl = process.env.DATABASE_URL || '';
if (!baseUrl) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
const params: string[] = [];
if (!baseUrl.includes('connection_limit')) {
params.push('connection_limit=20');
}
if (!baseUrl.includes('pool_timeout')) {
params.push('pool_timeout=30');
}
if (params.length === 0) return baseUrl;
return `${baseUrl}${separator}${params.join('&')}`;
}
// Prevent multiple instances of Prisma Client in development // Prevent multiple instances of Prisma Client in development
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined; prisma: PrismaClient | undefined;
@@ -14,6 +37,11 @@ export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
datasources: {
db: {
url: getPooledDatabaseUrl(),
},
},
}); });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+5
View File
@@ -12,6 +12,7 @@ export interface Audiobook {
asin: string; asin: string;
title: string; title: string;
author: string; author: string;
authorAsin?: string;
narrator?: string; narrator?: string;
description?: string; description?: string;
coverArtUrl?: string; coverArtUrl?: string;
@@ -19,6 +20,9 @@ export interface Audiobook {
releaseDate?: string; releaseDate?: string;
rating?: number; rating?: number;
genres?: string[]; genres?: string[];
series?: string; // Series name (e.g., "A Song of Ice and Fire")
seriesPart?: string; // Position in series (e.g., "1", "1.5")
seriesAsin?: string; // Audible ASIN for the series (links to /series/{asin})
isAvailable?: boolean; // Set by real-time matching against plex_library isAvailable?: boolean; // Set by real-time matching against plex_library
plexGuid?: string | null; plexGuid?: string | null;
dbId?: string | null; dbId?: string | null;
@@ -81,6 +85,7 @@ export function useAudiobookDetails(asin: string | null) {
return { return {
audiobook: data?.audiobook || null, audiobook: data?.audiobook || null,
audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com',
isLoading, isLoading,
error, error,
}; };
+88
View File
@@ -0,0 +1,88 @@
/**
* Component: Authors Fetching Hooks
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
export interface Author {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similarCount: number;
}
export interface SimilarAuthor {
asin: string;
name: string;
image?: string;
}
export interface AuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similar: SimilarAuthor[];
audibleUrl?: string;
}
export function useAuthorSearch(name: string) {
const shouldFetch = name && name.length > 0;
const endpoint = shouldFetch
? `/api/authors/search?name=${encodeURIComponent(name)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000,
});
return {
authors: (data?.authors || []) as Author[],
query: data?.query || '',
isLoading: shouldFetch && isLoading,
error,
};
}
export function useAuthorDetail(asin: string | null) {
const endpoint = asin ? `/api/authors/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
author: (data?.author || null) as AuthorDetail | null,
isLoading,
error,
};
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const shouldFetch = asin && authorName;
const endpoint = shouldFetch
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
});
return {
books: (data?.books || []) as Audiobook[],
totalBooks: data?.totalBooks || 0,
isLoading: !!shouldFetch && isLoading,
error,
};
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Component: Series Fetching Hooks
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
export interface SeriesSummary {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
tags: string[];
coverArtUrl?: string;
audibleUrl: string;
}
export interface SimilarSeries {
asin: string;
title: string;
bookCount?: number;
coverArtUrl?: string;
}
export interface SeriesDetail {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
description?: string;
tags: string[];
books: Audiobook[];
similarSeries: SimilarSeries[];
audibleUrl: string;
}
export function useSeriesSearch(query: string) {
const shouldFetch = query && query.length > 0;
const endpoint = shouldFetch
? `/api/series/search?q=${encodeURIComponent(query)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000,
});
return {
series: (data?.series || []) as SeriesSummary[],
query: data?.query || '',
isLoading: shouldFetch && isLoading,
error,
};
}
export function useSeriesDetail(asin: string | null) {
const endpoint = asin ? `/api/series/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
series: (data?.series || null) as SeriesDetail | null,
isLoading,
error,
};
}
+517
View File
@@ -0,0 +1,517 @@
/**
* Component: Audible Series Scraping
* Documentation: documentation/integrations/audible.md
*
* Standalone series scraping module. Uses the AudibleService fetch wrapper
* for HTTP requests and Cheerio for HTML parsing.
* Kept separate from audible.service.ts to avoid bloating the main service.
*/
import * as cheerio from 'cheerio';
import { getAudibleService, AudibleAudiobook } from './audible.service';
import { AUDIBLE_REGIONS } from '../types/audible';
import {
getLanguageForRegion,
buildContainsSelector,
stripPrefixes,
} from '../constants/language-config';
import { RMABLogger } from '../utils/logger';
import { randomDelay } from '../utils/scrape-resilience';
const logger = RMABLogger.create('Audible.Series');
const AUDIBLE_PAGE_SIZE = 50;
const MAX_SERIES_RESULTS = 15;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SeriesSummary {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
tags: string[];
coverArtUrl?: string;
audibleUrl: string;
}
export interface SimilarSeries {
asin: string;
title: string;
bookCount?: number;
coverArtUrl?: string;
}
export interface SeriesDetail {
asin: string;
title: string;
bookCount: number;
rating?: number;
ratingCount?: number;
description?: string;
tags: string[];
books: AudibleAudiobook[];
similarSeries: SimilarSeries[];
audibleUrl: string;
}
// ---------------------------------------------------------------------------
// Search: extract series links from Audible search results
// ---------------------------------------------------------------------------
/**
* Search for series by scraping Audible search results and extracting
* series links. De-duplicates by ASIN, then scrapes each unique series
* page in parallel (capped at MAX_SERIES_RESULTS).
*/
export async function searchForSeries(query: string): Promise<SeriesSummary[]> {
const service = getAudibleService();
const region = service.getRegion();
const baseUrl = service.getBaseUrl();
const langConfig = getLanguageForRegion(region);
const seriesLabels = langConfig.scraping.seriesLabels;
logger.info(`Searching series for "${query}" (region: ${region})`);
// Step 1: Fetch search results page
let $: cheerio.CheerioAPI;
try {
const { data: response } = await service.fetch('/search', {
params: {
ipRedirectOverride: 'true',
keywords: query,
pageSize: AUDIBLE_PAGE_SIZE,
},
});
$ = cheerio.load(response.data);
} catch (error) {
logger.error('Series search fetch failed', {
error: error instanceof Error ? error.message : String(error),
});
return [];
}
// Step 2: Extract unique series ASINs from search results
// Series links appear inside spans containing locale-specific "Series:" text
const seriesMap = new Map<string, { title: string; coverArtUrl?: string }>();
$('.s-result-item, .productListItem').each((_index, element) => {
if (seriesMap.size >= MAX_SERIES_RESULTS) return false;
const $el = $(element);
// Find the span containing a series label (e.g. "Series:")
const seriesSelector = buildContainsSelector('span', seriesLabels);
const seriesContainer = $el.find(seriesSelector).first();
if (seriesContainer.length === 0) return;
// Look for series link within or near the series label container
// The series link is a child or sibling: <a href="/series/Name/B006K1QER6">
const parentEl = seriesContainer.parent();
const seriesLink = parentEl.find('a[href*="/series/"]').first();
if (seriesLink.length === 0) return;
const href = seriesLink.attr('href') || '';
const asinMatch = href.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
if (!asinMatch) return;
const asin = asinMatch[1];
if (seriesMap.has(asin)) return;
const title = seriesLink.text().trim();
if (!title) return;
// Use the first book's cover as representative image
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
seriesMap.set(asin, { title, coverArtUrl });
});
if (seriesMap.size === 0) {
logger.info(`No series found for "${query}"`);
return [];
}
logger.info(`Found ${seriesMap.size} unique series, scraping detail pages...`);
// Step 3: Scrape each series page in parallel (with rate limiting)
const entries = Array.from(seriesMap.entries());
const BATCH_SIZE = 5;
const results: SeriesSummary[] = [];
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async ([asin, meta]) => {
try {
const detail = await scrapeSeriesPageSummary(asin);
if (!detail) return null;
return {
...detail,
coverArtUrl: detail.coverArtUrl || meta.coverArtUrl,
audibleUrl: `${baseUrl}/series/${asin}`,
} as SeriesSummary;
} catch (error) {
logger.warn(`Failed to scrape series ${asin}`, {
error: error instanceof Error ? error.message : String(error),
});
// Return a minimal result from search data
return {
asin,
title: meta.title,
bookCount: 0,
tags: [],
coverArtUrl: meta.coverArtUrl,
audibleUrl: `${baseUrl}/series/${asin}`,
} as SeriesSummary;
}
})
);
results.push(...batchResults.filter((r): r is SeriesSummary => r !== null));
// Rate limit between batches
if (i + BATCH_SIZE < entries.length) {
await new Promise(resolve => setTimeout(resolve, randomDelay(1500, 3000)));
}
}
logger.info(`Series search complete: "${query}" -> ${results.length} results`);
return results;
}
// ---------------------------------------------------------------------------
// Series page scraping (summary - for search results)
// ---------------------------------------------------------------------------
/**
* Scrape a series page for summary data (title, book count, rating, tags).
* Used during search to enrich each series result.
*/
async function scrapeSeriesPageSummary(asin: string): Promise<Omit<SeriesSummary, 'audibleUrl'> | null> {
const service = getAudibleService();
try {
const { data: response } = await service.fetch(`/series/${asin}`, {
params: { ipRedirectOverride: 'true' },
});
const $ = cheerio.load(response.data);
return parseSeriesPageSummary($, asin);
} catch (error) {
logger.warn(`Failed to fetch series page ${asin}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Parse summary fields from a series page's Cheerio document.
*/
function parseSeriesPageSummary(
$: cheerio.CheerioAPI,
asin: string
): Omit<SeriesSummary, 'audibleUrl'> {
// Title - from h1
const title = $('h1').first().text().trim() || '';
// Book count - multiple strategies, most specific first
let bookCount = 0;
// Primary: adbl-metadata[slot="child-count"] in the page header (NOT inside carousels)
// Filter out carousel items by excluding those inside adbl-product-carousel
$('adbl-metadata[slot="child-count"]').each((_i, el) => {
if (bookCount > 0) return false;
const $el = $(el);
// Skip if inside a carousel (those are similar-series counts)
if ($el.closest('adbl-product-carousel').length > 0) return;
const text = $el.text().trim();
const match = text.match(/(\d+)/);
if (match) bookCount = parseInt(match[1]);
});
// Secondary: text matching in spans/headings for "X books/titles/Titel/libros/Bucher"
if (bookCount === 0) {
const countText = $('span:contains("book"), span:contains("title"), span:contains("Titel"), span:contains("libro"), span:contains("Buch"), span:contains("B\u00fccher")')
.text().trim();
const countMatch = countText.match(/(\d+)\s*(books?|titles?|Titel|libros?|B(?:uch|\u00fccher))/i);
if (countMatch) {
bookCount = parseInt(countMatch[1]);
}
}
// Fallback: count product items on the page
if (bookCount === 0) {
bookCount = $('.productListItem, .bc-list-item[data-asin]').length;
}
// Rating
const { rating, ratingCount } = parseSeriesRating($);
// Tags/genres: primary from adbl-chip web components, fallback to legacy links
const tags: string[] = [];
const addTag = (text: string) => {
const tag = text.trim();
if (tag && tag.length >= 2 && tag.length <= 50 && !tags.includes(tag)) {
tags.push(tag);
}
};
// Primary: adbl-chip.related-tag elements (modern Audible layout)
$('adbl-chip.related-tag').each((_i, el) => {
addTag($(el).text());
});
// Fallback: legacy category and tag links
if (tags.length === 0) {
$('a[href*="/cat/"], a[href*="/tag/"]').each((_i, el) => {
addTag($(el).text());
});
}
// Cover art from first book image
const coverArtUrl = $('.productListItem img, .bc-list-item img').first()
.attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
return { asin, title, bookCount, rating, ratingCount, tags: tags.slice(0, 5), coverArtUrl };
}
// ---------------------------------------------------------------------------
// Series page scraping (full detail)
// ---------------------------------------------------------------------------
/**
* Scrape a series page for full detail data including books and similar series.
* Used by the detail API endpoint.
*/
export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | null> {
const service = getAudibleService();
const region = service.getRegion();
const baseUrl = service.getBaseUrl();
const langConfig = getLanguageForRegion(region);
logger.info(`Scraping series detail page: ${asin}`);
try {
const { data: response } = await service.fetch(`/series/${asin}`, {
params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE },
});
const $ = cheerio.load(response.data);
// Parse summary fields
const summary = parseSeriesPageSummary($, asin);
// Description
const description = $('.bc-expander-content').first().text().trim() ||
$('[class*="productPublisherSummary"]').first().text().trim() ||
undefined;
// Parse all books from the series page
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
// Use actual book count if we got more from scraping
const bookCount = Math.max(summary.bookCount, books.length);
// Parse similar series ("Listeners also enjoyed" or similar section)
const similarSeries = parseSimilarSeries($);
logger.info(`Series detail complete: "${summary.title}" (${books.length} books, ${similarSeries.length} similar)`);
return {
asin,
title: summary.title,
bookCount,
rating: summary.rating,
ratingCount: summary.ratingCount,
description,
tags: summary.tags,
books,
similarSeries,
audibleUrl: `${baseUrl}/series/${asin}`,
};
} catch (error) {
logger.error(`Failed to scrape series detail ${asin}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Extract rating and rating count from a series page.
*
* Real HTML uses:
* <div aria-label="4.5 out of 5 stars" class="bc-review-stars ...">
* <span class="series-rating bc-color-secondary">8,704 ratings</span>
*/
function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCount?: number } {
let rating: number | undefined;
let ratingCount: number | undefined;
// Primary: aria-label on div.bc-review-stars (e.g. "4.5 out of 5 stars")
const starsDiv = $('div.bc-review-stars');
let ariaLabel = starsDiv.attr('aria-label') || '';
// Fallback: any element with aria-label containing rating pattern
if (!ariaLabel) {
const fallbackEl = $('[aria-label*="out of"], [aria-label*="von 5"], [aria-label*="de 5"]').first();
ariaLabel = fallbackEl.attr('aria-label') || '';
}
// Extract numeric rating from aria-label (handles "4.5 out of 5", "4,5 von 5", "4,5 de 5")
const ratingMatch = ariaLabel.match(/(\d+[.,]?\d*)\s*(?:out of|von|de)\s*5/i);
if (ratingMatch) {
rating = parseFloat(ratingMatch[1].replace(',', '.'));
}
// Rating count from span.series-rating (e.g. "8,704 ratings")
const seriesRatingSpan = $('span.series-rating').first();
let countText = seriesRatingSpan.text().trim();
// Fallback: look in broader context for rating count text
if (!countText) {
const fallbackContainer = $('[class*="rating"], .ratingsLabel').first();
countText = fallbackContainer.text().trim();
}
const countMatch = countText.match(/([\d,.]+)\s*(?:ratings?|Bewertungen?|calificaciones?)/i);
if (countMatch) {
ratingCount = parseInt(countMatch[1].replace(/[.,]/g, ''));
}
return { rating, ratingCount };
}
/**
* Parse all books from a series page's product list items.
*/
function parseSeriesBooks(
$: cheerio.CheerioAPI,
authorPrefixes: string[],
narratorPrefixes: string[]
): AudibleAudiobook[] {
const books: AudibleAudiobook[] = [];
const seenAsins = new Set<string>();
$('.productListItem, .bc-list-item').each((_index, element) => {
const $el = $(element);
// Extract ASIN
const bookAsin = $el.attr('data-asin') ||
$el.find('li').attr('data-asin') ||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!bookAsin || seenAsins.has(bookAsin)) return;
seenAsins.add(bookAsin);
// Title: h3 a / .bc-heading a hold the real book title;
// h2 on series pages is the position label ("Book 1"), so try it last.
const title = $el.find('h3 a').first().text().trim() ||
$el.find('.bc-heading a').first().text().trim() ||
$el.find('h2 a').first().text().trim() ||
$el.find('h2').first().text().trim() ||
'';
if (!title) return;
// Author
const authorLink = $el.find('a[href*="/author/"]').first();
const authorText = authorLink.text().trim() ||
$el.find('.authorLabel').text().trim() ||
'';
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
// Narrator
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim() ||
'';
// Cover art
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
// Rating
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
books.push({
asin: bookAsin,
title,
author: stripPrefixes(authorText, authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, narratorPrefixes),
coverArtUrl,
rating,
});
});
return books;
}
/**
* Parse similar series from the "Listeners also enjoyed" carousel.
*
* Real HTML uses web components:
* <adbl-product-carousel id="SeriestoSeries">
* <adbl-product-grid-item>
* <div class="adbl-impression-emitted" data-asin="B0CGS1LPWJ">
* <adbl-metadata slot="title"><a>Hockey Guys</a></adbl-metadata>
* <adbl-metadata slot="child-count">3 titles</adbl-metadata>
* </adbl-product-grid-item>
*/
function parseSimilarSeries($: cheerio.CheerioAPI): SimilarSeries[] {
const similar: SimilarSeries[] = [];
const seenAsins = new Set<string>();
// Scope to the SeriestoSeries carousel to avoid picking up other series links
const carousel = $('adbl-product-carousel#SeriestoSeries');
if (carousel.length === 0) return similar;
carousel.find('adbl-product-grid-item').each((_i, el) => {
if (similar.length >= 15) return false;
const $el = $(el);
// Extract ASIN: prefer data-asin on impression div, fallback to series href
let asin = $el.find('.adbl-impression-emitted, .adbl-asin-impression').first().attr('data-asin') || '';
if (!asin) {
const seriesHref = $el.find('a[href*="/series/"]').first().attr('href') || '';
const hrefMatch = seriesHref.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
if (hrefMatch) asin = hrefMatch[1];
}
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) return;
if (seenAsins.has(asin)) return;
seenAsins.add(asin);
// Title from metadata slot
const title = $el.find('adbl-metadata[slot="title"] a').first().text().trim() ||
$el.find('adbl-metadata[slot="title"]').first().text().trim() || '';
if (!title || title.length > 200) return;
// Book count from child-count slot (e.g. "3 titles")
const countText = $el.find('adbl-metadata[slot="child-count"]').first().text().trim();
const countMatch = countText.match(/(\d+)/);
const bookCount = countMatch ? parseInt(countMatch[1]) : undefined;
// Cover image from adbl-collection-image
const coverArtUrl = $el.find('adbl-collection-image img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
$el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
undefined;
similar.push({ asin, title, bookCount, coverArtUrl });
});
return similar;
}
+269 -43
View File
@@ -8,6 +8,14 @@ import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { getConfigService } from '../services/config.service'; import { getConfigService } from '../services/config.service';
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible'; import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
import {
getLanguageForRegion,
stripPrefixes,
buildContainsSelector,
extractByPatterns,
isAcceptedLanguage,
type LanguageConfig,
} from '../constants/language-config';
import { import {
pickUserAgent, pickUserAgent,
getBrowserHeaders, getBrowserHeaders,
@@ -30,6 +38,7 @@ export interface AudibleAudiobook {
asin: string; asin: string;
title: string; title: string;
author: string; author: string;
authorAsin?: string;
narrator?: string; narrator?: string;
description?: string; description?: string;
coverArtUrl?: string; coverArtUrl?: string;
@@ -39,6 +48,7 @@ export interface AudibleAudiobook {
genres?: string[]; genres?: string[];
series?: string; series?: string;
seriesPart?: string; seriesPart?: string;
seriesAsin?: string;
} }
export interface AudibleSearchResult { export interface AudibleSearchResult {
@@ -61,6 +71,36 @@ export class AudibleService {
// Client will be created lazily on first use // Client will be created lazily on first use
} }
/**
* Get the current Audible base URL for the configured region
*/
public getBaseUrl(): string {
return this.baseUrl;
}
/**
* Get the current Audible region code
*/
public getRegion(): AudibleRegion {
return this.region;
}
/**
* Public fetch wrapper for external scraping modules (e.g. audible-series.ts).
* Ensures the service is initialized and delegates to fetchWithRetry.
*/
public async fetch(url: string, config: any = {}): Promise<{ data: any; meta: FetchResultMeta }> {
await this.initialize();
return this.fetchWithRetry(url, config);
}
/**
* Get the language config for the current region
*/
private getLangConfig(): LanguageConfig {
return getLanguageForRegion(this.region);
}
/** /**
* Force re-initialization (used when region config changes) * Force re-initialization (used when region config changes)
*/ */
@@ -98,6 +138,9 @@ export class AudibleService {
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`); logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`);
// Get language config for the region
const langConfig = getLanguageForRegion(this.region);
// Create axios client with region-specific base URL and realistic browser headers // Create axios client with region-specific base URL and realistic browser headers
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
@@ -105,7 +148,7 @@ export class AudibleService {
headers: getBrowserHeaders(this.sessionUserAgent), headers: getBrowserHeaders(this.sessionUserAgent),
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) language: langConfig.scraping.audibleLocaleParam, // Force locale (prevents IP-based language serving)
}, },
}); });
@@ -117,13 +160,16 @@ export class AudibleService {
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.sessionUserAgent = pickUserAgent(); this.sessionUserAgent = pickUserAgent();
this.pacer.reset(); this.pacer.reset();
const fallbackLangConfig = getLanguageForRegion(this.region);
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 15000, timeout: 15000,
headers: getBrowserHeaders(this.sessionUserAgent), headers: getBrowserHeaders(this.sessionUserAgent),
params: { params: {
ipRedirectOverride: 'true', ipRedirectOverride: 'true',
language: 'english', language: fallbackLangConfig.scraping.audibleLocaleParam,
}, },
}); });
this.initialized = true; this.initialized = true;
@@ -269,6 +315,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() || const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim(); $el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link if available
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText = $el.find('.narratorLabel').text().trim() || const narratorText = $el.find('.narratorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim(); $el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
@@ -277,11 +327,14 @@ export class AudibleService {
const ratingText = $el.find('.ratingsLabel').text().trim(); const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const langConfig = this.getLangConfig();
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
narrator: narratorText.replace('Narrated by:', '').trim(), authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating, rating,
}); });
@@ -367,6 +420,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() || const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim(); $el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link if available
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText = $el.find('.narratorLabel').text().trim(); const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || ''; const coverArtUrl = $el.find('img').attr('src') || '';
@@ -374,11 +431,14 @@ export class AudibleService {
const ratingText = $el.find('.ratingsLabel').text().trim(); const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const langConfig = this.getLangConfig();
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
narrator: narratorText.replace('Narrated by:', '').trim(), authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating, rating,
}); });
@@ -454,19 +514,26 @@ export class AudibleService {
$el.find('.bc-heading a').text().trim(); $el.find('.bc-heading a').text().trim();
// Extract author from author link // Extract author from author link
const authorText = $el.find('a[href*="/author/"]').first().text().trim() || const authorLink = $el.find('a[href*="/author/"]').first();
const authorText = authorLink.text().trim() ||
$el.find('.authorLabel').text().trim() || $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim(); $el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link href
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
// Extract narrator from narrator search link // Extract narrator from narrator search link
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim(); $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || ''; const coverArtUrl = $el.find('img').attr('src') || '';
const langConfig = this.getLangConfig();
// Extract runtime/duration // Extract runtime/duration
const runtimeText = $el.find('.runtimeLabel').text().trim() || const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find('span:contains("Length:")').text().trim(); $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText); const durationMinutes = this.parseRuntime(runtimeText);
// Extract rating // Extract rating
@@ -477,8 +544,9 @@ export class AudibleService {
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
narrator: narratorText.replace('Narrated by:', '').trim(), authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes, durationMinutes,
rating, rating,
@@ -510,6 +578,131 @@ export class AudibleService {
} }
} }
/**
* Search for all books by a specific author, validated by ASIN.
* Uses Audible's searchAuthor parameter and paginates through all results.
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
*/
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> {
await this.initialize();
const MAX_PAGES = 10;
const allBooks: AudibleAudiobook[] = [];
const seenAsins = new Set<string>();
try {
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`);
for (let page = 1; page <= MAX_PAGES; page++) {
const { data: response, meta } = await this.fetchWithRetry('/search', {
params: {
ipRedirectOverride: 'true',
searchAuthor: authorName,
pageSize: AUDIBLE_PAGE_SIZE,
page,
},
});
const $ = cheerio.load(response.data);
let pageResults = 0;
$('.s-result-item, .productListItem').each((_index, element) => {
const $el = $(element);
// --- Language filter: require matching language for region ---
const langConfig = this.getLangConfig();
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
$el.find('.languageLabel').text().trim();
// Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
const langMatch = langText.match(langLabelPattern);
const language = langMatch?.[1]?.trim();
if (!language || !isAcceptedLanguage(language, langConfig)) return;
// --- Author ASIN filter: verify target ASIN in author links ---
const authorLinks = $el.find('a[href*="/author/"]');
let hasMatchingAuthor = false;
authorLinks.each((_i, link) => {
const href = $(link).attr('href') || '';
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (asinMatch && asinMatch[1] === authorAsin) {
hasMatchingAuthor = true;
return false; // break .each()
}
});
if (!hasMatchingAuthor) return;
// --- Extract book ASIN ---
const bookAsin = $el.find('li').attr('data-asin') ||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!bookAsin || seenAsins.has(bookAsin)) return;
seenAsins.add(bookAsin);
// --- Parse book details ---
const title = $el.find('h2').first().text().trim() ||
$el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
allBooks.push({
asin: bookAsin,
title,
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
});
pageResults++;
});
// Check if there are more pages
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
if (!hasMore || pageResults === 0) break;
// Pace between pages
if (page < MAX_PAGES) {
await this.delay(this.pacer.reportPageResult(meta));
}
}
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
return allBooks;
} catch (error) {
logger.error(`Author books search failed for "${authorName}"`, {
error: error instanceof Error ? error.message : String(error),
collectedSoFar: allBooks.length,
});
// Return what we collected before the error
return allBooks;
}
}
/** /**
* Get detailed audiobook information * Get detailed audiobook information
* Primary: Audnexus API (reliable, structured data) * Primary: Audnexus API (reliable, structured data)
@@ -563,6 +756,7 @@ export class AudibleService {
asin, asin,
title: data.title || '', title: data.title || '',
author: data.authors?.map((a: any) => a.name).join(', ') || '', author: data.authors?.map((a: any) => a.name).join(', ') || '',
authorAsin: data.authors?.[0]?.asin || undefined,
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '', narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
description: data.description || data.summary || '', description: data.description || data.summary || '',
coverArtUrl: data.image || '', coverArtUrl: data.image || '',
@@ -572,6 +766,7 @@ export class AudibleService {
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined, genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
series: data.seriesPrimary?.name || undefined, series: data.seriesPrimary?.name || undefined,
seriesPart: data.seriesPrimary?.position || undefined, seriesPart: data.seriesPrimary?.position || undefined,
seriesAsin: data.seriesPrimary?.asin || undefined,
}; };
// Ensure cover art URL is high quality // Ensure cover art URL is high quality
@@ -588,7 +783,8 @@ export class AudibleService {
rating: result.rating, rating: result.rating,
genreCount: result.genres?.length || 0, genreCount: result.genres?.length || 0,
series: result.series, series: result.series,
seriesPart: result.seriesPart seriesPart: result.seriesPart,
seriesAsin: result.seriesAsin
}); });
return result; return result;
@@ -719,10 +915,20 @@ export class AudibleService {
result.author = [...new Set(authors)].slice(0, 3).join(', '); result.author = [...new Set(authors)].slice(0, 3).join(', ');
} }
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim(); const authorLangConfig = this.getLangConfig();
result.author = stripPrefixes(result.author, authorLangConfig.scraping.authorPrefixes);
logger.info(` Author from HTML: "${result.author}"`); logger.info(` Author from HTML: "${result.author}"`);
} }
// Author ASIN - extract from the first author link
if (!result.authorAsin) {
const firstAuthorHref = $('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = firstAuthorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (authorAsinMatch) {
result.authorAsin = authorAsinMatch[1];
}
}
// Narrator - try multiple approaches (only in product details area) // Narrator - try multiple approaches (only in product details area)
if (!result.narrator) { if (!result.narrator) {
// Look specifically in the product details section // Look specifically in the product details section
@@ -754,22 +960,16 @@ export class AudibleService {
} }
if (result.narrator) { if (result.narrator) {
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim(); const detailLangConfig = this.getLangConfig();
result.narrator = stripPrefixes(result.narrator, detailLangConfig.scraping.narratorPrefixes);
} }
logger.info(` Narrator from HTML: "${result.narrator || ''}"`); logger.info(` Narrator from HTML: "${result.narrator || ''}"`);
} }
// Description - try multiple approaches with strict filtering // Description - try multiple approaches with strict filtering
if (!result.description) { if (!result.description) {
const excludePatterns = [ const descLangConfig = this.getLangConfig();
/\$\d+\.\d+/, // Price patterns const excludePatterns = descLangConfig.scraping.descriptionExcludePatterns;
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i, // Just author names
];
const isValidDescription = (text: string): boolean => { const isValidDescription = (text: string): boolean => {
if (!text || text.length < 50 || text.length > 5000) return false; if (!text || text.length < 50 || text.length > 5000) return false;
@@ -825,18 +1025,20 @@ export class AudibleService {
// Runtime/Duration - try multiple approaches // Runtime/Duration - try multiple approaches
if (!result.durationMinutes) { if (!result.durationMinutes) {
const rtLangConfig = this.getLangConfig();
// Look for runtime text in various places // Look for runtime text in various places
const runtimeText = const runtimeText =
$('li.runtimeLabel span').text().trim() || $('li.runtimeLabel span').text().trim() ||
$('.runtimeLabel').text().trim() || $('.runtimeLabel').text().trim() ||
$('span:contains("Length:")').parent().text().trim() || $(buildContainsSelector('span', rtLangConfig.scraping.lengthLabels)).parent().text().trim() ||
$('li:contains("Length:")').text().trim() || $(buildContainsSelector('li', rtLangConfig.scraping.lengthLabels)).text().trim() ||
(() => { (() => {
// Look for any text matching duration pattern // Look for any text matching duration pattern
let found = ''; let found = '';
$('li, span, div').each((_, elem) => { $('li, span, div').each((_, elem) => {
const text = $(elem).text().trim(); const text = $(elem).text().trim();
if (text.match(/\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i) && text.length < 100) { if (text.match(rtLangConfig.scraping.durationDetectionPattern) && text.length < 100) {
found = text; found = text;
return false; // break return false; // break
} }
@@ -850,41 +1052,55 @@ export class AudibleService {
// Rating - try multiple approaches // Rating - try multiple approaches
if (!result.rating) { if (!result.rating) {
const ratingLangConfig = this.getLangConfig();
const ratingText = const ratingText =
$('.ratingsLabel').text().trim() || $('.ratingsLabel').text().trim() ||
$('[class*="rating"]').first().text().trim() || $('[class*="rating"]').first().text().trim() ||
$('span:contains("out of 5 stars")').parent().text().trim() || $(`span:contains("${ratingLangConfig.scraping.ratingTextSelector}")`).parent().text().trim() ||
(() => { (() => {
// Look for rating pattern // Look for rating pattern using language-specific patterns
let found = ''; let found = '';
$('span, div').each((_, elem) => { $('span, div').each((_, elem) => {
const text = $(elem).text().trim(); const text = $(elem).text().trim();
if (text.match(/\d+\.?\d*\s*out of\s*5/i) && text.length < 50) { if (text.length < 50) {
for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
if (pattern.test(text)) {
found = text; found = text;
return false; return false;
} }
}
}
}); });
return found; return found;
})(); })();
if (ratingText) { if (ratingText) {
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i); let ratingValue: number | undefined;
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined; for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
const ratingMatch = ratingText.match(pattern);
if (ratingMatch) {
// Handle comma as decimal separator (e.g. "4,5" in German/Spanish)
ratingValue = parseFloat(ratingMatch[1].replace(',', '.'));
break;
}
}
result.rating = ratingValue;
} }
logger.info(` Rating from "${ratingText}": ${result.rating}`); logger.info(` Rating from "${ratingText}": ${result.rating}`);
} }
// Release date - try multiple selectors // Release date - try multiple selectors
if (!result.releaseDate) { if (!result.releaseDate) {
const rdLangConfig = this.getLangConfig();
const releaseDateText = const releaseDateText =
$('li:contains("Release date:")').text().trim() || $(buildContainsSelector('li', rdLangConfig.scraping.releaseDateLabels)).text().trim() ||
$('span:contains("Release date:")').parent().text().trim() || $(buildContainsSelector('span', rdLangConfig.scraping.releaseDateLabels)).parent().text().trim() ||
$('[class*="release"]').text().trim(); $('[class*="release"]').text().trim();
const dateMatch = releaseDateText.match(/Release date:\s*(.+)/i) || const dateMatch = extractByPatterns(releaseDateText, rdLangConfig.scraping.releaseDatePatterns) ||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/); releaseDateText.match(/(\w+ \d{1,2},? \d{4})/)?.[1];
if (dateMatch) { if (dateMatch) {
result.releaseDate = dateMatch[1].trim(); result.releaseDate = dateMatch.trim();
} }
logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`); logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`);
} }
@@ -921,20 +1137,30 @@ export class AudibleService {
} }
/** /**
* Parse runtime text to minutes * Parse runtime text to minutes using language-specific patterns
*/ */
private parseRuntime(runtimeText: string): number | undefined { private parseRuntime(runtimeText: string): number | undefined {
if (!runtimeText) return undefined; if (!runtimeText) return undefined;
const hoursMatch = runtimeText.match(/(\d+)\s*hrs?/i); const langConfig = this.getLangConfig();
const minutesMatch = runtimeText.match(/(\d+)\s*mins?/i);
let totalMinutes = 0; let totalMinutes = 0;
if (hoursMatch) {
totalMinutes += parseInt(hoursMatch[1]) * 60; // Try each hour pattern until one matches
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]) * 60;
break;
}
}
// Try each minute pattern until one matches
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]);
break;
} }
if (minutesMatch) {
totalMinutes += parseInt(minutesMatch[1]);
} }
return totalMinutes > 0 ? totalMinutes : undefined; return totalMinutes > 0 ? totalMinutes : undefined;
+104
View File
@@ -0,0 +1,104 @@
/**
* Component: Audnexus Author API Integration
* Documentation: documentation/integrations/audible.md
*
* Shared utilities for fetching author data from the Audnexus API.
* Used by author search, author detail, and similar authors routes.
*/
import axios from 'axios';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('Audnexus.Authors');
const AUDNEXUS_BASE = 'https://api.audnex.us';
const AUDNEXUS_TIMEOUT = 10000;
const AUDNEXUS_HEADERS = { 'User-Agent': 'ReadMeABook/1.0' };
export interface AudnexusAuthorSearch {
asin: string;
name: string;
}
export interface AudnexusAuthorGenre {
asin: string;
name: string;
type: string;
}
export interface AudnexusAuthorSimilar {
asin: string;
name: string;
}
export interface AudnexusAuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
region: string;
genres?: AudnexusAuthorGenre[];
similar?: AudnexusAuthorSimilar[];
}
/**
* Fetch with retry and exponential backoff for Audnexus API
*/
export async function audnexusFetchWithRetry(url: string, params: Record<string, string>, maxRetries = 3): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await axios.get(url, {
params,
timeout: AUDNEXUS_TIMEOUT,
headers: AUDNEXUS_HEADERS,
});
} catch (error: any) {
lastError = error;
const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
if (!isRetryable) throw error;
if (attempt === maxRetries) break;
const backoffMs = Math.pow(2, attempt) * 1000;
logger.info(`Audnexus request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, backoffMs));
}
}
throw lastError || new Error('Audnexus request failed after retries');
}
/**
* Search authors via Audnexus and return deduplicated results
*/
export async function searchAuthors(name: string, region: string): Promise<AudnexusAuthorSearch[]> {
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors`, { region, name });
const results: AudnexusAuthorSearch[] = response.data;
const seen = new Set<string>();
return results.filter(author => {
if (seen.has(author.asin)) return false;
seen.add(author.asin);
return true;
});
}
/**
* Fetch full author details from Audnexus
*/
export async function fetchAuthorDetail(asin: string, region: string): Promise<AudnexusAuthorDetail | null> {
try {
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors/${asin}`, { region });
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
logger.debug(`Author not found on Audnexus: ${asin}`);
} else {
logger.warn(`Failed to fetch author detail: ${asin}`, { error: error.message });
}
return null;
}
}
+386
View File
@@ -0,0 +1,386 @@
/**
* Component: Deluge Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient, DownloadClientType, ProtocolType,
DownloadInfo, DownloadStatus, AddDownloadOptions, ConnectionTestResult,
} from '../interfaces/download-client.interface';
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('Deluge');
export class DelugeService implements IDownloadClient {
readonly clientType: DownloadClientType = 'deluge';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private password: string;
private defaultSavePath: string;
private defaultCategory: string;
private pathMappingConfig: PathMappingConfig;
private sessionCookie: string = '';
private requestId: number = 0;
constructor(
baseUrl: string,
_username: string, // Unused — Deluge uses password-only auth; kept for consistent signature
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
const httpsAgent = disableSSLVerify && this.baseUrl.startsWith('https')
? new https.Agent({ rejectUnauthorized: false }) : undefined;
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent });
}
/** JSON-RPC call with automatic re-authentication on auth failure */
private async rpc(method: string, params: any[] = [], retried = false): Promise<any> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.sessionCookie) headers['Cookie'] = this.sessionCookie;
try {
const reqId = ++this.requestId;
const { data } = await this.client.post('/json', { method, params, id: reqId }, { headers });
// Deluge error.code === 1: "Not authenticated" — re-login then retry
if (data.error?.code === 1 && !retried) {
await this.login();
return this.rpc(method, params, true);
}
// Deluge error.code === 2: "Unknown method" — daemon disconnected, force reconnect
// Only retry for core.* methods — plugin methods (label.*) fail because the plugin
// isn't enabled, not because the daemon is disconnected.
if (data.error?.code === 2 && !retried && method.startsWith('core.')) {
await this.login(true);
return this.rpc(method, params, true);
}
return data;
} catch (error) {
if (!retried) { await this.login(); return this.rpc(method, params, true); }
throw error;
}
}
private async login(forceReconnect: boolean = false): Promise<void> {
const { data, headers } = await this.client.post(
'/json',
{ method: 'auth.login', params: [this.password], id: ++this.requestId },
{ headers: { 'Content-Type': 'application/json' } }
);
if (!data?.result) throw new Error('Failed to authenticate with Deluge — check your password');
const cookies = headers['set-cookie'];
if (cookies?.length) this.sessionCookie = cookies[0].split(';')[0];
logger.info('Successfully authenticated with Deluge');
// Deluge Web UI requires a daemon connection before core.* methods work.
// When forceReconnect is true, skip the web.connected check and force a fresh connection.
await this.ensureDaemonConnected(forceReconnect);
}
/**
* Ensure the Web UI is connected to a deluged daemon host.
* Uses web.connected (returns boolean) as the check daemon.info is NOT a valid
* method through the Deluge Web UI JSON-RPC; only web.* and core.* methods work.
*/
private async ensureDaemonConnected(force: boolean = false): Promise<void> {
if (!force) {
const test = await this.rpc('web.connected', [], true);
if (test.result === true) return;
}
logger.info('Connecting to daemon...');
const hostsData = await this.rpc('web.get_hosts', [], true);
const hosts: any[] = hostsData.result || [];
if (hosts.length === 0) {
throw new Error('Deluge has no daemon hosts configured. Add a host in the Deluge Web UI under Connection Manager.');
}
const hostId = hosts[0][0];
const connectResult = await this.rpc('web.connect', [hostId], true);
if (connectResult.error) {
throw new Error(`Failed to connect to Deluge daemon: ${connectResult.error.message}`);
}
// Verify connection is established
const verify = await this.rpc('web.connected', [], true);
if (verify.result !== true) {
throw new Error('Deluge daemon failed to respond after web.connect. Check that deluged is running.');
}
logger.info('Connected to Deluge daemon');
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
return { success: true, message: 'Connected to Deluge' };
} catch (error) {
const msg = error instanceof Error ? error.message : 'Connection failed';
if (axios.isAxiosError(error)) {
const c = error.code;
if (c?.includes('CERT') || c?.includes('SSL')) return { success: false, message: `SSL verification failed (${c}). Enable "Disable SSL Verification".` };
if (c === 'ECONNREFUSED') return { success: false, message: `Connection refused at: ${this.baseUrl}` };
if (c === 'ETIMEDOUT' || c === 'ECONNABORTED') return { success: false, message: `Connection timeout: ${this.baseUrl}` };
if (c === 'ENOTFOUND') return { success: false, message: `Host not found: ${this.baseUrl}` };
if (error.response?.status === 401) return { success: false, message: 'Authentication failed. Check your password.' };
}
logger.error('Connection test failed', { error: msg });
return { success: false, message: msg };
}
}
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
if (!url || typeof url !== 'string' || url.trim() === '') {
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
}
const category = options?.category || this.defaultCategory;
return url.startsWith('magnet:')
? this.addMagnetLink(url, category, options)
: this.addTorrentFile(url, category, options);
}
private async addMagnetLink(magnetUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) throw new Error('Invalid magnet link - could not extract info_hash');
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
if (existing.result && Object.keys(existing.result).length > 0) {
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
return infoHash;
}
const opts = this.buildTorrentOptions(options?.paused);
const data = await this.rpc('core.add_torrent_magnet', [magnetUrl, opts]);
if (!data.result) throw new Error(`Deluge rejected magnet link: ${data.error?.message || 'unknown error'}`);
await this.postAddSetup(data.result, category);
logger.info(`Successfully added magnet link: ${infoHash}`);
return infoHash;
}
private async addTorrentFile(torrentUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer', maxRedirects: 0,
validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT,
});
if (torrentResponse.data.length > 0) {
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
if (magnetMatch) return this.addMagnetLink(magnetMatch[0], category, options);
}
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) throw error;
const status = error.response.status;
if (status >= 300 && status < 400) {
const loc = error.response.headers['location'];
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5 }); }
catch { throw new Error('Failed to download torrent file after redirect'); }
} else { throw new Error(`Invalid redirect location: ${loc}`); }
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
}
const torrentBuffer = Buffer.from(torrentResponse.data);
let parsed: any;
try { parsed = await parseTorrent(torrentBuffer); }
catch { throw new Error('Invalid .torrent file - failed to parse'); }
const infoHash = parsed.infoHash;
if (!infoHash) throw new Error('Failed to extract info_hash from .torrent file');
logger.info(`Extracted info_hash: ${infoHash}`);
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
if (existing.result && Object.keys(existing.result).length > 0) {
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
return infoHash;
}
const filename = parsed.name ? `${parsed.name}.torrent` : 'torrent.torrent';
const opts = this.buildTorrentOptions(options?.paused);
const data = await this.rpc('core.add_torrent_file', [filename, torrentBuffer.toString('base64'), opts]);
if (!data.result) throw new Error(`Deluge rejected .torrent file: ${data.error?.message || 'unknown error'}`);
await this.postAddSetup(infoHash, category);
logger.info(`Successfully added torrent: ${infoHash}`);
return infoHash;
}
async getDownload(id: string): Promise<DownloadInfo | null> {
const fields = ['name', 'total_size', 'total_done', 'progress', 'state',
'download_payload_rate', 'eta', 'label', 'save_path',
'time_added', 'is_finished', 'seeding_time', 'ratio', 'message'];
for (let attempt = 0; attempt <= 3; attempt++) {
const { result } = await this.rpc('core.get_torrent_status', [id, fields]);
if (result && Object.keys(result).length > 0) return this.mapToDownloadInfo(id, result);
if (attempt === 3) return null;
const delay = 500 * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delay}ms (${attempt + 1}/3)`);
await new Promise(r => setTimeout(r, delay));
}
return null;
}
async pauseDownload(id: string): Promise<void> {
await this.rpc('core.pause_torrent', [[id]]);
logger.info(`Paused torrent: ${id}`);
}
async resumeDownload(id: string): Promise<void> {
await this.rpc('core.resume_torrent', [[id]]);
logger.info(`Resumed torrent: ${id}`);
}
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
await this.rpc('core.remove_torrent', [id, deleteFiles]);
logger.info(`Deleted torrent: ${id}`);
}
async postProcess(_id: string): Promise<void> {} // No-op: seeding cleanup scheduler manages lifecycle
async getCategories(): Promise<string[]> {
try { const { result } = await this.rpc('label.get_labels'); return Array.isArray(result) ? result : []; }
catch { return []; }
}
async setCategory(id: string, category: string): Promise<void> {
await this.applyLabel(id, category);
logger.info(`Set label for torrent ${id}: ${category}`);
}
// =========================================================================
// Internal Helpers
// =========================================================================
private buildTorrentOptions(paused?: boolean): Record<string, any> {
const remoteSavePath = PathMapper.reverseTransform(this.defaultSavePath, this.pathMappingConfig);
const opts: Record<string, any> = { download_location: remoteSavePath, move_completed: false, move_completed_path: '' };
if (paused) opts.add_paused = true;
return opts;
}
private async postAddSetup(hash: string, category: string): Promise<void> {
await this.disableSeedLimits(hash);
await this.applyLabel(hash, category);
}
private async applyLabel(hash: string, label: string): Promise<void> {
try {
try { await this.rpc('label.add', [label]); } catch { /* may already exist */ }
await this.rpc('label.set_torrent', [hash, label]);
} catch (error) {
logger.warn(`Failed to apply label "${label}" to ${hash}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async disableSeedLimits(hash: string): Promise<void> {
try {
await this.rpc('core.set_torrent_options', [[hash], { stop_at_ratio: false, seed_time_limit: -1 }]);
} catch (error) {
logger.warn(`Failed to disable seed limits for ${hash}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private mapToDownloadInfo(hash: string, t: Record<string, any>): DownloadInfo {
return {
id: hash, name: t.name || '', size: t.total_size || 0,
bytesDownloaded: t.total_done || 0, progress: (t.progress || 0) / 100,
status: this.mapStatus(t.state), downloadSpeed: t.download_payload_rate || 0,
eta: t.eta > 0 ? t.eta : 0, category: t.label || '',
downloadPath: t.save_path ? path.join(t.save_path, t.name || '') : undefined,
completedAt: t.is_finished && t.time_added ? new Date(t.time_added * 1000) : undefined,
errorMessage: t.message || undefined, seedingTime: t.seeding_time,
ratio: t.ratio >= 0 ? t.ratio : undefined,
};
}
private mapStatus(state: string): DownloadStatus {
const map: Record<string, DownloadStatus> = {
'Downloading': 'downloading', 'Seeding': 'seeding', 'Paused': 'paused',
'Checking': 'checking', 'Queued': 'queued', 'Error': 'failed', 'Moving': 'downloading',
};
return map[state] || 'downloading';
}
private extractHashFromMagnet(magnetUrl: string): string | null {
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
return match ? match[1].toLowerCase() : null;
}
}
// Singleton factory (matches Transmission/qBittorrent pattern)
let delugeServiceInstance: DelugeService | null = null;
let configLoaded = false;
export async function getDelugeService(): Promise<DelugeService> {
if (delugeServiceInstance && configLoaded) return delugeServiceInstance;
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) throw new Error('Deluge is not configured. Please configure a Deluge client in admin settings.');
if (clientConfig.type !== 'deluge') throw new Error(`Expected Deluge client but found ${clientConfig.type}`);
if (!clientConfig.url) throw new Error('Deluge is not fully configured. Check your configuration in admin settings.');
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath ? require('path').join(baseDir, clientConfig.customPath) : baseDir;
delugeServiceInstance = new DelugeService(
clientConfig.url, clientConfig.username || '', clientConfig.password || '',
downloadDir, clientConfig.category || 'readmeabook', clientConfig.disableSSLVerify,
{ enabled: clientConfig.remotePathMappingEnabled || false, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '' }
);
const result = await delugeServiceInstance.testConnection();
if (!result.success) throw new Error(result.message || 'Deluge connection test failed.');
logger.info('[Deluge] Connection test successful');
configLoaded = true;
return delugeServiceInstance;
} catch (error) {
logger.error('[Deluge] Failed to initialize service', { error: error instanceof Error ? error.message : String(error) });
delugeServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateDelugeService(): void {
delugeServiceInstance = null;
configLoaded = false;
logger.info('[Deluge] Service singleton invalidated');
}
+15 -2
View File
@@ -5,6 +5,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import { TorrentResult } from '../utils/ranking-algorithm'; import { TorrentResult } from '../utils/ranking-algorithm';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
@@ -87,7 +88,7 @@ export class ProwlarrService {
headers: { headers: {
'X-Api-Key': this.apiKey, 'X-Api-Key': this.apiKey,
}, },
timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download timeout: DOWNLOAD_CLIENT_TIMEOUT,
paramsSerializer: { paramsSerializer: {
serialize: (params) => { serialize: (params) => {
// Custom serializer to handle arrays correctly for Prowlarr API // Custom serializer to handle arrays correctly for Prowlarr API
@@ -314,7 +315,7 @@ export class ProwlarrService {
limit: 100, limit: 100,
extended: 1, extended: 1,
}, },
timeout: 60000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
responseType: 'text', // Get XML as text responseType: 'text', // Get XML as text
}); });
@@ -640,6 +641,18 @@ export class ProwlarrService {
// Singleton instance // Singleton instance
let prowlarrService: ProwlarrService | null = null; let prowlarrService: ProwlarrService | null = null;
/**
* Invalidate the cached ProwlarrService singleton.
* Must be called after updating Prowlarr URL or API key so that
* background jobs (search, RSS monitor, etc.) pick up the new credentials.
*/
export function invalidateProwlarrService(): void {
if (prowlarrService) {
logger.info('Prowlarr service singleton invalidated — will reconnect with new credentials on next use');
}
prowlarrService = null;
}
export async function getProwlarrService(): Promise<ProwlarrService> { export async function getProwlarrService(): Promise<ProwlarrService> {
if (!prowlarrService) { if (!prowlarrService) {
// Get configuration from database // Get configuration from database
+35 -28
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data'; import FormData from 'form-data';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
@@ -27,12 +28,10 @@ const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('QBittorrent'); const logger = RMABLogger.create('QBittorrent');
export interface AddTorrentOptions { export interface AddTorrentOptions {
savePath?: string;
category?: string; category?: string;
tags?: string[]; tags?: string[];
paused?: boolean; paused?: boolean;
skipChecking?: boolean; skipChecking?: boolean;
sequentialDownload?: boolean;
} }
export interface TorrentInfo { export interface TorrentInfo {
@@ -142,7 +141,7 @@ export class QBittorrentService implements IDownloadClient {
this.client = axios.create({ this.client = axios.create({
baseURL: `${this.baseUrl}/api/v2`, baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
httpsAgent: this.httpsAgent, httpsAgent: this.httpsAgent,
// Support nginx/Apache reverse proxy with HTTP Basic Auth // Support nginx/Apache reverse proxy with HTTP Basic Auth
auth: { auth: {
@@ -276,7 +275,7 @@ export class QBittorrentService implements IDownloadClient {
/** /**
* Add magnet link - hash is extractable from URI (deterministic) * Add magnet link - hash is extractable from URI (deterministic)
*/ */
private async addMagnetLink( protected async addMagnetLink(
magnetUrl: string, magnetUrl: string,
category: string, category: string,
options?: AddTorrentOptions options?: AddTorrentOptions
@@ -299,20 +298,18 @@ export class QBittorrentService implements IDownloadClient {
// Torrent doesn't exist, continue with adding // Torrent doesn't exist, continue with adding
} }
// Apply reverse path mapping (local → remote) to savepath
const localSavePath = options?.savePath || this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload via 'urls' parameter // Upload via 'urls' parameter
// Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's // Note: savepath is intentionally omitted — the category (managed by ensureCategory)
// defines the save path. Omitting per-torrent savepath allows qBittorrent to use
// Automatic Torrent Management, respecting the user's "incomplete downloads" temp folder.
// sequentialDownload is also omitted — left to qBittorrent's own settings.
// ratioLimit and seedingTimeLimit are set to -1 (unlimited) so qBittorrent's
// global seeding rules don't remove the torrent prematurely. // global seeding rules don't remove the torrent prematurely.
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor. // RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const form = new URLSearchParams({ const form = new URLSearchParams({
urls: magnetUrl, urls: magnetUrl,
savepath: remoteSavePath,
category, category,
paused: options?.paused ? 'true' : 'false', paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
ratioLimit: '-1', ratioLimit: '-1',
seedingTimeLimit: '-1', seedingTimeLimit: '-1',
}); });
@@ -341,7 +338,7 @@ export class QBittorrentService implements IDownloadClient {
/** /**
* Add .torrent file - download, parse, extract hash, upload content (deterministic) * Add .torrent file - download, parse, extract hash, upload content (deterministic)
*/ */
private async addTorrentFile( protected async addTorrentFile(
torrentUrl: string, torrentUrl: string,
category: string, category: string,
options?: AddTorrentOptions options?: AddTorrentOptions
@@ -356,7 +353,7 @@ export class QBittorrentService implements IDownloadClient {
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxRedirects: 0, maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success
timeout: 30000, // 30 seconds - public indexers can be slow timeout: DOWNLOAD_CLIENT_TIMEOUT,
}); });
logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`); logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`);
@@ -398,7 +395,7 @@ export class QBittorrentService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(location, { torrentResponse = await axios.get(location, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
maxRedirects: 5, maxRedirects: 5,
}); });
logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`); logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`);
@@ -446,11 +443,13 @@ export class QBittorrentService implements IDownloadClient {
// Torrent doesn't exist, continue with adding // Torrent doesn't exist, continue with adding
} }
// Apply reverse path mapping (local → remote) to savepath
const localSavePath = options?.savePath || this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload .torrent file content via multipart/form-data // Upload .torrent file content via multipart/form-data
// Note: savepath is intentionally omitted — the category (managed by ensureCategory)
// defines the save path. Omitting per-torrent savepath allows qBittorrent to use
// Automatic Torrent Management, respecting the user's "incomplete downloads" temp folder.
// sequentialDownload is also omitted — left to qBittorrent's own settings.
// ratioLimit and seedingTimeLimit override qBittorrent's global seeding rules —
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const formData = new FormData(); const formData = new FormData();
const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent'; const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent';
@@ -458,11 +457,8 @@ export class QBittorrentService implements IDownloadClient {
filename, filename,
contentType: 'application/x-bittorrent', contentType: 'application/x-bittorrent',
}); });
formData.append('savepath', remoteSavePath);
formData.append('category', category); formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false'); formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
// Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle
formData.append('ratioLimit', '-1'); formData.append('ratioLimit', '-1');
formData.append('seedingTimeLimit', '-1'); formData.append('seedingTimeLimit', '-1');
@@ -494,7 +490,7 @@ export class QBittorrentService implements IDownloadClient {
* Checks existing categories first, then creates or updates as needed * Checks existing categories first, then creates or updates as needed
* Applies reverse path mapping (local remote) for remote seedbox scenarios * Applies reverse path mapping (local remote) for remote seedbox scenarios
*/ */
private async ensureCategory(category: string): Promise<void> { protected async ensureCategory(category: string): Promise<void> {
if (!this.cookie) { if (!this.cookie) {
await this.login(); await this.login();
} }
@@ -591,7 +587,19 @@ export class QBittorrentService implements IDownloadClient {
throw new Error(`Torrent ${hash} not found`); throw new Error(`Torrent ${hash} not found`);
} }
return torrents[0]; // Find the torrent with the exact matching hash.
// Some qBittorrent-compatible clients (e.g. RDTClient) ignore the hashes
// filter and return all torrents, so we must verify the hash ourselves.
const normalizedHash = hash.toLowerCase();
const match = torrents.find(
(t: TorrentInfo) => t.hash?.toLowerCase() === normalizedHash
);
if (!match) {
throw new Error(`Torrent ${hash} not found`);
}
return match;
} catch (error) { } catch (error) {
// Don't log error here - caller handles it (e.g., duplicate checking) // Don't log error here - caller handles it (e.g., duplicate checking)
throw error; throw error;
@@ -1013,7 +1021,6 @@ export class QBittorrentService implements IDownloadClient {
category: options?.category, category: options?.category,
paused: options?.paused, paused: options?.paused,
tags: ['audiobook'], tags: ['audiobook'],
sequentialDownload: true,
}); });
} }
@@ -1081,7 +1088,7 @@ export class QBittorrentService implements IDownloadClient {
/** /**
* Map a TorrentInfo object to the unified DownloadInfo format. * Map a TorrentInfo object to the unified DownloadInfo format.
*/ */
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
return { return {
id: torrent.hash, id: torrent.hash,
name: torrent.name, name: torrent.name,
@@ -1109,7 +1116,7 @@ export class QBittorrentService implements IDownloadClient {
stalledDL: 'downloading', stalledDL: 'downloading',
stalledUP: 'seeding', stalledUP: 'seeding',
pausedDL: 'paused', pausedDL: 'paused',
// pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met) // pausedUP = download finished, paused on upload side (e.g. ratio met)
pausedUP: 'seeding', pausedUP: 'seeding',
queuedDL: 'queued', queuedDL: 'queued',
queuedUP: 'seeding', queuedUP: 'seeding',
@@ -1164,7 +1171,7 @@ export class QBittorrentService implements IDownloadClient {
stalledDL: 'downloading', stalledDL: 'downloading',
stalledUP: 'completed', stalledUP: 'completed',
pausedDL: 'paused', pausedDL: 'paused',
// pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met) // pausedUP = download finished, paused on upload side (e.g. ratio met)
pausedUP: 'completed', pausedUP: 'completed',
queuedDL: 'queued', queuedDL: 'queued',
queuedUP: 'completed', queuedUP: 'completed',
@@ -1194,7 +1201,7 @@ export class QBittorrentService implements IDownloadClient {
/** /**
* Extract info_hash from magnet link * Extract info_hash from magnet link
*/ */
private extractHashFromMagnet(magnetUrl: string): string | null { protected extractHashFromMagnet(magnetUrl: string): string | null {
// Extract hash from magnet:?xt=urn:btih:HASH // Extract hash from magnet:?xt=urn:btih:HASH
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i); const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) { if (match) {
+4 -3
View File
@@ -6,6 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https'; import https from 'https';
import path from 'path'; import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
@@ -106,7 +107,7 @@ export class TransmissionService implements IDownloadClient {
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
httpsAgent: this.httpsAgent, httpsAgent: this.httpsAgent,
}); });
} }
@@ -274,7 +275,7 @@ export class TransmissionService implements IDownloadClient {
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxRedirects: 0, maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300, validateStatus: (status) => status >= 200 && status < 300,
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
}); });
// Check if response body is a magnet link // Check if response body is a magnet link
@@ -302,7 +303,7 @@ export class TransmissionService implements IDownloadClient {
try { try {
torrentResponse = await axios.get(location, { torrentResponse = await axios.get(location, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000, timeout: DOWNLOAD_CLIENT_TIMEOUT,
maxRedirects: 5, maxRedirects: 5,
}); });
} catch { } catch {
@@ -11,7 +11,7 @@
// ========================================================================= // =========================================================================
/** Supported download client types — single source of truth */ /** Supported download client types — single source of truth */
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const; export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission', 'deluge'] as const;
/** Identifies the specific download client software */ /** Identifies the specific download client software */
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number]; export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
@@ -22,6 +22,7 @@ export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
sabnzbd: 'SABnzbd', sabnzbd: 'SABnzbd',
nzbget: 'NZBGet', nzbget: 'NZBGet',
transmission: 'Transmission', transmission: 'Transmission',
deluge: 'Deluge',
}; };
/** Get display name for a client type, falling back to the raw type */ /** Get display name for a client type, falling back to the raw type */
@@ -38,6 +39,7 @@ export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
sabnzbd: 'usenet', sabnzbd: 'usenet',
nzbget: 'usenet', nzbget: 'usenet',
transmission: 'torrent', transmission: 'torrent',
deluge: 'torrent',
}; };
/** Unified download status across all clients */ /** Unified download status across all clients */
@@ -316,6 +316,7 @@ async function downloadFileWithProgress(
let bytesDownloaded = 0; let bytesDownloaded = 0;
let lastLogTime = Date.now(); let lastLogTime = Date.now();
let lastDbUpdateTime = Date.now(); let lastDbUpdateTime = Date.now();
let dbUpdatePending = false; // Guard against stacking unresolved DB updates
response.data.on('data', (chunk: Buffer) => { response.data.on('data', (chunk: Buffer) => {
bytesDownloaded += chunk.length; bytesDownloaded += chunk.length;
@@ -332,18 +333,18 @@ async function downloadFileWithProgress(
logger.info(`Download progress: ${percent}% (${(bytesDownloaded / (1024 * 1024)).toFixed(1)} MB, ${speedMBps.toFixed(2)} MB/s)`); logger.info(`Download progress: ${percent}% (${(bytesDownloaded / (1024 * 1024)).toFixed(1)} MB, ${speedMBps.toFixed(2)} MB/s)`);
lastLogTime = now; lastLogTime = now;
// Update database with progress (non-blocking) // Update database with progress (non-blocking, at most 1 in-flight at a time)
if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS) { if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS && !dbUpdatePending) {
lastDbUpdateTime = now; lastDbUpdateTime = now;
dbUpdatePending = true;
// Non-blocking update - fire and forget
prisma.request.update({ prisma.request.update({
where: { id: tracking.requestId }, where: { id: tracking.requestId },
data: { data: {
progress: Math.min(percent, 99), // Cap at 99% until fully complete progress: Math.min(percent, 99), // Cap at 99% until fully complete
updatedAt: new Date(), updatedAt: new Date(),
}, },
}).catch(() => {}); // Ignore errors during progress update }).catch(() => {}).finally(() => { dbUpdatePending = false; });
} }
} }
}); });
@@ -16,8 +16,23 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-
* Checks download progress from download client and updates request status * Checks download progress from download client and updates request status
* Re-schedules itself if download is still in progress * Re-schedules itself if download is still in progress
*/ */
/** Base polling interval in seconds */
const BASE_POLL_INTERVAL = 10;
/** Maximum polling interval in seconds (5 minutes) */
const MAX_POLL_INTERVAL = 300;
/**
* Compute next poll delay with exponential backoff for stalled downloads.
* Active downloads poll every 10s; stalled downloads back off up to 5 min.
*/
function getBackoffDelay(stallCount: number): number {
if (stallCount <= 0) return BASE_POLL_INTERVAL;
return Math.min(BASE_POLL_INTERVAL * Math.pow(2, stallCount), MAX_POLL_INTERVAL);
}
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> { export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId } = payload; const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
lastProgress: prevProgress, stallCount: prevStallCount } = payload;
const logger = RMABLogger.forJob(jobId, 'MonitorDownload'); const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
@@ -199,22 +214,35 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
progress: progressPercent, progress: progressPercent,
}; };
} else { } else {
// Still downloading - schedule another check in 10 seconds // Still downloading — compute adaptive poll interval
const isStalled = info.downloadSpeed === 0
|| progressPercent === (prevProgress ?? -1)
|| progressState === 'paused'
|| progressState === 'queued'
|| progressState === 'checking';
const stallCount = isStalled ? (prevStallCount ?? 0) + 1 : 0;
const delay = getBackoffDelay(stallCount);
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob( await jobQueue.addMonitorJob(
requestId, requestId,
downloadHistoryId, downloadHistoryId,
downloadClientId, downloadClientId,
downloadClient, downloadClient,
10 // Delay 10 seconds between checks delay,
progressPercent,
stallCount
); );
// Only log every 5% progress to reduce log spam // Only log every 5% progress to reduce log spam, but always log stall transitions
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5; const shouldLog = progressPercent % 5 === 0 || progressPercent < 5
|| (stallCount === 1) || (stallCount > 0 && stallCount % 10 === 0);
if (shouldLog) { if (shouldLog) {
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, { logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
speed: info.downloadSpeed, speed: info.downloadSpeed,
eta: info.eta, eta: info.eta,
...(stallCount > 0 && { stallCount, nextPollSec: delay }),
}); });
} }
@@ -227,6 +255,8 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
speed: info.downloadSpeed, speed: info.downloadSpeed,
eta: info.eta, eta: info.eta,
state: progressState, state: progressState,
stallCount,
nextPollSec: delay,
}; };
} }
} catch (error) { } catch (error) {
@@ -124,6 +124,9 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
break; break;
} }
} }
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
} }
logger.info(`RSS monitoring complete: ${matched} matches found and queued for processing`); logger.info(`RSS monitoring complete: ${matched} matches found and queued for processing`);
@@ -622,7 +622,9 @@ async function processEbookOrganization(
requestId, requestId,
book.title, book.title,
book.author, book.author,
request.user.plexUsername || 'Unknown User' request.user.plexUsername || 'Unknown User',
undefined,
'ebook'
).catch((error) => { ).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
}); });
@@ -862,8 +864,10 @@ async function cleanupDownloadAfterOrganize(
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined', removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
}); });
// Check if this is a non-torrent indexer with cleanup enabled // Check if this is a non-torrent indexer with cleanup enabled.
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) { const isTorrentProtocol = indexer?.protocol?.toLowerCase() === 'torrent';
if (!indexer || isTorrentProtocol || !indexer.removeAfterProcessing) {
return; return;
} }
@@ -325,7 +325,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
request.id, request.id,
audiobook.title, audiobook.title,
audiobook.author, audiobook.author,
request.user.plexUsername || 'Unknown User' request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => { ).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
}); });
@@ -157,6 +157,9 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
); );
triggered++; triggered++;
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`); logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) { } catch (error) {
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++; skipped++;
@@ -44,6 +44,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
} }
// Trigger appropriate search job for each request based on type // Trigger appropriate search job for each request based on type
// Throttle: 100ms delay between jobs to avoid connection pool burst
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
let triggered = 0; let triggered = 0;
@@ -73,6 +74,9 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
} catch (error) { } catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
} }
logger.info(`Triggered ${triggered}/${requests.length} search jobs`); logger.info(`Triggered ${triggered}/${requests.length} search jobs`);
+3 -1
View File
@@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
request.id, request.id,
audiobook.title, audiobook.title,
audiobook.author, audiobook.author,
request.user.plexUsername || 'Unknown User' request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => { ).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
}); });
+15 -2
View File
@@ -14,6 +14,8 @@ import { RMABLogger } from '../utils/logger';
import { getProwlarrService } from '../integrations/prowlarr.service'; import { getProwlarrService } from '../integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm'; import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { getLanguageForRegion } from '../constants/language-config';
import type { AudibleRegion } from '../types/audible';
// Import ebook scraper functions for Anna's Archive // Import ebook scraper functions for Anna's Archive
import { import {
@@ -151,6 +153,11 @@ async function searchAnnasArchive(
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li'; const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined; const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (flaresolverrUrl) { if (flaresolverrUrl) {
logger.info(`Using FlareSolverr at ${flaresolverrUrl}`); logger.info(`Using FlareSolverr at ${flaresolverrUrl}`);
} }
@@ -161,7 +168,7 @@ async function searchAnnasArchive(
// Try ASIN search first (exact match - best) // Try ASIN search first (exact match - best)
if (audiobook.asin) { if (audiobook.asin) {
logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`); logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`);
md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via ASIN: ${md5}`); logger.info(`Found via ASIN: ${md5}`);
@@ -174,7 +181,7 @@ async function searchAnnasArchive(
// Fallback to title + author search // Fallback to title + author search
if (!md5) { if (!md5) {
logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`); logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`);
md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via title search: ${md5}`); logger.info(`Found via title search: ${md5}`);
@@ -301,6 +308,10 @@ async function searchIndexers(
logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`); logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`);
} }
// Get language-specific stop words for ranking
const ebookRegion = await configService.getAudibleRegion() as AudibleRegion;
const ebookLangConfig = getLanguageForRegion(ebookRegion);
// Rank results with ebook-specific scoring // Rank results with ebook-specific scoring
// This filters out > 20MB and uses inverted size scoring // This filters out > 20MB and uses inverted size scoring
const rankedResults = rankEbookTorrents(allResults, { const rankedResults = rankEbookTorrents(allResults, {
@@ -311,6 +322,8 @@ async function searchIndexers(
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: true, // Automatic mode - prevent wrong authors requireAuthor: true, // Automatic mode - prevent wrong authors
stopWords: ebookLangConfig.stopWords,
characterReplacements: ebookLangConfig.characterReplacements,
}); });
// Log filter results // Log filter results
@@ -9,6 +9,8 @@ import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm'; import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { getLanguageForRegion } from '../constants/language-config';
import type { AudibleRegion } from '../types/audible';
/** /**
* Process search indexers job * Process search indexers job
@@ -146,8 +148,10 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`); logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
} }
// Get ranking algorithm // Get ranking algorithm and language-specific stop words
const ranker = getRankingAlgorithm(); const ranker = getRankingAlgorithm();
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank results with indexer priorities and flag configs // Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally // Note: rankTorrents now filters out results < 20 MB internally
@@ -159,7 +163,9 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
}, { }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: true // Automatic mode - prevent wrong authors requireAuthor: true, // Automatic mode - prevent wrong authors
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
}); });
// Log filter results // Log filter results
@@ -18,7 +18,7 @@ export type { SendNotificationPayload } from '../services/job-queue.service';
* Calls NotificationService to send notifications to all enabled backends * Calls NotificationService to send notifications to all enabled backends
*/ */
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> { export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
const { event, requestId, issueId, title, author, userName, message, jobId } = payload; const { event, requestId, issueId, title, author, userName, message, requestType, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'SendNotification'); const logger = RMABLogger.forJob(jobId, 'SendNotification');
@@ -34,6 +34,7 @@ export async function processSendNotification(payload: SendNotificationPayload):
author, author,
userName, userName,
message, message,
requestType,
timestamp: new Date(), timestamp: new Date(),
}); });
@@ -2,7 +2,7 @@
* Component: Download Client Manager Service * Component: Download Client Manager Service
* Documentation: documentation/phase3/download-clients.md * Documentation: documentation/phase3/download-clients.md
* *
* Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing. * Manages multiple download clients (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet) with protocol-based routing.
* Supports migration from legacy single-client config to multi-client JSON array format. * Supports migration from legacy single-client config to multi-client JSON array format.
*/ */
@@ -16,6 +16,7 @@ import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { NZBGetService } from '@/lib/integrations/nzbget.service'; import { NZBGetService } from '@/lib/integrations/nzbget.service';
import { TransmissionService } from '@/lib/integrations/transmission.service'; import { TransmissionService } from '@/lib/integrations/transmission.service';
import { DelugeService } from '@/lib/integrations/deluge.service';
import { PathMappingConfig } from '@/lib/utils/path-mapper'; import { PathMappingConfig } from '@/lib/utils/path-mapper';
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
@@ -193,6 +194,8 @@ export class DownloadClientManager {
return this.createNZBGetService(config, downloadDir); return this.createNZBGetService(config, downloadDir);
case 'transmission': case 'transmission':
return this.createTransmissionService(config, downloadDir); return this.createTransmissionService(config, downloadDir);
case 'deluge':
return this.createDelugeService(config, downloadDir);
default: default:
throw new Error(`Unsupported download client type: ${config.type}`); throw new Error(`Unsupported download client type: ${config.type}`);
} }
@@ -335,6 +338,29 @@ export class DownloadClientManager {
); );
} }
/**
* Create Deluge service instance
*/
private createDelugeService(config: DownloadClientConfig, downloadDir: string): DelugeService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new DelugeService(
config.url,
config.username || '',
config.password || '',
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
}
/** /**
* Migrate legacy single-client config to new multi-client format * Migrate legacy single-client config to new multi-client format
*/ */
+13 -10
View File
@@ -170,7 +170,8 @@ export async function downloadEbook(
preferredFormat: string = 'epub', preferredFormat: string = 'epub',
baseUrl: string = 'https://annas-archive.li', baseUrl: string = 'https://annas-archive.li',
logger?: RMABLogger, logger?: RMABLogger,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookDownloadResult> { ): Promise<EbookDownloadResult> {
try { try {
let md5: string | null = null; let md5: string | null = null;
@@ -183,7 +184,7 @@ export async function downloadEbook(
// Step 1: Try ASIN search (exact match - best) // Step 1: Try ASIN search (exact match - best)
if (asin) { if (asin) {
await logger?.info(`Searching by ASIN: ${asin} (format: ${preferredFormat})...`); await logger?.info(`Searching by ASIN: ${asin} (format: ${preferredFormat})...`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByAsin(asin, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
await logger?.info(`Found via ASIN: ${md5}`); await logger?.info(`Found via ASIN: ${md5}`);
@@ -195,7 +196,7 @@ export async function downloadEbook(
// Step 2: Fallback to title + author search // Step 2: Fallback to title + author search
if (!md5) { if (!md5) {
await logger?.info(`Searching by title + author: "${title}" by ${author}...`); await logger?.info(`Searching by title + author: "${title}" by ${author}...`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByTitle(title, author, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
await logger?.info(`Found via title search: ${md5}`); await logger?.info(`Found via title search: ${md5}`);
@@ -312,10 +313,11 @@ export async function searchByAsin(
format: string, format: string,
baseUrl: string, baseUrl: string,
logger?: RMABLogger, logger?: RMABLogger,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<string | null> { ): Promise<string | null> {
// Check cache first // Check cache first
const cacheKey = `${asin}-${format}`; const cacheKey = `${asin}-${format}-${languageCode}`;
if (md5Cache.has(cacheKey)) { if (md5Cache.has(cacheKey)) {
const cached = md5Cache.get(cacheKey); const cached = md5Cache.get(cacheKey);
if (cached) { if (cached) {
@@ -327,7 +329,7 @@ export async function searchByAsin(
try { try {
// Build search URL with ASIN and optional format filter // Build search URL with ASIN and optional format filter
const formatParam = format && format !== 'any' ? `ext=${format}&` : ''; const formatParam = format && format !== 'any' ? `ext=${format}&` : '';
const searchUrl = `${baseUrl}/search?${formatParam}lang=en&q=%22asin:${asin}%22`; const searchUrl = `${baseUrl}/search?${formatParam}lang=${languageCode}&q=%22asin:${asin}%22`;
moduleLogger.debug(`ASIN search URL: ${searchUrl}`); moduleLogger.debug(`ASIN search URL: ${searchUrl}`);
@@ -404,10 +406,11 @@ export async function searchByTitle(
format: string, format: string,
baseUrl: string, baseUrl: string,
logger?: RMABLogger, logger?: RMABLogger,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<string | null> { ): Promise<string | null> {
// Check cache first // Check cache first
const cacheKey = `title-${title}-${author}-${format}`.toLowerCase(); const cacheKey = `title-${title}-${author}-${format}-${languageCode}`.toLowerCase();
if (md5Cache.has(cacheKey)) { if (md5Cache.has(cacheKey)) {
const cached = md5Cache.get(cacheKey); const cached = md5Cache.get(cacheKey);
if (cached) { if (cached) {
@@ -432,8 +435,8 @@ export async function searchByTitle(
// Add content type filters (books only, all fiction/nonfiction/unknown) // Add content type filters (books only, all fiction/nonfiction/unknown)
searchUrl += '&content=book_nonfiction&content=book_fiction&content=book_unknown'; searchUrl += '&content=book_nonfiction&content=book_fiction&content=book_unknown';
// Add language filter (English) // Add language filter
searchUrl += '&lang=en'; searchUrl += `&lang=${languageCode}`;
// Empty raw query (we're using specific terms instead) // Empty raw query (we're using specific terms instead)
searchUrl += '&q='; searchUrl += '&q=';
+16 -4
View File
@@ -289,11 +289,23 @@ async function performAudibleLookup(
const audibleService = getAudibleService(); const audibleService = getAudibleService();
try { try {
const searchQuery = `${book.title} ${book.author}`; // Try full Goodreads title first, then fall back to stripped title
log.info(`Searching Audible for: "${searchQuery}"`); // (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results)
const fullQuery = `${book.title} ${book.author}`;
log.info(`Searching Audible for: "${fullQuery}"`);
const searchResult = await audibleService.search(searchQuery); let searchResult = await audibleService.search(fullQuery);
const firstResult = searchResult.results[0]; let firstResult = searchResult.results[0];
if (!firstResult?.asin) {
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
if (cleanTitle !== book.title) {
const cleanQuery = `${cleanTitle} ${book.author}`;
log.info(`No results with full title, retrying without series info: "${cleanQuery}"`);
searchResult = await audibleService.search(cleanQuery);
firstResult = searchResult.results[0];
}
}
if (firstResult?.asin) { if (firstResult?.asin) {
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`); log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
+18 -9
View File
@@ -63,6 +63,8 @@ export interface MonitorDownloadPayload extends JobPayload {
downloadHistoryId: string; downloadHistoryId: string;
downloadClientId: string; downloadClientId: string;
downloadClient: DownloadClientType; downloadClient: DownloadClientType;
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
} }
export interface OrganizeFilesPayload extends JobPayload { export interface OrganizeFilesPayload extends JobPayload {
@@ -155,6 +157,7 @@ export interface SendNotificationPayload extends JobPayload {
author: string; author: string;
userName: string; userName: string;
message?: string; message?: string;
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific notification titles
timestamp: Date; timestamp: Date;
} }
@@ -276,19 +279,19 @@ export class JobQueueService {
*/ */
private startProcessors(): void { private startProcessors(): void {
// Search indexers processor // Search indexers processor
this.queue.process('search_indexers', 3, async (job: BullJob<SearchIndexersPayload>) => { this.queue.process('search_indexers', 2, async (job: BullJob<SearchIndexersPayload>) => {
const { processSearchIndexers } = await import('../processors/search-indexers.processor'); const { processSearchIndexers } = await import('../processors/search-indexers.processor');
return await processSearchIndexers(job.data); return await processSearchIndexers(job.data);
}); });
// Download torrent processor // Download torrent processor
this.queue.process('download_torrent', 3, async (job: BullJob<DownloadTorrentPayload>) => { this.queue.process('download_torrent', 2, async (job: BullJob<DownloadTorrentPayload>) => {
const { processDownloadTorrent } = await import('../processors/download-torrent.processor'); const { processDownloadTorrent } = await import('../processors/download-torrent.processor');
return await processDownloadTorrent(job.data); return await processDownloadTorrent(job.data);
}); });
// Monitor download processor // Monitor download processor
this.queue.process('monitor_download', 5, async (job: BullJob<MonitorDownloadPayload>) => { this.queue.process('monitor_download', 2, async (job: BullJob<MonitorDownloadPayload>) => {
const { processMonitorDownload } = await import('../processors/monitor-download.processor'); const { processMonitorDownload } = await import('../processors/monitor-download.processor');
return await processMonitorDownload(job.data); return await processMonitorDownload(job.data);
}); });
@@ -356,23 +359,23 @@ export class JobQueueService {
}); });
// Send notification processor // Send notification processor
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => { this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
const { processSendNotification } = await import('../processors/send-notification.processor'); const { processSendNotification } = await import('../processors/send-notification.processor');
return await processSendNotification(job.data); return await processSendNotification(job.data);
}); });
// Ebook-specific processors // Ebook-specific processors
this.queue.process('search_ebook', 3, async (job: BullJob<SearchEbookPayload>) => { this.queue.process('search_ebook', 2, async (job: BullJob<SearchEbookPayload>) => {
const { processSearchEbook } = await import('../processors/search-ebook.processor'); const { processSearchEbook } = await import('../processors/search-ebook.processor');
return await processSearchEbook(job.data); return await processSearchEbook(job.data);
}); });
this.queue.process('start_direct_download', 3, async (job: BullJob<StartDirectDownloadPayload>) => { this.queue.process('start_direct_download', 2, async (job: BullJob<StartDirectDownloadPayload>) => {
const { processStartDirectDownload } = await import('../processors/direct-download.processor'); const { processStartDirectDownload } = await import('../processors/direct-download.processor');
return await processStartDirectDownload(job.data); return await processStartDirectDownload(job.data);
}); });
this.queue.process('monitor_direct_download', 5, async (job: BullJob<MonitorDirectDownloadPayload>) => { this.queue.process('monitor_direct_download', 2, async (job: BullJob<MonitorDirectDownloadPayload>) => {
const { processMonitorDirectDownload } = await import('../processors/direct-download.processor'); const { processMonitorDirectDownload } = await import('../processors/direct-download.processor');
return await processMonitorDirectDownload(job.data); return await processMonitorDirectDownload(job.data);
}); });
@@ -562,7 +565,9 @@ export class JobQueueService {
downloadHistoryId: string, downloadHistoryId: string,
downloadClientId: string, downloadClientId: string,
downloadClient: DownloadClientType, downloadClient: DownloadClientType,
delaySeconds: number = 0 delaySeconds: number = 0,
lastProgress?: number,
stallCount?: number
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'monitor_download', 'monitor_download',
@@ -571,6 +576,8 @@ export class JobQueueService {
downloadHistoryId, downloadHistoryId,
downloadClientId, downloadClientId,
downloadClient, downloadClient,
lastProgress,
stallCount,
} as MonitorDownloadPayload, } as MonitorDownloadPayload,
{ {
priority: 5, // Medium priority priority: 5, // Medium priority
@@ -948,7 +955,8 @@ export class JobQueueService {
title: string, title: string,
author: string, author: string,
userName: string, userName: string,
message?: string message?: string,
requestType?: string
): Promise<string> { ): Promise<string> {
logger.info(`Queueing notification: ${event}`, { requestId, title, userName }); logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
return await this.addJob( return await this.addJob(
@@ -963,6 +971,7 @@ export class JobQueueService {
author, author,
userName, userName,
message, message,
requestType,
// Pass the original ID for notification display (e.g., Discord footer) // Pass the original ID for notification display (e.g., Discord footer)
...(event === 'issue_reported' && { issueId: requestId }), ...(event === 'issue_reported' && { issueId: requestId }),
timestamp: new Date(), timestamp: new Date(),
@@ -18,6 +18,7 @@ export interface NotificationPayload {
author: string; author: string;
userName: string; userName: string;
message?: string; // For error/issue events message?: string; // For error/issue events
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific titles via getEventTitle()
timestamp: Date; timestamp: Date;
} }
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events'; import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface AppriseConfig { export interface AppriseConfig {
serverUrl: string; serverUrl: string;
@@ -108,8 +108,7 @@ export class AppriseProvider implements INotificationProvider {
} }
private formatMessage(payload: NotificationPayload): { title: string; body: string } { private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message } = payload; const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
@@ -123,7 +122,7 @@ export class AppriseProvider implements INotificationProvider {
} }
return { return {
title: meta.title, title: getEventTitle(event, requestType),
body: messageLines.join('\n'), body: messageLines.join('\n'),
}; };
} }
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events'; import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface DiscordConfig { export interface DiscordConfig {
webhookUrl: string; webhookUrl: string;
@@ -59,8 +59,9 @@ export class DiscordProvider implements INotificationProvider {
} }
private formatEmbed(payload: NotificationPayload): any { private formatEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload; const { event, title, author, userName, message, requestId, requestType, timestamp } = payload;
const meta = getEventMeta(event); const meta = getEventMeta(event);
const resolvedTitle = getEventTitle(event, requestType);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const fields = [ const fields = [
@@ -74,7 +75,7 @@ export class DiscordProvider implements INotificationProvider {
} }
return { return {
title: `${meta.emoji} ${meta.title}`, title: `${meta.emoji} ${resolvedTitle}`,
color: SEVERITY_COLORS[meta.severity], color: SEVERITY_COLORS[meta.severity],
fields, fields,
footer: { footer: {
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events'; import { getEventMeta, getEventTitle, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
export interface NtfyConfig { export interface NtfyConfig {
serverUrl?: string; serverUrl?: string;
@@ -83,8 +83,7 @@ export class NtfyProvider implements INotificationProvider {
} }
private formatMessage(payload: NotificationPayload): { title: string; message: string } { private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload; const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
@@ -98,7 +97,7 @@ export class NtfyProvider implements INotificationProvider {
} }
return { return {
title: meta.title, title: getEventTitle(event, requestType),
message: messageLines.join('\n'), message: messageLines.join('\n'),
}; };
} }
@@ -4,7 +4,7 @@
*/ */
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events'; import { getEventMeta, getEventTitle, type NotificationPriority } from '@/lib/constants/notification-events';
export interface PushoverConfig { export interface PushoverConfig {
userKey: string; userKey: string;
@@ -77,12 +77,13 @@ export class PushoverProvider implements INotificationProvider {
} }
private formatMessage(payload: NotificationPayload): { title: string; message: string } { private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload; const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event); const meta = getEventMeta(event);
const resolvedTitle = getEventTitle(event, requestType);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
`${meta.emoji} ${meta.title}`, `${meta.emoji} ${resolvedTitle}`,
'', '',
`\u{1F4DA} ${title}`, `\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`, `\u270D\uFE0F ${author}`,
@@ -94,7 +95,7 @@ export class PushoverProvider implements INotificationProvider {
} }
return { return {
title: meta.title, title: resolvedTitle,
message: messageLines.join('\n'), message: messageLines.join('\n'),
}; };
} }
@@ -84,6 +84,7 @@ export async function createRequestForUser(
let year: number | undefined; let year: number | undefined;
let series: string | undefined; let series: string | undefined;
let seriesPart: string | undefined; let seriesPart: string | undefined;
let seriesAsin: string | undefined;
try { try {
const audibleService = getAudibleService(); const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin); const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
@@ -100,6 +101,7 @@ export async function createRequestForUser(
} }
if (audnexusData?.series) series = audnexusData.series; if (audnexusData?.series) series = audnexusData.series;
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart; if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
if (audnexusData?.seriesAsin) seriesAsin = audnexusData.seriesAsin;
} catch (error) { } catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
@@ -121,6 +123,7 @@ export async function createRequestForUser(
year, year,
series, series,
seriesPart, seriesPart,
seriesAsin,
status: 'requested', status: 'requested',
}, },
}); });
@@ -134,6 +137,7 @@ export async function createRequestForUser(
if (year) updates.year = year; if (year) updates.year = year;
if (series) updates.series = series; if (series) updates.series = series;
if (seriesPart) updates.seriesPart = seriesPart; if (seriesPart) updates.seriesPart = seriesPart;
if (seriesAsin) updates.seriesAsin = seriesAsin;
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
audiobookRecord = await prisma.audiobook.update({ audiobookRecord = await prisma.audiobook.update({
+31 -3
View File
@@ -51,12 +51,18 @@ export class SchedulerService {
logger.info('Initializing scheduler service...'); logger.info('Initializing scheduler service...');
// Re-encrypt any notification backends with plaintext sensitive fields // Re-encrypt any notification backends with plaintext sensitive fields
try {
await getNotificationService().reEncryptUnprotectedBackends(); await getNotificationService().reEncryptUnprotectedBackends();
} catch (error) {
logger.error('Failed to re-encrypt notification backends (non-fatal)', {
error: error instanceof Error ? error.message : String(error),
});
}
// Create default jobs if they don't exist // Create default jobs if they don't exist
await this.ensureDefaultJobs(); await this.ensureDefaultJobs();
// Load and schedule all enabled jobs // Load and schedule all enabled jobs (works with whatever jobs exist in DB)
await this.scheduleAllJobs(); await this.scheduleAllJobs();
// Check and trigger overdue jobs // Check and trigger overdue jobs
@@ -66,7 +72,8 @@ export class SchedulerService {
} }
/** /**
* Ensure default jobs exist in database * Ensure default jobs exist in database.
* Each job is created independently so a single failure doesn't block the rest.
*/ */
private async ensureDefaultJobs(): Promise<void> { private async ensureDefaultJobs(): Promise<void> {
const defaults = [ const defaults = [
@@ -128,7 +135,11 @@ export class SchedulerService {
}, },
]; ];
let created = 0;
let failed = 0;
for (const defaultJob of defaults) { for (const defaultJob of defaults) {
try {
const existing = await prisma.scheduledJob.findFirst({ const existing = await prisma.scheduledJob.findFirst({
where: { type: defaultJob.type }, where: { type: defaultJob.type },
}); });
@@ -137,8 +148,22 @@ export class SchedulerService {
await prisma.scheduledJob.create({ await prisma.scheduledJob.create({
data: defaultJob, data: defaultJob,
}); });
logger.info(`Created default job: ${defaultJob.name} (disabled by default)`); created++;
logger.info(`Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`);
} }
} catch (error) {
failed++;
logger.error(`Failed to create default job: ${defaultJob.name}`, {
type: defaultJob.type,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (failed > 0) {
logger.warn(`Default jobs: ${created} created, ${failed} failed — failed jobs will be retried on next restart`);
} else if (created > 0) {
logger.info(`Default jobs: ${created} created`);
} }
} }
@@ -466,6 +491,9 @@ export class SchedulerService {
if (this.isJobOverdue(job)) { if (this.isJobOverdue(job)) {
logger.info(`Job "${job.name}" is overdue, triggering now...`); logger.info(`Job "${job.name}" is overdue, triggering now...`);
await this.triggerJobNow(job.id); await this.triggerJobNow(job.id);
// Stagger triggers to avoid connection pool burst on startup
await new Promise(resolve => setTimeout(resolve, 500));
} }
} catch (error) { } catch (error) {
logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) }); logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) });
+18 -9
View File
@@ -3,14 +3,16 @@
* Documentation: documentation/integrations/audible.md * Documentation: documentation/integrations/audible.md
*/ */
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es'; import type { SupportedLanguage } from '../constants/language-config';
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es' | 'fr';
export interface AudibleRegionConfig { export interface AudibleRegionConfig {
code: AudibleRegion; code: AudibleRegion;
name: string; name: string;
baseUrl: string; baseUrl: string;
audnexusParam: string; audnexusParam: string;
isEnglish: boolean; language: SupportedLanguage;
} }
export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = { export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
@@ -19,49 +21,56 @@ 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, language: 'en',
}, },
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, language: 'en',
}, },
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, language: 'en',
}, },
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, language: 'en',
}, },
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, language: 'en',
}, },
de: { de: {
code: 'de', code: 'de',
name: 'Germany', name: 'Germany',
baseUrl: 'https://www.audible.de', baseUrl: 'https://www.audible.de',
audnexusParam: 'de', audnexusParam: 'de',
isEnglish: false, language: 'de',
}, },
es: { es: {
code: 'es', code: 'es',
name: 'Spain', name: 'Spain',
baseUrl: 'https://www.audible.es', baseUrl: 'https://www.audible.es',
audnexusParam: 'es', audnexusParam: 'es',
isEnglish: false, language: 'es',
},
fr: {
code: 'fr',
name: 'France',
baseUrl: 'https://www.audible.fr',
audnexusParam: 'fr',
language: 'fr',
} }
}; };
+14 -1
View File
@@ -163,7 +163,20 @@ export async function enrichAudiobooksWithMatches(
audiobooks: Array<AudiobookMatchInput & Record<string, any>>, audiobooks: Array<AudiobookMatchInput & Record<string, any>>,
userId?: string userId?: string
) { ) {
const results = await Promise.all(audiobooks.map((book) => enrichAudiobookWithMatch(book))); // Batch parallel DB queries to avoid connection pool exhaustion
const BATCH_SIZE = 5;
const results: Awaited<ReturnType<typeof enrichAudiobookWithMatch>>[] = [];
for (let i = 0; i < audiobooks.length; i += BATCH_SIZE) {
const batch = audiobooks.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.allSettled(batch.map((book) => enrichAudiobookWithMatch(book)));
for (const result of batchResults) {
if (result.status === 'fulfilled') {
results.push(result.value);
} else {
logger.error('Failed to enrich audiobook', { error: result.reason instanceof Error ? result.reason.message : String(result.reason) });
}
}
}
// Always enrich with request status (check ANY user's requests) // Always enrich with request status (check ANY user's requests)
const asins = audiobooks.map(book => book.asin); const asins = audiobooks.map(book => book.asin);
+50 -18
View File
@@ -40,6 +40,8 @@ export interface RankTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25) indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true) requireAuthor?: boolean; // Enforce author presence check (default: true)
stopWords?: string[]; // Language-specific stop words for matching
characterReplacements?: Record<string, string>; // Language-specific char replacements (e.g. ß→ss)
} }
export interface EbookTorrentRequest { export interface EbookTorrentRequest {
@@ -52,6 +54,8 @@ export interface RankEbookTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25) indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true) requireAuthor?: boolean; // Enforce author presence check (default: true)
stopWords?: string[]; // Language-specific stop words for matching
characterReplacements?: Record<string, string>; // Language-specific char replacements (e.g. ß→ss)
} }
export interface BonusModifier { export interface BonusModifier {
@@ -113,7 +117,9 @@ export class RankingAlgorithm {
const { const {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode requireAuthor = true, // Safe default: require author in automatic mode
stopWords,
characterReplacements,
} = options; } = options;
// Filter out files < 20 MB (likely ebooks/samples) // Filter out files < 20 MB (likely ebooks/samples)
const filteredTorrents = torrents.filter((torrent) => { const filteredTorrents = torrents.filter((torrent) => {
@@ -126,7 +132,7 @@ export class RankingAlgorithm {
const formatScore = this.scoreFormat(torrent); const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes); const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders); const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor); const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor, stopWords, characterReplacements);
const baseScore = formatScore + sizeScore + seederScore + matchScore; const baseScore = formatScore + sizeScore + seederScore + matchScore;
@@ -340,11 +346,22 @@ export class RankingAlgorithm {
* "Twelve.Months-Jim.Butcher" "twelve months jim butcher" * "Twelve.Months-Jim.Butcher" "twelve months jim butcher"
* "Author_Name_Book" "author name book" * "Author_Name_Book" "author name book"
*/ */
private normalizeForMatching(text: string): string { private normalizeForMatching(text: string, characterReplacements?: Record<string, string>): string {
return text let result = text
// Split CamelCase FIRST (before lowercasing): "TheCorrespondent" → "The Correspondent" // Split CamelCase FIRST (before lowercasing): "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase() .toLowerCase();
// Apply language-specific character replacements before NFD (e.g. ß→ss)
if (characterReplacements) {
for (const [from, to] of Object.entries(characterReplacements)) {
result = result.replace(new RegExp(from, 'g'), to);
}
}
return result
// NFD normalization: convert accented chars to ASCII base forms
// e.g. "uber" from "uber", "senor" from "senor", "cafe" from "cafe"
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
// Replace underscores with spaces (must be explicit since \w includes _) // Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ') .replace(/_/g, ' ')
// Replace other punctuation/separators with spaces (preserves apostrophes in contractions) // Replace other punctuation/separators with spaces (preserves apostrophes in contractions)
@@ -362,11 +379,13 @@ export class RankingAlgorithm {
private scoreMatch( private scoreMatch(
torrent: TorrentResult, torrent: TorrentResult,
audiobook: AudiobookRequest, audiobook: AudiobookRequest,
requireAuthor: boolean = true requireAuthor: boolean = true,
customStopWords?: string[],
characterReplacements?: Record<string, string>
): number { ): number {
// Normalize for matching (handles CamelCase, punctuation separators) // Normalize for matching (handles CamelCase, punctuation separators, diacritics)
const torrentTitle = this.normalizeForMatching(torrent.title); const torrentTitle = this.normalizeForMatching(torrent.title, characterReplacements);
const requestTitle = this.normalizeForMatching(audiobook.title); const requestTitle = this.normalizeForMatching(audiobook.title, characterReplacements);
// Parse authors from RAW string first (preserving commas for splitting) // Parse authors from RAW string first (preserving commas for splitting)
// Then normalize individual authors for matching // Then normalize individual authors for matching
@@ -377,19 +396,30 @@ export class RankingAlgorithm {
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a)); .filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize parsed authors for matching (handles CamelCase in author names) // Normalize parsed authors for matching (handles CamelCase in author names)
const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a)); const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a, characterReplacements));
// Combined normalized author string for fuzzy matching // Combined normalized author string for fuzzy matching
const requestAuthorNormalized = normalizedAuthors.join(' '); const requestAuthorNormalized = normalizedAuthors.join(' ');
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ========== // ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
// Extract significant words (filter out common stop words) // Extract significant words (filter out common stop words)
const stopWords = ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for']; // Use provided language-specific stop words, or fall back to English defaults
const stopWords = customStopWords || ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'];
const extractWords = (text: string, stopList: string[]): string[] => { const extractWords = (text: string, stopList: string[]): string[] => {
return text let processed = text
// Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent" // Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase() .toLowerCase();
// Apply language-specific character replacements before NFD
if (characterReplacements) {
for (const [from, to] of Object.entries(characterReplacements)) {
processed = processed.replace(new RegExp(from, 'g'), to);
}
}
return processed
// NFD normalization for accented characters
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
// Replace underscores with spaces (must be explicit since \w includes _) // Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ') .replace(/_/g, ' ')
// Remove other punctuation (but keep apostrophes for contractions) // Remove other punctuation (but keep apostrophes for contractions)
@@ -431,7 +461,7 @@ export class RankingAlgorithm {
} }
// Normalize the required portion (handles CamelCase, punctuation) // Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw); const required = this.normalizeForMatching(requiredRaw, characterReplacements);
const optional = optionalMatches.join(' '); const optional = optionalMatches.join(' ');
return { required, optional }; return { required, optional };
@@ -653,7 +683,7 @@ export class RankingAlgorithm {
* @param requestAuthor - Raw author string (will be parsed and normalized internally) * @param requestAuthor - Raw author string (will be parsed and normalized internally)
* @returns true if at least ONE author is present with high confidence * @returns true if at least ONE author is present with high confidence
*/ */
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean { private checkAuthorPresence(torrentTitle: string, requestAuthor: string, characterReplacements?: Record<string, string>): boolean {
// Parse multiple authors (same logic as Stage 3 author matching) // Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor const authors = requestAuthor
.split(/,|&| and | - /) .split(/,|&| and | - /)
@@ -661,7 +691,7 @@ export class RankingAlgorithm {
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a)); .filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize each author for matching // Normalize each author for matching
const normalizedAuthors = authors.map(a => this.normalizeForMatching(a)); const normalizedAuthors = authors.map(a => this.normalizeForMatching(a, characterReplacements));
return this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors); return this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors);
} }
@@ -788,7 +818,9 @@ export class RankingAlgorithm {
const { const {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode requireAuthor = true, // Safe default: require author in automatic mode
stopWords,
characterReplacements,
} = options; } = options;
// Filter out files > 20 MB (too large for ebooks) // Filter out files > 20 MB (too large for ebooks)
@@ -809,7 +841,7 @@ export class RankingAlgorithm {
const matchScore = this.scoreMatch(torrent, { const matchScore = this.scoreMatch(torrent, {
title: ebook.title, title: ebook.title,
author: ebook.author, author: ebook.author,
}, requireAuthor); }, requireAuthor, stopWords, characterReplacements);
const baseScore = formatScore + sizeScore + seederScore + matchScore; const baseScore = formatScore + sizeScore + seederScore + matchScore;
@@ -116,6 +116,7 @@ describe('Admin notifications test route', () => {
title: expect.any(String), title: expect.any(String),
author: expect.any(String), author: expect.any(String),
userName: 'Test User', userName: 'Test User',
requestType: 'audiobook',
timestamp: expect.any(Date), timestamp: expect.any(Date),
}) })
); );
+2 -2
View File
@@ -152,8 +152,8 @@ describe('Admin settings core routes', () => {
it('rejects invalid download client types', async () => { it('rejects invalid download client types', async () => {
const request = { const request = {
json: vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({
type: 'deluge', type: 'rtorrent',
url: 'http://deluge', url: 'http://rtorrent',
}), }),
}; };
@@ -10,6 +10,7 @@ const prismaMock = createPrismaMock();
const audibleServiceMock = vi.hoisted(() => ({ const audibleServiceMock = vi.hoisted(() => ({
search: vi.fn(), search: vi.fn(),
getAudiobookDetails: vi.fn(), getAudiobookDetails: vi.fn(),
getBaseUrl: vi.fn().mockReturnValue('https://www.audible.com'),
})); }));
const enrichMock = vi.hoisted(() => vi.fn()); const enrichMock = vi.hoisted(() => vi.fn());
const currentUserMock = vi.hoisted(() => vi.fn()); const currentUserMock = vi.hoisted(() => vi.fn());
@@ -10,6 +10,7 @@ let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAuthMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ const configServiceMock = vi.hoisted(() => ({
get: vi.fn(), get: vi.fn(),
getAudibleRegion: vi.fn().mockResolvedValue('us'),
})); }));
const prowlarrMock = vi.hoisted(() => ({ const prowlarrMock = vi.hoisted(() => ({
search: vi.fn(), search: vi.fn(),
@@ -43,6 +44,7 @@ vi.mock('@/lib/utils/indexer-grouping', () => ({
describe('Audiobooks search torrents route', () => { describe('Audiobooks search torrents route', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configServiceMock.getAudibleRegion.mockResolvedValue('us');
authRequest = { authRequest = {
user: { id: 'user-1', role: 'user' }, user: { id: 'user-1', role: 'user' },
json: vi.fn(), json: vi.fn(),
-104
View File
@@ -1,104 +0,0 @@
/**
* Component: Config API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const configServiceMock = vi.hoisted(() => ({
setMany: vi.fn(),
getAll: vi.fn(),
getCategory: vi.fn(),
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('Config API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns full configuration', async () => {
configServiceMock.getAll.mockResolvedValue({ plex_url: 'http://plex' });
const { GET } = await import('@/app/api/config/route');
const response = await GET();
const payload = await response.json();
expect(payload.config.plex_url).toBe('http://plex');
});
it('updates configuration values', async () => {
const { PUT } = await import('@/app/api/config/route');
const response = await PUT({
json: vi.fn().mockResolvedValue({
updates: [{ key: 'plex_url', value: 'http://plex' }],
}),
} as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.updated).toBe(1);
expect(configServiceMock.setMany).toHaveBeenCalled();
});
it('returns 400 when configuration update payload is invalid', async () => {
const { PUT } = await import('@/app/api/config/route');
const response = await PUT({ json: vi.fn().mockResolvedValue({}) } as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/Validation error/);
});
it('returns 500 when configuration update fails', async () => {
configServiceMock.setMany.mockRejectedValueOnce(new Error('db down'));
const { PUT } = await import('@/app/api/config/route');
const response = await PUT({
json: vi.fn().mockResolvedValue({
updates: [{ key: 'plex_url', value: 'http://plex' }],
}),
} as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toMatch(/Failed to update configuration/);
});
it('returns 500 when configuration lookup fails', async () => {
configServiceMock.getAll.mockRejectedValueOnce(new Error('db down'));
const { GET } = await import('@/app/api/config/route');
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toMatch(/Failed to get configuration/);
});
it('returns category configuration', async () => {
configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' });
const { GET } = await import('@/app/api/config/[category]/route');
const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) });
const payload = await response.json();
expect(payload.category).toBe('plex');
expect(payload.config.plex_url).toBe('http://plex');
});
it('returns 500 when category configuration lookup fails', async () => {
configServiceMock.getCategory.mockRejectedValueOnce(new Error('db down'));
const { GET } = await import('@/app/api/config/[category]/route');
const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) });
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toMatch(/Failed to get configuration/);
});
});
+2 -1
View File
@@ -12,7 +12,7 @@ const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAuthMock = vi.hoisted(() => vi.fn());
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() })); const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() }));
const rankTorrentsMock = vi.hoisted(() => vi.fn()); const rankTorrentsMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); const configServiceMock = vi.hoisted(() => ({ get: vi.fn(), getAudibleRegion: vi.fn().mockResolvedValue('us') }));
const groupIndexersMock = vi.hoisted(() => vi.fn()); const groupIndexersMock = vi.hoisted(() => vi.fn());
const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group')); const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group'));
const configState = vi.hoisted(() => ({ const configState = vi.hoisted(() => ({
@@ -75,6 +75,7 @@ vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4
describe('Request action routes', () => { describe('Request action routes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configServiceMock.getAudibleRegion.mockResolvedValue('us');
configState.values.clear(); configState.values.clear();
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() }; authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
+1 -1
View File
@@ -182,7 +182,7 @@ describe('Setup test routes', () => {
it('rejects invalid download client type', async () => { it('rejects invalid download client type', async () => {
const { POST } = await import('@/app/api/setup/test-download-client/route'); const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({ const response = await POST({
json: vi.fn().mockResolvedValue({ type: 'deluge', url: 'http://deluge' }), json: vi.fn().mockResolvedValue({ type: 'rtorrent', url: 'http://rtorrent' }),
} as any); } as any);
const payload = await response.json(); const payload = await response.json();

Some files were not shown because too many files have changed in this diff Show More