mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b15a472bab | |||
| 3c680f2f38 | |||
| 16cd606421 | |||
| 40d5363dc4 | |||
| c138d8e642 | |||
| 3d590b38cc | |||
| aa7ba8a76d | |||
| 328fd8392b | |||
| 9a460f808d | |||
| c60b6214ce | |||
| aff5faaa58 | |||
| c43ce7ba8f | |||
| f570b87343 | |||
| dfa7a11674 | |||
| 7a1a8ffa50 | |||
| d70f6c9957 | |||
| 04dbb05a6e | |||
| cb9f1b81bc | |||
| 5d8ac2f73d | |||
| c146383735 | |||
| 3820b9b21d | |||
| 20798b3dc0 | |||
| 3f8180a246 | |||
| c97df7798a | |||
| c0096cda1a | |||
| 98a2cc2813 | |||
| 4df49633b4 | |||
| 6f0d71ee9b | |||
| a145dc9877 | |||
| 89422fc77a | |||
| e40e77c8fe | |||
| 7addb1dc70 |
@@ -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
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 "Trigger Now" 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 "{confirmDialog.jobName}" now?
|
Are you sure you want to trigger "{confirmDialog.jobName}" 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 (0–23)
|
||||||
</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 (0–59)
|
||||||
</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 (1–31)
|
||||||
</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
@@ -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} · {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 "Show Details" 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>
|
||||||
|
|||||||
@@ -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 → General → Security → 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
@@ -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()} · 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'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'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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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) });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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=';
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user