mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b54d343a | |||
| 8a757f5b67 | |||
| 850e777a81 | |||
| 4322c3af90 | |||
| c8bfcdb611 | |||
| 6fc622c4e7 | |||
| dbf13c39d5 | |||
| f8c6ff3882 | |||
| 4d3af02dc8 | |||
| 5ae58a36b4 | |||
| d73d13aa26 | |||
| 81712ad3ce | |||
| b20673e7ea | |||
| 6af15b9622 | |||
| e98ac8a4e5 | |||
| c373ffffbc | |||
| 2749902564 | |||
| 6a668cc62f | |||
| 06447fed71 | |||
| 0ae8f66a2d | |||
| 09cff5b68d | |||
| da7ad7cac1 | |||
| 8aac63715a | |||
| 0a405f2313 | |||
| 98c89db0a7 | |||
| 309a7960a8 | |||
| 06e77b8eba | |||
| dfc34df3d1 | |||
| 5d2e33e369 | |||
| 789a2e50ef | |||
| 9cb9d06144 | |||
| a81549768c | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c | |||
| 01b59fae9d | |||
| 137e2b5607 | |||
| f09931f352 | |||
| 5b4aa3fa15 | |||
| 3e2221ad5b | |||
| 859a331012 | |||
| c35bec9f89 | |||
| 09e1a0db3a | |||
| 832a8ad00b | |||
| cc8e106a2b | |||
| 079a337f1c | |||
| 6025ac200a | |||
| 248bd5359c | |||
| 53c1e0dad7 | |||
| 45c8b614e3 | |||
| 24aa6afefc | |||
| 81813dc625 | |||
| f65cb59a9c | |||
| d1ea65a41a | |||
| a5e7af1a53 | |||
| ca02b8b6e7 | |||
| 85aa80938a | |||
| efb4f64014 | |||
| 95917715b1 | |||
| a50fbc721e | |||
| d6eca611fc | |||
| 45e818c181 | |||
| 85977d123c | |||
| 441724c378 | |||
| d0ce485bdc | |||
| c29cfa3a07 | |||
| 7f706e806f | |||
| 338331d006 | |||
| 6ca2e964e8 | |||
| 1d1aaa7ff3 | |||
| cbf02d3e24 | |||
| f0b2476b87 | |||
| 04b6a2c135 | |||
| 6da2c4ce95 | |||
| ce8f4d642b | |||
| 61b183542c | |||
| ae4a73144d | |||
| c57d0c1492 | |||
| 8f8387abff | |||
| 4ae68d01de | |||
| 225ef8c919 | |||
| 610873af6b | |||
| ff80d995c5 | |||
| e4e127880b | |||
| bfd624e120 | |||
| b559835390 | |||
| d25a6ebf79 | |||
| b3dad47aba | |||
| 7891e31893 | |||
| bff74446fe | |||
| b940ad39f9 | |||
| f45f31b49c | |||
| 978e177715 | |||
| 038c92e49f | |||
| 3861d07cf4 | |||
| 41d45d1210 | |||
| cfe780c6f0 | |||
| 3ee67c8763 | |||
| edc56bc457 | |||
| 73c5fe14e7 | |||
| d9ccbfef5c | |||
| 01cac0e8e6 | |||
| 66f4a215f7 | |||
| 0bd9e88acc | |||
| f0b9bd2688 | |||
| e1629ce516 | |||
| 1006a04337 | |||
| 547af71de8 | |||
| 1b0a80052d | |||
| d38f03b8f4 | |||
| dbea15a34f | |||
| 2972297903 | |||
| 03f82d4841 | |||
| 33c2265e56 | |||
| 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 |
+4
-1
@@ -1,5 +1,6 @@
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Dependencies
|
||||
/node_modules
|
||||
@@ -53,4 +54,6 @@ next-env.d.ts
|
||||
/redis
|
||||
/pgdata
|
||||
/test-media
|
||||
/test-data
|
||||
/test-data
|
||||
/bookdrop
|
||||
dockerfile.patch
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
||||
|
||||
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
|
||||
1. **Research & analyze** — Read code, trace the issue, identify root cause.
|
||||
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
|
||||
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
|
||||
4. Only after approval: implement, build, and report results.
|
||||
|
||||
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Efficient Documentation System
|
||||
|
||||
@@ -17,6 +17,11 @@ services:
|
||||
- ./downloads:/downloads
|
||||
- ./media:/media
|
||||
|
||||
# Book Drop: optional folder for Manual Import (Admin → audiobook → Manual Import)
|
||||
# Map any host folder here and it will appear as a browsable root in the file picker.
|
||||
# Example: - /path/to/your/audiobooks:/bookdrop
|
||||
# - ./bookdrop:/bookdrop
|
||||
|
||||
# PostgreSQL data persistence
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
|
||||
@@ -44,6 +49,15 @@ services:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
|
||||
# ========================================================================
|
||||
# OPTIONAL: File Permission Mask
|
||||
# ========================================================================
|
||||
# Set a umask to control default file permissions for all files created
|
||||
# by the application. Common values:
|
||||
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
|
||||
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
|
||||
# UMASK: "002"
|
||||
|
||||
# ========================================================================
|
||||
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
||||
# ========================================================================
|
||||
@@ -53,6 +67,24 @@ services:
|
||||
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-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
|
||||
# ========================================================================
|
||||
|
||||
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
|
||||
echo "[App] Starting Next.js server..."
|
||||
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
||||
|
||||
# Apply UMASK if set (controls default file permissions)
|
||||
if [ -n "$UMASK" ]; then
|
||||
echo "[App] Applying umask: $UMASK"
|
||||
umask "$UMASK"
|
||||
fi
|
||||
|
||||
cd /app
|
||||
|
||||
# =============================================================================
|
||||
@@ -53,14 +59,75 @@ start_server() {
|
||||
start_server
|
||||
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.
|
||||
|
||||
# Initialize application services (creates default scheduled jobs)
|
||||
echo "[App] Initializing application services..."
|
||||
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
|
||||
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] Server ready with PID $SERVER_PID"
|
||||
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.
|
||||
|
||||
echo "[App] Initializing application services..."
|
||||
|
||||
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)
|
||||
if [ -f "/proc/$SERVER_PID/status" ]; then
|
||||
|
||||
+195
-99
@@ -157,67 +157,104 @@ export PLEX_PRODUCT_NAME="${PLEX_PRODUCT_NAME:-ReadMeABook}"
|
||||
export LOG_LEVEL="${LOG_LEVEL:-info}"
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZE POSTGRESQL
|
||||
# DETECT EXTERNAL SERVICES
|
||||
# ============================================================================
|
||||
PGDATA="/var/lib/postgresql/data"
|
||||
PG_WAS_EMPTY=0
|
||||
# Check if user provided external DATABASE_URL or REDIS_URL
|
||||
USE_EXTERNAL_POSTGRES=false
|
||||
USE_EXTERNAL_REDIS=false
|
||||
|
||||
# Ensure correct ownership of data directories (critical for bind mounts)
|
||||
echo "🔧 Setting up directory permissions..."
|
||||
|
||||
# PostgreSQL directories - owned by postgres user, group accessible
|
||||
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on PostgreSQL directories"
|
||||
echo ""
|
||||
echo " This usually happens when using bind mounts on incompatible filesystems."
|
||||
echo ""
|
||||
echo " Common causes:"
|
||||
echo " - WSL2: Project on Windows filesystem (/mnt/c/...)"
|
||||
echo " - NFS/CIFS: Mount without proper permission support"
|
||||
echo ""
|
||||
echo " Solutions:"
|
||||
echo ""
|
||||
echo " 1. Use Docker named volumes (recommended for WSL2):"
|
||||
echo " In docker-compose.yml, change:"
|
||||
echo " - ./pgdata:/var/lib/postgresql/data"
|
||||
echo " To:"
|
||||
echo " - pgdata:/var/lib/postgresql/data"
|
||||
echo " Then add at bottom:"
|
||||
echo " volumes:"
|
||||
echo " pgdata:"
|
||||
echo ""
|
||||
echo " 2. Move project to Linux filesystem (WSL2):"
|
||||
echo " mkdir -p ~/readmeabook && cd ~/readmeabook"
|
||||
echo " # Copy docker-compose.yml and restart"
|
||||
echo ""
|
||||
echo " 3. Pre-create directories with correct ownership:"
|
||||
echo " mkdir -p pgdata redis config cache"
|
||||
echo " # Let Docker create them on first run"
|
||||
echo ""
|
||||
exit 1
|
||||
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 "$PGID" ]; then
|
||||
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
|
||||
# This allows the PGID group to read PostgreSQL files if needed
|
||||
chmod 750 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
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"
|
||||
PG_WAS_EMPTY=0
|
||||
|
||||
# Ensure correct ownership of data directories (critical for bind mounts)
|
||||
echo "🔧 Setting up directory permissions..."
|
||||
|
||||
# PostgreSQL directories - owned by postgres user, group accessible
|
||||
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on PostgreSQL directories"
|
||||
echo ""
|
||||
echo " This usually happens when using bind mounts on incompatible filesystems."
|
||||
echo ""
|
||||
echo " Common causes:"
|
||||
echo " - WSL2: Project on Windows filesystem (/mnt/c/...)"
|
||||
echo " - NFS/CIFS: Mount without proper permission support"
|
||||
echo ""
|
||||
echo " Solutions:"
|
||||
echo ""
|
||||
echo " 1. Use Docker named volumes (recommended for WSL2):"
|
||||
echo " In docker-compose.yml, change:"
|
||||
echo " - ./pgdata:/var/lib/postgresql/data"
|
||||
echo " To:"
|
||||
echo " - pgdata:/var/lib/postgresql/data"
|
||||
echo " Then add at bottom:"
|
||||
echo " volumes:"
|
||||
echo " pgdata:"
|
||||
echo ""
|
||||
echo " 2. Move project to Linux filesystem (WSL2):"
|
||||
echo " mkdir -p ~/readmeabook && cd ~/readmeabook"
|
||||
echo " # Copy docker-compose.yml and restart"
|
||||
echo ""
|
||||
echo " 3. Pre-create directories with correct ownership:"
|
||||
echo " mkdir -p pgdata redis config cache"
|
||||
echo " # Let Docker create them on first run"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PGID" ]; then
|
||||
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
|
||||
# This allows the PGID group to read PostgreSQL files if needed
|
||||
chmod 750 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
else
|
||||
# Without PUID/PGID: Use strict 700 permissions (owner only)
|
||||
chmod 700 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
fi
|
||||
else
|
||||
# Without PUID/PGID: Use strict 700 permissions (owner only)
|
||||
chmod 700 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
echo "⏭️ Skipping internal PostgreSQL setup (using external database)"
|
||||
fi
|
||||
|
||||
# Redis directory - owned by redis user (remapped to PUID:PGID if set)
|
||||
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on Redis directory"
|
||||
echo " See solutions above for PostgreSQL directories"
|
||||
echo ""
|
||||
exit 1
|
||||
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
|
||||
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on Redis directory"
|
||||
echo " See solutions above for PostgreSQL directories"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
chmod 770 /var/lib/redis
|
||||
else
|
||||
echo "⏭️ Skipping internal Redis setup (using external Redis)"
|
||||
fi
|
||||
chmod 770 /var/lib/redis
|
||||
|
||||
# App directories - owned by node user (remapped to PUID:PGID if set)
|
||||
# These need group write permissions for shared access
|
||||
@@ -232,18 +269,20 @@ chmod 775 /app/config /app/cache
|
||||
|
||||
echo "✅ Directory permissions configured"
|
||||
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
PG_WAS_EMPTY=1
|
||||
echo "📦 Initializing PostgreSQL database..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
# Only initialize/setup PostgreSQL if using internal instance
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
PG_WAS_EMPTY=1
|
||||
echo "📦 Initializing PostgreSQL database..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
|
||||
|
||||
# Configure PostgreSQL for local access
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
|
||||
# Configure PostgreSQL for local access
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
|
||||
|
||||
# Update postgresql.conf for performance
|
||||
cat >> "$PGDATA/postgresql.conf" <<EOF
|
||||
# Update postgresql.conf for performance
|
||||
cat >> "$PGDATA/postgresql.conf" <<EOF
|
||||
listen_addresses = '127.0.0.1'
|
||||
max_connections = 100
|
||||
shared_buffers = 128MB
|
||||
@@ -254,31 +293,31 @@ log_destination = 'stderr'
|
||||
logging_collector = off
|
||||
EOF
|
||||
|
||||
echo "✅ PostgreSQL initialized"
|
||||
else
|
||||
echo "✅ PostgreSQL data directory already exists"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
|
||||
# ============================================================================
|
||||
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'"
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
for i in {1..30}; do
|
||||
if su - postgres -c "/usr/lib/postgresql/16/bin/pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; then
|
||||
echo "✅ PostgreSQL is ready"
|
||||
break
|
||||
echo "✅ PostgreSQL initialized"
|
||||
else
|
||||
echo "✅ PostgreSQL data directory already exists"
|
||||
fi
|
||||
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Always ensure user and database exist (safe due to IF NOT EXISTS checks)
|
||||
# This handles cases where data directory exists but user/database don't
|
||||
echo "👤 Ensuring database user and database exist..."
|
||||
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
|
||||
# ========================================================================
|
||||
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
|
||||
# ========================================================================
|
||||
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'"
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
for i in {1..30}; do
|
||||
if su - postgres -c "/usr/lib/postgresql/16/bin/pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; then
|
||||
echo "✅ PostgreSQL is ready"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Always ensure user and database exist (safe due to IF NOT EXISTS checks)
|
||||
# This handles cases where data directory exists but user/database don't
|
||||
echo "👤 Ensuring database user and database exist..."
|
||||
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$POSTGRES_USER') THEN
|
||||
@@ -296,19 +335,36 @@ GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
|
||||
ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;
|
||||
EOF
|
||||
|
||||
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
|
||||
echo "✅ Database initialized and setup complete"
|
||||
else
|
||||
echo "✅ Database user and permissions verified"
|
||||
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
|
||||
echo "✅ Database initialized and setup complete"
|
||||
else
|
||||
echo "✅ Database user and permissions verified"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# SET ENVIRONMENT VARIABLES FOR APP
|
||||
# ============================================================================
|
||||
# URL-encode the password to handle special characters
|
||||
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
|
||||
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
|
||||
export REDIS_URL="redis://127.0.0.1:6379"
|
||||
# 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
|
||||
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
|
||||
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"
|
||||
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 PORT="3030"
|
||||
export HOSTNAME="0.0.0.0"
|
||||
@@ -318,6 +374,8 @@ export HOSTNAME="0.0.0.0"
|
||||
cat > /etc/environment <<EOF
|
||||
DATABASE_URL=$DATABASE_URL
|
||||
REDIS_URL=$REDIS_URL
|
||||
USE_EXTERNAL_POSTGRES=$USE_EXTERNAL_POSTGRES
|
||||
USE_EXTERNAL_REDIS=$USE_EXTERNAL_REDIS
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
|
||||
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
|
||||
@@ -329,21 +387,51 @@ PORT=$PORT
|
||||
HOSTNAME=$HOSTNAME
|
||||
PUID=${PUID:-}
|
||||
PGID=${PGID:-}
|
||||
UMASK=${UMASK:-}
|
||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||
EOF
|
||||
|
||||
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..."
|
||||
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..."
|
||||
|
||||
# Stop PostgreSQL (supervisord will start it)
|
||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
|
||||
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
|
||||
echo "🔄 Running data migrations..."
|
||||
|
||||
for sql_file in /app/prisma/data-migrations/*.sql; do
|
||||
if [ -f "$sql_file" ]; then
|
||||
migration_name=$(basename "$sql_file")
|
||||
|
||||
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
|
||||
if [ "$already_run" = "1" ]; then
|
||||
echo " Skipping $migration_name (already executed)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " Running $migration_name..."
|
||||
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
|
||||
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
|
||||
echo " ✅ $migration_name completed"
|
||||
else
|
||||
echo "⚠️ Data migration $migration_name failed, will retry on next start"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# DISPLAY STARTUP INFO
|
||||
@@ -361,8 +449,16 @@ if [ "$POSTGRES_PASSWORD" = "$(generate_secret)" ]; then
|
||||
fi
|
||||
echo ""
|
||||
echo "📊 Services starting:"
|
||||
echo " - PostgreSQL (internal, user=postgres)"
|
||||
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})"
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
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})"
|
||||
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
|
||||
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
|
||||
# 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
|
||||
#
|
||||
# Supports:
|
||||
@@ -15,11 +18,17 @@ if [ -f /etc/environment ]; then
|
||||
set +a
|
||||
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)
|
||||
PUID=${PUID:-$(id -u redis)}
|
||||
PGID=${PGID:-$(id -g redis)}
|
||||
|
||||
echo "[Redis] Starting Redis server..."
|
||||
echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -7,7 +7,7 @@ loglevel=info
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:postgresql]
|
||||
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
|
||||
command=/app/postgres-start.sh
|
||||
user=postgres
|
||||
autostart=true
|
||||
autorestart=true
|
||||
|
||||
+18
-1
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
|
||||
supervisor \
|
||||
curl \
|
||||
openssl \
|
||||
ffmpeg \
|
||||
locales \
|
||||
gosu \
|
||||
xz-utils \
|
||||
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
||||
&& locale-gen en_US.UTF-8 \
|
||||
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install static ffmpeg (no transitive dependencies like imagemagick)
|
||||
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
|
||||
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
|
||||
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf /tmp/ffmpeg*
|
||||
|
||||
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
|
||||
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
|
||||
'libmagickcore*' 'libmagickwand*' && \
|
||||
apt-get autoremove -y --purge && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
@@ -115,6 +127,11 @@ COPY --chown=root:root docker/unified/redis-start.sh /app/redis-start.sh
|
||||
# Convert line endings and make executable
|
||||
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 3030
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Documentation System Agent — Master Prompt
|
||||
|
||||
You are a documentation architect. Your job is to analyze a codebase from scratch and produce a **cascading, token-efficient documentation system** with a navigational index. When you are done, future AI agents dropped into this repo will be able to find any information they need by reading a single table of contents file, then following a link to exactly the right document — never wasting tokens reading irrelevant material.
|
||||
|
||||
---
|
||||
|
||||
## 1. What You Are Building
|
||||
|
||||
You are building three things:
|
||||
|
||||
### A. A `documentation/` directory
|
||||
A tree of concise, AI-optimized markdown files that describe every meaningful part of the codebase. The structure mirrors the codebase's own architecture (backend services, frontend components, integrations, configuration, etc.) rather than imposing an arbitrary layout.
|
||||
|
||||
### B. A `documentation/TABLEOFCONTENTS.md` file
|
||||
The **single entry point** for all documentation. This file maps natural-language questions and topic keywords to specific documentation files. Any agent that needs to understand something reads this file first, finds the 1-3 relevant docs, and reads only those. This is the most important file you will produce.
|
||||
|
||||
### C. A `CLAUDE.md` file at the project root
|
||||
Project instructions that teach future agents how to use the documentation system. This file is automatically loaded into every Claude Code conversation, so it must be concise, directive, and self-contained.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Token-Efficient Documentation Format
|
||||
|
||||
Every documentation file you create MUST follow this format. No exceptions.
|
||||
|
||||
### 2.1 Structure Template
|
||||
|
||||
```markdown
|
||||
# [Title]
|
||||
|
||||
**Status:** [Implemented | Partial | Planned] — [One-line summary of what this is]
|
||||
|
||||
## Overview
|
||||
[1-3 sentences. What is this? What does it do? Why does it exist?]
|
||||
|
||||
## Key Details
|
||||
- Bullet points, not prose
|
||||
- Data models: field names, types, constraints
|
||||
- API endpoints: method, path, request/response shape
|
||||
- Config keys and their values/defaults
|
||||
- Enums, status values, important constants
|
||||
- File paths and code locations
|
||||
- Behavioral rules and edge cases
|
||||
|
||||
## API / Interfaces
|
||||
[If applicable — tables or compact code blocks for endpoints, function signatures, event names, etc.]
|
||||
|
||||
## Dependencies
|
||||
[What this depends on, and what depends on it — keep to a bullet list]
|
||||
|
||||
## Known Issues / Gotchas
|
||||
[Only if there are real, non-obvious pitfalls. Omit section entirely if none.]
|
||||
|
||||
## Related
|
||||
- [Link to related doc 1]
|
||||
- [Link to related doc 2]
|
||||
```
|
||||
|
||||
### 2.2 Format Rules
|
||||
|
||||
**REQUIRED — always include:**
|
||||
- Status line with one-line summary
|
||||
- API endpoints, data models, config keys (complete and accurate)
|
||||
- File paths to source code (so agents can navigate directly)
|
||||
- Enums, constants, and status values (exact strings/numbers)
|
||||
- Dependency relationships between components
|
||||
- Gotchas that have caused or could cause bugs
|
||||
|
||||
**FORBIDDEN — never include:**
|
||||
- Verbose prose or narrative explanations
|
||||
- "Why we chose X" sections (brief rationale in a bullet is fine)
|
||||
- ASCII art diagrams larger than 5 lines
|
||||
- More than 2 code examples per document
|
||||
- "Future enhancements" or roadmap speculation
|
||||
- "Testing strategy" sections (unless tests are the subject of the doc)
|
||||
- "Performance considerations" (unless performance is the subject)
|
||||
- Empty sections or placeholder text
|
||||
- Decorative formatting, horizontal rules between every section, emoji
|
||||
|
||||
**TARGET:** Each doc file should be 30-80 lines. If it exceeds 120 lines, split it into sub-documents and link from a parent. The goal is ~70% fewer tokens than traditional documentation while preserving 100% of the technical details an agent needs.
|
||||
|
||||
---
|
||||
|
||||
## 3. The TABLEOFCONTENTS.md Format
|
||||
|
||||
This is the **router**. It maps questions to files. Format:
|
||||
|
||||
```markdown
|
||||
# Table of Contents — [Project Name]
|
||||
|
||||
> **Read this file first.** Find your topic below, then read ONLY the linked files.
|
||||
|
||||
## Quick Reference
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| [Short topic] | [path/to/file.md] |
|
||||
| ... | ... |
|
||||
|
||||
## By Category
|
||||
|
||||
### [Category Name] (e.g., "Authentication", "Database", "API Endpoints")
|
||||
| Question / Topic | File(s) |
|
||||
|-------------------|---------|
|
||||
| How does [X] work? | [path.md] |
|
||||
| What are the [Y] endpoints? | [path.md] |
|
||||
| How is [Z] configured? | [path1.md], [path2.md] |
|
||||
|
||||
### [Next Category]
|
||||
...
|
||||
|
||||
## Architecture Overview
|
||||
[3-10 bullet points describing the high-level architecture — frameworks, major services, data flow. Just enough for an agent to orient itself before diving into specific docs.]
|
||||
```
|
||||
|
||||
**Rules for TABLEOFCONTENTS.md:**
|
||||
- Every documentation file MUST appear in at least one table row
|
||||
- Questions should be phrased the way a developer or AI agent would actually ask them
|
||||
- A single question can map to multiple files (e.g., "How do downloads work?" → `downloads.md`, `jobs.md`)
|
||||
- A single file can appear under multiple questions
|
||||
- Categories should match the codebase's actual domain boundaries, not generic labels
|
||||
- The Architecture Overview section gives agents a 30-second orientation before they search for specifics
|
||||
|
||||
---
|
||||
|
||||
## 4. Execution Plan
|
||||
|
||||
Follow these phases in order. **Delegate heavily using the Task tool** — you should be orchestrating, not doing all the reading yourself.
|
||||
|
||||
### Phase 1: Deep Discovery (Delegate to Explore Agents)
|
||||
|
||||
Launch **3-5 parallel Explore agents** using the Task tool to map the entire codebase. Each agent should focus on a different area. Suggested splits:
|
||||
|
||||
**Agent 1 — Project Structure & Config:**
|
||||
- Map the top-level directory tree (2-3 levels deep)
|
||||
- Identify the tech stack (languages, frameworks, package managers)
|
||||
- Read config files (package.json, tsconfig, docker-compose, .env.example, etc.)
|
||||
- Identify build/deploy pipeline
|
||||
- Note the entry points of the application
|
||||
|
||||
**Agent 2 — Backend / Server-Side:**
|
||||
- Identify all backend services, controllers, routes, middleware
|
||||
- Map API endpoints (paths, methods, handlers)
|
||||
- Identify the database layer (ORM, schema files, migrations)
|
||||
- Note background jobs, queues, cron tasks, workers
|
||||
- Identify authentication/authorization mechanisms
|
||||
|
||||
**Agent 3 — Frontend / Client-Side:**
|
||||
- Identify UI framework and component structure
|
||||
- Map page routes and navigation
|
||||
- Identify state management approach
|
||||
- Note API client/service layer
|
||||
- Identify shared components, layouts, hooks
|
||||
|
||||
**Agent 4 — Integrations & External Services:**
|
||||
- Identify all third-party API integrations
|
||||
- Map external service connections (databases, caches, message queues, cloud services)
|
||||
- Note webhook handlers, OAuth flows, API keys
|
||||
- Identify notification systems (email, push, SMS)
|
||||
|
||||
**Agent 5 — Data Layer & Business Logic:**
|
||||
- Map database schema (tables/collections, relationships, key fields)
|
||||
- Identify core business logic and domain models
|
||||
- Map data validation rules
|
||||
- Note important algorithms or complex logic
|
||||
|
||||
Adjust these splits based on what the repo actually contains. A frontend-only repo doesn't need a backend agent. A CLI tool doesn't need a frontend agent. Use your judgment.
|
||||
|
||||
**Each agent should return:**
|
||||
- A structured summary of what it found
|
||||
- File paths to the most important source files
|
||||
- A suggested list of documentation topics for its area
|
||||
|
||||
### Phase 2: Architecture Synthesis
|
||||
|
||||
After all discovery agents return, synthesize their findings:
|
||||
|
||||
1. **Draw the dependency map** — What are the major components? How do they connect?
|
||||
2. **Identify documentation topics** — Each distinct service, feature, integration, or subsystem gets its own doc file
|
||||
3. **Design the directory structure** — Mirror the codebase's architecture. Example:
|
||||
```
|
||||
documentation/
|
||||
├── TABLEOFCONTENTS.md
|
||||
├── README.md # Project overview (brief)
|
||||
├── architecture.md # System architecture, tech stack, data flow
|
||||
├── backend/
|
||||
│ ├── api-endpoints.md # Or split by domain: users.md, orders.md, etc.
|
||||
│ ├── database.md # Schema, ORM, migrations
|
||||
│ ├── auth.md # Authentication & authorization
|
||||
│ └── jobs.md # Background processing
|
||||
├── frontend/
|
||||
│ ├── components.md # Component tree, shared components
|
||||
│ ├── routing.md # Pages, navigation, guards
|
||||
│ └── state.md # State management
|
||||
├── integrations/
|
||||
│ ├── [service-name].md # One per external integration
|
||||
│ └── ...
|
||||
└── deployment/
|
||||
└── docker.md # Or whatever the deploy mechanism is
|
||||
```
|
||||
4. **Prioritize** — Rank topics by impact. High-impact = core architecture, APIs, database schema, auth, and anything with complex logic or non-obvious behavior. Low-impact = static config files, simple utility functions, standard boilerplate.
|
||||
|
||||
### Phase 3: Documentation Generation (Delegate to Writer Agents)
|
||||
|
||||
Launch **parallel writer agents** using the Task tool. Each agent writes 2-5 related documentation files.
|
||||
|
||||
**Instructions for each writer agent must include:**
|
||||
- The exact file paths to create
|
||||
- The list of source files to read for that topic
|
||||
- The token-efficient format template (copy Section 2.1 into each agent's prompt)
|
||||
- A reminder: "Write concise bullets, not prose. Include all technical details. Target 30-80 lines per file."
|
||||
|
||||
**Suggested batching:**
|
||||
- Agent A: `architecture.md` + `README.md` (needs broadest context)
|
||||
- Agent B: Backend services docs (group related services)
|
||||
- Agent C: Frontend docs
|
||||
- Agent D: Integration docs
|
||||
- Agent E: Database + deployment docs
|
||||
|
||||
Scale the number of agents to the size of the repo. A small repo might need 2-3 writers. A large monorepo might need 8-10.
|
||||
|
||||
**Each writer agent should return:** Confirmation of files written, with a brief summary of what each file covers and a list of cross-references to note for the TOC.
|
||||
|
||||
### Phase 4: Build the TABLEOFCONTENTS.md
|
||||
|
||||
After all writers finish, build the table of contents yourself. This requires you to:
|
||||
|
||||
1. Read or review every documentation file that was created
|
||||
2. For each file, generate 2-5 natural-language questions it answers
|
||||
3. Organize questions into categories that match the codebase's domain
|
||||
4. Write the Architecture Overview section (3-10 bullets, high-level only)
|
||||
5. Cross-check: every doc file appears in at least one row; no dead links
|
||||
|
||||
### Phase 5: Generate the CLAUDE.md
|
||||
|
||||
Write the project-root `CLAUDE.md` using the template in Section 5 below. Customize it for this specific repo — fill in the actual project name, the actual documentation structure, and real examples from the actual TOC.
|
||||
|
||||
### Phase 6: Validate
|
||||
|
||||
Do a final pass:
|
||||
1. Verify every file referenced in TABLEOFCONTENTS.md actually exists
|
||||
2. Verify every file in the `documentation/` directory appears in TABLEOFCONTENTS.md
|
||||
3. Spot-check 2-3 doc files for format compliance (status line, bullets not prose, within line limits)
|
||||
4. Verify CLAUDE.md references the correct paths
|
||||
|
||||
---
|
||||
|
||||
## 5. CLAUDE.md Template
|
||||
|
||||
Generate a `CLAUDE.md` at the project root using this template. **Customize every bracketed item** for the specific repo. Remove sections that don't apply. Keep it under 200 lines — this file is loaded into every conversation and consumes tokens.
|
||||
|
||||
```markdown
|
||||
# CLAUDE.md — [Project Name]
|
||||
|
||||
## Documentation System
|
||||
|
||||
This project uses a cascading, token-efficient documentation system optimized for AI agent consumption.
|
||||
|
||||
### How to Find Information
|
||||
|
||||
1. **Read `documentation/TABLEOFCONTENTS.md` FIRST** — this is the navigation index
|
||||
2. Find your topic in the question-to-file mapping tables
|
||||
3. Read ONLY the 1-3 files relevant to your task
|
||||
4. **Never read all documentation files** — this wastes token budget
|
||||
|
||||
### Documentation Structure
|
||||
[Insert the actual directory tree of documentation/ here]
|
||||
|
||||
### Example Lookups
|
||||
- "[Example question 1]" → `[actual-path-1.md]`
|
||||
- "[Example question 2]" → `[actual-path-2.md]`, `[actual-path-3.md]`
|
||||
- "[Example question 3]" → `[actual-path-4.md]`
|
||||
|
||||
## Token Budget Rules
|
||||
|
||||
- **20-30% of tokens:** Reading documentation (via TABLEOFCONTENTS.md targeting)
|
||||
- **70-80% of tokens:** Implementation and problem-solving
|
||||
|
||||
**Do:**
|
||||
- Use TABLEOFCONTENTS.md to target specific files
|
||||
- Read only "Key Details" and "API/Interfaces" sections
|
||||
- Skip code examples unless implementing similar functionality
|
||||
|
||||
**Don't:**
|
||||
- Read all documentation files sequentially
|
||||
- Read verbose examples when not needed
|
||||
- Re-read the same docs multiple times in one session
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
When you modify code that changes behavior documented in `documentation/`:
|
||||
1. Read TABLEOFCONTENTS.md to find the relevant doc(s)
|
||||
2. Update those docs to reflect your changes
|
||||
3. Use the token-efficient format: bullets, tables, compact code blocks — no prose
|
||||
4. If you create a new doc, add it to TABLEOFCONTENTS.md
|
||||
|
||||
### Token-Efficient Format Reference
|
||||
- **Status line:** `**Status:** [Implemented | Partial | Planned] — [one-line summary]`
|
||||
- **Bullets, not paragraphs** — every detail as a dash-prefixed list item
|
||||
- **Tables for APIs** — method, path, request, response
|
||||
- **Code blocks only for schemas/configs** — max 2 per document
|
||||
- **30-80 lines per file** — split if over 120
|
||||
- **No:** prose explanations, future plans, testing strategy, empty sections
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality Standards
|
||||
|
||||
Your output will be evaluated on:
|
||||
|
||||
1. **TABLEOFCONTENTS.md completeness** — Can an agent find any topic by searching this one file?
|
||||
2. **Question quality** — Are the TOC questions phrased the way someone would actually ask them?
|
||||
3. **Format compliance** — Do all docs follow the token-efficient format? No prose, no fluff?
|
||||
4. **Accuracy** — Do the docs match what's actually in the code? Are file paths correct?
|
||||
5. **Coverage** — Are all high-impact areas documented? Are low-impact areas at least listed?
|
||||
6. **CLAUDE.md clarity** — Could a brand-new agent read CLAUDE.md and immediately know how to navigate the docs?
|
||||
7. **Cross-referencing** — Do Related sections link to the right companion docs?
|
||||
|
||||
---
|
||||
|
||||
## 7. Important Reminders
|
||||
|
||||
- **You are writing for AI agents, not humans.** Optimize for parseability and token efficiency, not readability or visual appeal.
|
||||
- **Accuracy over completeness.** It's better to document 80% of the codebase accurately than 100% with errors. If a discovery agent can't determine something with confidence, note it as `**Status:** Partial` and move on.
|
||||
- **Mirror the codebase's language.** Use the same names for things that the code uses. If the code calls it a "processor," don't call it a "handler" in the docs.
|
||||
- **File paths are critical.** Every doc should reference the actual source files it describes. Agents will use these paths to navigate directly to code.
|
||||
- **The TOC is the product.** The individual doc files are supporting material. If the TOC is excellent, the whole system works. If the TOC is poor, nothing else matters.
|
||||
- **Delegate aggressively.** You have access to the Task tool with sub-agents. Use it. The discovery phase should be 3-5 parallel agents. The writing phase should be 2-10 parallel agents depending on repo size. Your job is to orchestrate, synthesize, and build the TOC — not to read every file yourself.
|
||||
- **Do not add headers or comments to source code files.** Your output is documentation files only. Do not modify any existing source code.
|
||||
|
||||
---
|
||||
|
||||
## Now Begin
|
||||
|
||||
Start with Phase 1. Launch your discovery agents in parallel. Once they report back, proceed through the remaining phases. When complete, report what you've created and provide the full TABLEOFCONTENTS.md for review.
|
||||
@@ -5,6 +5,7 @@
|
||||
## Authentication & Users
|
||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||
|
||||
@@ -32,6 +33,14 @@
|
||||
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
|
||||
|
||||
## Reading Shelves (Goodreads, Hardcover)
|
||||
- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||
- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||
- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory)
|
||||
- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||
|
||||
## Audible Integration
|
||||
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
||||
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
||||
@@ -77,6 +86,7 @@
|
||||
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
|
||||
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
|
||||
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
|
||||
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
|
||||
|
||||
## BookDate (AI Recommendations)
|
||||
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
|
||||
@@ -89,6 +99,7 @@
|
||||
|
||||
## Admin Features
|
||||
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
||||
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
|
||||
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
||||
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||
@@ -150,3 +161,13 @@
|
||||
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
||||
**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||
|
||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
||||
|
||||
## Admin-Generated Login Token
|
||||
|
||||
- Login token stored as SHA-256 hash in `User.loginTokenHash`
|
||||
- Admin generates/revokes via user permissions modal
|
||||
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
|
||||
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
|
||||
- Invalid token redirects to `/login`
|
||||
|
||||
## Security
|
||||
|
||||
- Never log tokens
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Goodreads & Shelf Sync
|
||||
|
||||
**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture
|
||||
|
||||
## Overview
|
||||
Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core
|
||||
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||
- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing
|
||||
- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations
|
||||
- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes
|
||||
- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||
- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape)
|
||||
- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||
|
||||
### Database Models
|
||||
- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups.
|
||||
|
||||
## Goodreads RSS Feed
|
||||
- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}`
|
||||
- **Auth:** None required (public RSS)
|
||||
- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url`
|
||||
|
||||
## Shared Sync Core
|
||||
|
||||
`shelf-sync-core.service.ts` contains all provider-agnostic sync logic:
|
||||
|
||||
### Interface: `ShelfBook`
|
||||
```typescript
|
||||
{ bookId: string; title: string; author: string; coverUrl?: string }
|
||||
```
|
||||
|
||||
### Function: `processShelfBooks()`
|
||||
Accepts provider-agnostic book list + context, performs:
|
||||
1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`)
|
||||
2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info)
|
||||
3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days)
|
||||
4. **Request creation** — Calls `createRequestForUser()` for matched ASINs
|
||||
5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs
|
||||
6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls`
|
||||
|
||||
### Constants
|
||||
- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers)
|
||||
- `NO_MATCH_RETRY_DAYS` = 7
|
||||
|
||||
### Hook Factory: `createShelfHooks(endpoint)`
|
||||
Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves |
|
||||
| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) |
|
||||
| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) |
|
||||
| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) |
|
||||
| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` |
|
||||
|
||||
## Adding a New Provider
|
||||
1. Create Prisma shelf model + migration (BookMapping table is already shared)
|
||||
2. Create API client service for the external data source
|
||||
3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()`
|
||||
4. Create API routes (or use a generic route handler)
|
||||
5. Create hook file (~40 lines) using `createShelfHooks(endpoint)`
|
||||
6. Add tab in `AddShelfModal` with provider-specific form fields
|
||||
|
||||
## Related
|
||||
- [Hardcover sync](hardcover-sync.md)
|
||||
- [Background jobs](jobs.md)
|
||||
- [Scheduler](scheduler.md)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Hardcover Shelf Sync
|
||||
|
||||
**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation
|
||||
|
||||
## Overview
|
||||
Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()`
|
||||
- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core
|
||||
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||
- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes
|
||||
- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||
- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||
|
||||
### Database Models
|
||||
- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`)
|
||||
|
||||
## Hardcover API
|
||||
|
||||
- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based)
|
||||
- **Auth:** Bearer token in Authorization header
|
||||
- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables
|
||||
|
||||
### Query Strategies (custom lists)
|
||||
| Input | Strategy | Query root |
|
||||
|---|---|---|
|
||||
| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` |
|
||||
| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` |
|
||||
| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` |
|
||||
|
||||
### Status Lists
|
||||
- Prefix: `status-{id}` (e.g., `status-1`)
|
||||
- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }`
|
||||
- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish
|
||||
|
||||
## Sync Flow
|
||||
1. Fetch shelves from DB (all or specific `shelfId`)
|
||||
2. Decrypt API token (encryption service)
|
||||
3. Fetch books from Hardcover GraphQL API
|
||||
4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment)
|
||||
5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers |
|
||||
| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) |
|
||||
| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) |
|
||||
| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) |
|
||||
|
||||
## Key Details
|
||||
- **Token cleanup:** Strips `Bearer ` prefix if user pastes it
|
||||
- **Duplicate check:** Unique constraint on `(userId, listId)`
|
||||
- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups
|
||||
- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle)
|
||||
- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display
|
||||
|
||||
## Related
|
||||
- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core)
|
||||
- [Background jobs](jobs.md)
|
||||
- [Scheduler](scheduler.md)
|
||||
@@ -33,10 +33,16 @@ model NotificationBackend {
|
||||
|-------|---------|------------------------|
|
||||
| request_pending_approval | User creates request | Request needs admin approval |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
**Request Creation (POST /api/requests)**
|
||||
@@ -60,10 +66,14 @@ model NotificationBackend {
|
||||
- Approve (with or without pre-selected torrent): After job triggered → request_approved
|
||||
- Deny: No notification
|
||||
|
||||
**Request Available (processors: scan-plex, plex-recently-added)**
|
||||
- After `status: 'available'` update → request_available
|
||||
**Audiobook Available (processors: scan-plex, plex-recently-added)**
|
||||
- After `status: 'available'` update → request_available (requestType: 'audiobook')
|
||||
- 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)**
|
||||
- After `status: 'failed'` or `status: 'warn'` update → request_error
|
||||
- Includes error message in payload
|
||||
@@ -166,6 +176,7 @@ model NotificationBackend {
|
||||
author: string,
|
||||
userName: string,
|
||||
message?: string,
|
||||
requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
|
||||
timestamp: Date
|
||||
}
|
||||
```
|
||||
@@ -174,7 +185,7 @@ model NotificationBackend {
|
||||
- Calls NotificationService.sendNotification()
|
||||
- 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
|
||||
|
||||
@@ -203,10 +214,15 @@ src/lib/services/notification/
|
||||
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
|
||||
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
|
||||
|
||||
**Helper functions:**
|
||||
**Helper functions (notification.service.ts):**
|
||||
- `getRegisteredProviderTypes(): string[]` — all registered type keys
|
||||
- `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)
|
||||
|
||||
## 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.
|
||||
|
||||
**Adding New Event (e.g., download_complete):**
|
||||
1. Add 'download_complete' to NotificationEvent enum
|
||||
2. Add to event labels in UI
|
||||
3. Add trigger point in processor
|
||||
4. Add message formatting in Discord/Pushover formatters
|
||||
1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
|
||||
2. Optionally add `titleByRequestType` for type-specific titles
|
||||
3. Add trigger point in processor, passing `requestType` if relevant
|
||||
4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
|
||||
|
||||
## Tech Stack
|
||||
- Bull (job queue)
|
||||
|
||||
@@ -129,10 +129,10 @@ interface ScheduledJob {
|
||||
## Audible Refresh Processor
|
||||
|
||||
**Implementation:**
|
||||
1. Clear previous `isPopular`/`isNewRelease` flags
|
||||
2. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths
|
||||
1. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||
2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||
3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs
|
||||
4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
|
||||
5. Record sync timestamp (`lastAudibleSync`)
|
||||
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
|
||||
7. Perform fuzzy matching (70% threshold) against Plex library
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Bulk Import Feature
|
||||
|
||||
**Status:** ✅ Implemented | Admin-only | Multi-step wizard modal
|
||||
|
||||
## Overview
|
||||
Lets admins scan a server folder recursively, discover audiobook subfolders, match against Audible, review matches, and import selected books via the existing manual import pipeline.
|
||||
|
||||
## Flow
|
||||
1. **Select Folder** — Browse base folders (Downloads, Media Library, Book Drop), pick scan root
|
||||
2. **Scan & Match** — Recursively discover audiobook folders (max 10 levels), read metadata via ffprobe, search Audible per book (1.5s rate limit)
|
||||
3. **Review & Import** — Scrollable list with skip toggles, library status, confidence badges; Start Import queues organize_files jobs
|
||||
|
||||
## Key Details
|
||||
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
||||
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
||||
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further
|
||||
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file
|
||||
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown
|
||||
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
|
||||
- **Scan depth:** Max 10 levels recursion
|
||||
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
||||
- **Library check:** Uses `findPlexMatch()` for ASIN-based availability detection
|
||||
- **Import:** Reuses existing `organize_files` job queue (same as manual import)
|
||||
- **No new database tables** — all state is ephemeral during wizard session
|
||||
|
||||
## API Endpoints
|
||||
|
||||
**POST /api/admin/bulk-import/scan** (SSE stream)
|
||||
- Body: `{ rootPath: string }`
|
||||
- Path validation: must be within download_dir, media_dir, or /bookdrop
|
||||
- Streams events: `progress`, `discovery_complete`, `matching`, `book_matched`, `complete`, `error`
|
||||
- Each `book_matched` event includes: folderPath, match (Audible data), inLibrary, hasActiveRequest, metadataSource
|
||||
|
||||
**POST /api/admin/bulk-import/execute**
|
||||
- Body: `{ imports: Array<{ folderPath: string, asin: string }> }`
|
||||
- Creates audiobook records + requests, queues organize_files jobs
|
||||
- Returns: `{ success, results[], summary: { total, succeeded, failed } }`
|
||||
|
||||
## SSE Event Types
|
||||
|
||||
| Event | Data | When |
|
||||
|---|---|---|
|
||||
| `progress` | `{ phase, foldersScanned, audiobooksFound, currentFolder }` | During folder discovery |
|
||||
| `discovery_complete` | `{ totalFound, message }` | All folders scanned |
|
||||
| `matching` | `{ current, total, folderName, searchTerm }` | Before each Audible search |
|
||||
| `book_matched` | Full book result with match data | After each Audible search |
|
||||
| `complete` | `{ audiobooks[], totalFound, matched, inLibrary }` | All matching done |
|
||||
| `error` | `{ message }` | On failure |
|
||||
|
||||
## UI States
|
||||
|
||||
| State | Visual |
|
||||
|---|---|
|
||||
| Normal (will import) | Full opacity, blue toggle ON |
|
||||
| Skipped by user | 40% opacity, gray toggle OFF |
|
||||
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
|
||||
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
|
||||
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
|
||||
| Low confidence (folder name fallback) | Amber "Low Confidence" badge |
|
||||
|
||||
## Files
|
||||
|
||||
**Backend:**
|
||||
- `src/lib/utils/bulk-import-scanner.ts` — Folder discovery + ffprobe metadata
|
||||
- `src/app/api/admin/bulk-import/scan/route.ts` — SSE scan endpoint
|
||||
- `src/app/api/admin/bulk-import/execute/route.ts` — Batch import endpoint
|
||||
|
||||
**Frontend:**
|
||||
- `src/components/admin/BulkImportWizard.tsx` — Modal orchestrator
|
||||
- `src/components/admin/bulk-import/types.ts` — Shared types
|
||||
- `src/components/admin/bulk-import/ScanFolderStep.tsx` — Folder browser
|
||||
- `src/components/admin/bulk-import/ScanProgressStep.tsx` — Progress display
|
||||
- `src/components/admin/bulk-import/MatchReviewStep.tsx` — Review list + import
|
||||
|
||||
**Modified:**
|
||||
- `src/app/admin/page.tsx` — Added Bulk Import quick action + modal
|
||||
|
||||
## Related
|
||||
- [Manual Import](manual-import.md) — Single-book import (reused pipeline)
|
||||
- [File Organization](../phase3/file-organization.md) — organize_files job
|
||||
- [Audible Integration](../integrations/audible.md) — Search/scraping
|
||||
- [Background Jobs](../backend/services/jobs.md) — Job queue system
|
||||
@@ -0,0 +1,64 @@
|
||||
# Home Page Sections (Per-User Configurable)
|
||||
|
||||
**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories)
|
||||
|
||||
## Overview
|
||||
Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories.
|
||||
|
||||
## Data Models
|
||||
|
||||
**UserHomeSection** (`user_home_sections`):
|
||||
- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int)
|
||||
- Unique: `(userId, sectionType, categoryId)`
|
||||
- Default: Popular (0) + New Releases (1) created on first access
|
||||
|
||||
**AudibleCacheCategory** (`audible_cache_categories`):
|
||||
- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt`
|
||||
- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh |
|
||||
| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 |
|
||||
| GET | `/api/audible/categories` | user | Live scrape top-level categories |
|
||||
| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache |
|
||||
|
||||
## Refresh Processor (Unified Storage)
|
||||
- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections
|
||||
- Popular/new-releases use same wipe-and-populate pattern as user categories
|
||||
- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection`
|
||||
- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries
|
||||
- Batch cooldown between sections (10-20s random)
|
||||
- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID`
|
||||
|
||||
## AudibleService Methods
|
||||
- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]`
|
||||
- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results
|
||||
|
||||
## Frontend
|
||||
- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts`
|
||||
- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce
|
||||
- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header
|
||||
- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize
|
||||
- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections
|
||||
|
||||
## Key Decisions
|
||||
- 10 section limit per user (total)
|
||||
- Category picker scraped live (no categories table)
|
||||
- Top-level categories only (v1)
|
||||
- Wipe-and-re-scrape per category during refresh
|
||||
- Deduplication of categories across users before scraping
|
||||
- If category disappears, user sees empty section
|
||||
- 10-color palette assigned by sort order
|
||||
|
||||
## Files
|
||||
- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory)
|
||||
- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql`
|
||||
- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks)
|
||||
- Processor: `src/lib/processors/audible-refresh.processor.ts`
|
||||
- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts`
|
||||
- Hooks: `src/lib/hooks/useHomeSections.ts`
|
||||
- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx`
|
||||
- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts`
|
||||
@@ -0,0 +1,87 @@
|
||||
# Manual Import Feature — Acceptance Criteria
|
||||
|
||||
**Status:** ⏳ In Progress
|
||||
|
||||
## Overview
|
||||
Allow admins to manually import audiobook files from the server filesystem into RMAB's processing pipeline for a specific book.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC-1: Manual Import Button (Frontend)
|
||||
- [ ] "Manual Import" button visible on `AudiobookDetailsModal` for admin users only
|
||||
- [ ] Button hidden when book is in active processing states: `downloading`, `processing`, `searching`
|
||||
- [ ] Button uses `FolderArrowDownIcon` from Heroicons
|
||||
- [ ] Clicking opens the file browser modal
|
||||
|
||||
### AC-2: File Browser Modal — Phase 1 (Browse)
|
||||
- [ ] Modal opens at `max-w-2xl`, rounded-2xl, with header/breadcrumb/listing/footer regions
|
||||
- [ ] Root view shows two entry tiles: Downloads and Media Library (paths from `download_dir` and `media_dir` config)
|
||||
- [ ] Each folder row shows: folder icon, name, metadata line (audio file count, subfolder count, total size)
|
||||
- [ ] Blue `♪ N` badge on folders containing audio files
|
||||
- [ ] Folder icon swaps to `FolderOpenIcon` on hover (150ms transition)
|
||||
- [ ] Single-click selects folder (only if it has audio files); double-click navigates into it
|
||||
- [ ] Folders without audio files shown at reduced opacity, still navigable but not selectable
|
||||
- [ ] Breadcrumb navigation with clickable segments, home icon for root, ellipsis collapse for deep paths
|
||||
- [ ] Footer shows selected path (monospace), file stats, "Review Import →" button (only when valid selection)
|
||||
- [ ] Directional slide animations: right when going deeper, left when going back
|
||||
- [ ] Loading skeletons during directory fetch
|
||||
- [ ] Empty state for empty directories
|
||||
- [ ] Error state with "Try Again" for failed directory reads
|
||||
- [ ] Dark mode support throughout
|
||||
|
||||
### AC-3: File Browser Modal — Phase 2 (Confirm)
|
||||
- [ ] Slide transition from browse to confirm phase
|
||||
- [ ] Shows book context: cover thumbnail + title + author
|
||||
- [ ] Shows selected folder: path (monospace) + stats in inset block
|
||||
- [ ] Numbered "What will happen" list: (1) copy to media library, (2) tag metadata, (3) download cover art, (4) scan library
|
||||
- [ ] "Back" button returns to browse phase
|
||||
- [ ] "Start Import" primary button triggers the import
|
||||
- [ ] Button shows loading state during API call
|
||||
- [ ] Success: close modal, show success toast, trigger request list refresh
|
||||
- [ ] Error: show error toast, stay on confirm screen
|
||||
|
||||
### AC-4: Filesystem Browse API
|
||||
- [ ] `GET /api/admin/filesystem/browse?path=...` — admin-only endpoint
|
||||
- [ ] Returns directory listing: `{ entries: [{ name, type, audioFileCount, subfolderCount, totalSize }] }`
|
||||
- [ ] If no `path` param, returns root directories (download_dir, media_dir from config)
|
||||
- [ ] Path validation: must be within allowed root directories (prevent directory traversal)
|
||||
- [ ] Handles permission errors gracefully
|
||||
- [ ] Sorts: folders first, then alphabetical
|
||||
|
||||
### AC-5: Manual Import API
|
||||
- [ ] `POST /api/admin/manual-import` — admin-only endpoint
|
||||
- [ ] Request body: `{ audiobookId: string, folderPath: string }`
|
||||
- [ ] Path validation: folderPath must be within allowed roots
|
||||
- [ ] Validates folder exists and contains audio files
|
||||
- [ ] If no existing request: creates request (status: `processing`) + queues `organize_files` job
|
||||
- [ ] If existing request (non-active state): updates status to `processing` + queues `organize_files` job
|
||||
- [ ] Returns: `{ success: true, requestId: string }`
|
||||
- [ ] Proper error responses for: invalid path, no audio files, already processing, book not found
|
||||
|
||||
### AC-6: Integration with Existing Pipeline
|
||||
- [ ] The `organize_files` job processes the manual import folder identically to download-client-delivered folders
|
||||
- [ ] Files are copied (not moved) to the media library
|
||||
- [ ] Metadata tagging, cover art download, file hash generation all work as normal
|
||||
- [ ] Library scan triggered after organization (if configured)
|
||||
- [ ] Request status progresses: processing → downloaded → available (via scheduled scan)
|
||||
|
||||
### AC-7: Docker Build
|
||||
- [ ] `docker compose build readmeabook` succeeds with no errors
|
||||
|
||||
## Non-Goals
|
||||
- No "move" option (copy only, matching existing pipeline)
|
||||
- No file-level selection (folder only)
|
||||
- No drag-and-drop upload
|
||||
- No non-admin access
|
||||
|
||||
## Technical Notes
|
||||
- Audio extensions: `.m4b`, `.m4a`, `.mp3`, `.mp4`, `.aa`, `.aax`, `.flac`, `.ogg` (from `src/lib/constants/audio-formats.ts`)
|
||||
- Config keys: `download_dir` (database), `media_dir` (database)
|
||||
- Existing file organizer: `src/lib/utils/file-organizer.ts`
|
||||
- Organize processor: `src/lib/processors/organize-files.processor.ts`
|
||||
- Job queue service: `src/lib/services/job-queue.service.ts`
|
||||
- Auth middleware: `requireAuth()`, `requireAdmin()` from `src/lib/middleware/auth.ts`
|
||||
- Frontend API pattern: `fetchWithAuth()` from `src/lib/utils/api.ts`
|
||||
- Modal base: `src/components/ui/Modal.tsx`
|
||||
- Audiobook details modal: `src/components/audiobooks/AudiobookDetailsModal.tsx`
|
||||
- Toast: `useToast()` from toast context
|
||||
@@ -33,6 +33,7 @@ Configurable Audible region for accurate metadata matching across different inte
|
||||
- India (`in`) - `audible.in` (English)
|
||||
- Germany (`de`) - `audible.de` (non-English)
|
||||
- Spain (`es`) - `audible.es` (non-English)
|
||||
- French (`fr`) - `audible.fr` (non-English)
|
||||
|
||||
**`isEnglish` Flag:**
|
||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||
@@ -127,11 +128,11 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
||||
Discovery APIs serve cached data from DB with real-time matching.
|
||||
|
||||
**Flow:**
|
||||
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases
|
||||
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories
|
||||
2. Downloads and caches cover thumbnails locally (reduces Audible load)
|
||||
3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings
|
||||
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs
|
||||
4. Cleans up unused thumbnails after sync
|
||||
5. API routes query DB → apply real-time matching → return enriched results
|
||||
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results
|
||||
6. Homepage loads instantly (no Audible API hits)
|
||||
|
||||
## Thumbnail Caching
|
||||
|
||||
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Base URL for mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
||||
|
||||
#### Section 2: Indexer Search
|
||||
@@ -180,18 +180,18 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
|
||||
|
||||
### Method 1: ASIN Search (exact match)
|
||||
```
|
||||
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
Search: https://annas-archive.gl/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
↓
|
||||
MD5 Page: https://annas-archive.li/md5/[md5]
|
||||
MD5 Page: https://annas-archive.gl/md5/[md5]
|
||||
↓
|
||||
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
|
||||
Slow Download: https://annas-archive.gl/slow_download/[md5]/0/5
|
||||
↓
|
||||
File Server: http://[server]/path/to/file.epub
|
||||
```
|
||||
|
||||
### Method 2: Title + Author (fallback)
|
||||
```
|
||||
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
|
||||
Search: https://annas-archive.gl/search?q=Title+Author&ext=epub&lang=en
|
||||
↓ (Same flow from MD5 page)
|
||||
```
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ type TorrentState =
|
||||
- `forcedUP` → `seeding`/`completed` enables monitor to trigger import
|
||||
- `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
|
||||
- `stoppedUP` → `seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
|
||||
- `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
|
||||
|
||||
@@ -81,7 +81,7 @@ src/app/admin/settings/
|
||||
|
||||
1. **Anna's Archive Section**
|
||||
- Enable toggle for Anna's Archive downloads
|
||||
- Base URL (default: `https://annas-archive.li`)
|
||||
- Base URL (default: `https://annas-archive.gl`)
|
||||
- FlareSolverr URL (optional, for Cloudflare bypass)
|
||||
|
||||
2. **Indexer Search Section**
|
||||
@@ -101,7 +101,7 @@ src/app/admin/settings/
|
||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
||||
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
|
||||
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||
|
||||
**Behavior:**
|
||||
@@ -271,7 +271,7 @@ src/app/admin/settings/
|
||||
|
||||
**PUT /api/admin/settings/audible**
|
||||
- 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
|
||||
|
||||
**PUT /api/admin/settings/prowlarr/indexers**
|
||||
|
||||
Generated
+816
-27
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.7",
|
||||
"version": "1.1.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -18,7 +18,9 @@
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.7.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.12.0",
|
||||
@@ -43,9 +45,9 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@types/adm-zip": "^0.5.6",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/adm-zip": "^0.5.6",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bull": "^4.10.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Normalize existing local usernames to lowercase (idempotent - safe to run multiple times)
|
||||
-- Only affects local auth users, not Plex/OIDC users
|
||||
UPDATE users SET plex_username = LOWER(plex_username)
|
||||
WHERE auth_provider = 'local' AND deleted_at IS NULL AND plex_username != LOWER(plex_username);
|
||||
|
||||
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7))
|
||||
WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%' AND plex_id != LOWER(plex_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
|
||||
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
|
||||
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
|
||||
-- because subtitle stripping collapsed them to the same base title.
|
||||
-- The works table auto-rebuilds from dedup logic as users browse.
|
||||
DELETE FROM work_asins;
|
||||
DELETE FROM works;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "download_access" BOOLEAN;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "works" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "works_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "work_asins" (
|
||||
"id" TEXT NOT NULL,
|
||||
"work_id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"narrator" TEXT,
|
||||
"duration_minutes" INTEGER,
|
||||
"is_canonical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "works_title_idx" ON "works"("title");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "works_author_idx" ON "works"("author");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "watched_series" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"series_asin" TEXT NOT NULL,
|
||||
"series_title" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"last_checked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "watched_series_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "watched_authors" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"author_asin" TEXT NOT NULL,
|
||||
"author_name" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"last_checked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "watched_authors_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_series_user_id_idx" ON "watched_series"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_series_series_asin_idx" ON "watched_series"("series_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "watched_series_user_id_series_asin_key" ON "watched_series"("user_id", "series_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_authors_user_id_idx" ON "watched_authors"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "watched_authors_author_asin_idx" ON "watched_authors"("author_asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "watched_authors_user_id_author_asin_key" ON "watched_authors"("user_id", "author_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "watched_series" ADD CONSTRAINT "watched_series_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "watched_authors" ADD CONSTRAINT "watched_authors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "hardcover_shelves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"list_id" TEXT NOT NULL,
|
||||
"api_token" TEXT NOT NULL,
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"book_count" INTEGER,
|
||||
"cover_urls" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "hardcover_book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"hardcover_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Normalize existing local usernames to lowercase
|
||||
UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL;
|
||||
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%';
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"external_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Migrate data from goodreads_book_mappings
|
||||
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||
SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||
FROM "goodreads_book_mappings";
|
||||
|
||||
-- Migrate data from hardcover_book_mappings
|
||||
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||
SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||
FROM "hardcover_book_mappings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "goodreads_book_mappings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "hardcover_book_mappings";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin");
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"token_prefix" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'user',
|
||||
"created_by_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_created_by_id_idx" ON "api_tokens"("created_by_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_home_sections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"section_type" TEXT NOT NULL,
|
||||
"category_id" TEXT,
|
||||
"category_name" TEXT,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audible_cache_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL,
|
||||
"last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_is_popular_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_is_new_release_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_popular_rank_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx";
|
||||
|
||||
-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories)
|
||||
ALTER TABLE "audible_cache" DROP COLUMN "is_popular",
|
||||
DROP COLUMN "is_new_release",
|
||||
DROP COLUMN "popular_rank",
|
||||
DROP COLUMN "new_release_rank";
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ignored_audiobooks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"cover_art_url" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable - Add login_token_hash column for admin-generated login tokens
|
||||
ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable - Add sessions_invalidated_at column for immediate session revocation
|
||||
ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ;
|
||||
+264
-27
@@ -55,6 +55,13 @@ model User {
|
||||
|
||||
// Fine-grained permissions
|
||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||
|
||||
// Login token (admin-generated, for direct URL login)
|
||||
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
|
||||
|
||||
// Session invalidation (set when login token is revoked to force-logout active sessions)
|
||||
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
|
||||
|
||||
// Soft delete support
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
@@ -65,8 +72,15 @@ model User {
|
||||
bookDateRecommendations BookDateRecommendation[]
|
||||
bookDateSwipes BookDateSwipe[]
|
||||
goodreadsShelves GoodreadsShelf[]
|
||||
hardcoverShelves HardcoverShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
homeSections UserHomeSection[]
|
||||
ignoredAudiobooks IgnoredAudiobook[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -93,12 +107,6 @@ model AudibleCache {
|
||||
rating Decimal? @db.Decimal(3, 2)
|
||||
genres Json @default("[]")
|
||||
|
||||
// Discovery categories
|
||||
isPopular Boolean @default(false) @map("is_popular")
|
||||
isNewRelease Boolean @default(false) @map("is_new_release")
|
||||
popularRank Int? @map("popular_rank")
|
||||
newReleaseRank Int? @map("new_release_rank")
|
||||
|
||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -106,10 +114,6 @@ model AudibleCache {
|
||||
@@index([asin])
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@index([isPopular])
|
||||
@@index([isNewRelease])
|
||||
@@index([popularRank])
|
||||
@@index([newReleaseRank])
|
||||
@@map("audible_cache")
|
||||
}
|
||||
|
||||
@@ -176,6 +180,7 @@ model Audiobook {
|
||||
year Int? // Release year extracted from releaseDate
|
||||
series String? // Book series name (e.g., "The Mistborn Saga")
|
||||
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
|
||||
status String @default("requested") // requested, downloading, processing, completed, failed
|
||||
@@ -230,6 +235,7 @@ model Request {
|
||||
importAttempts Int @default(0) @map("import_attempts")
|
||||
maxImportRetries Int @default(5) @map("max_import_retries")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
customSearchTerms String? @map("custom_search_terms") @db.Text
|
||||
lastImportAt DateTime? @map("last_import_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -389,7 +395,7 @@ model ScheduledJob {
|
||||
|
||||
model BookDateConfig {
|
||||
id String @id @default(uuid())
|
||||
provider String // 'openai' | 'claude' | 'custom'
|
||||
provider String // 'openai' | 'claude' | 'gemini' | 'custom'
|
||||
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
|
||||
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
|
||||
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
|
||||
@@ -493,6 +499,34 @@ model ReportedIssue {
|
||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// API TOKEN TABLE
|
||||
// Static API tokens for programmatic access (alternative to JWT)
|
||||
// Documentation: documentation/backend/services/api-tokens.md
|
||||
// ============================================================================
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(uuid())
|
||||
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
|
||||
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
|
||||
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
|
||||
role String @default("user") // Token role: 'admin' or 'user'
|
||||
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
|
||||
userId String @map("user_id") // The user identity this token acts as
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at") // null = never expires
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tokenHash])
|
||||
@@index([createdById])
|
||||
@@index([userId])
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
model GoodreadsShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
@@ -500,9 +534,10 @@ model GoodreadsShelf {
|
||||
rssUrl String @map("rss_url") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -512,19 +547,221 @@ model GoodreadsShelf {
|
||||
@@map("goodreads_shelves")
|
||||
}
|
||||
|
||||
model GoodreadsBookMapping {
|
||||
id String @id @default(uuid())
|
||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
// ============================================================================
|
||||
// UNIFIED BOOK MAPPING TABLE
|
||||
// Global book-to-ASIN mapping cache shared across all shelf providers.
|
||||
// Uses provider + externalBookId composite key for cross-provider dedup.
|
||||
// ============================================================================
|
||||
|
||||
@@index([goodreadsBookId])
|
||||
model BookMapping {
|
||||
id String @id @default(uuid())
|
||||
provider String // "goodreads", "hardcover", etc.
|
||||
externalBookId String @map("external_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([provider, externalBookId])
|
||||
@@index([provider, externalBookId])
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
@@map("book_mappings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HARDCOVER SYNC TABLES
|
||||
// Per-user Hardcover list subscriptions
|
||||
// ============================================================================
|
||||
|
||||
model HardcoverShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String // Extracted from Hardcover API list name or status
|
||||
listId String @map("list_id") // Hardcover List ID or Status ID
|
||||
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, listId])
|
||||
@@index([userId])
|
||||
@@map("hardcover_shelves")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKS TABLE
|
||||
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
||||
// to a single logical work for library matching across editions.
|
||||
// Documentation: documentation/integrations/audible.md
|
||||
// ============================================================================
|
||||
|
||||
model Work {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
author String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
asins WorkAsin[]
|
||||
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@map("works")
|
||||
}
|
||||
|
||||
model WorkAsin {
|
||||
id String @id @default(uuid())
|
||||
workId String @map("work_id")
|
||||
asin String @unique
|
||||
narrator String?
|
||||
durationMinutes Int? @map("duration_minutes")
|
||||
isCanonical Boolean @default(false) @map("is_canonical")
|
||||
source String // 'dedup_auto' | 'admin_manual'
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workId])
|
||||
@@index([asin])
|
||||
@@map("work_asins")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WATCHED LISTS TABLES
|
||||
// Per-user series and author subscriptions for automatic new-release requests.
|
||||
// Documentation: documentation/features/watched-lists.md
|
||||
// ============================================================================
|
||||
|
||||
model WatchedSeries {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
seriesAsin String @map("series_asin")
|
||||
seriesTitle String @map("series_title")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, seriesAsin])
|
||||
@@index([userId])
|
||||
@@index([seriesAsin])
|
||||
@@map("watched_series")
|
||||
}
|
||||
|
||||
model WatchedAuthor {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
authorAsin String @map("author_asin")
|
||||
authorName String @map("author_name")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, authorAsin])
|
||||
@@index([userId])
|
||||
@@index([authorAsin])
|
||||
@@map("watched_authors")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IGNORED AUDIOBOOK TABLE
|
||||
// Per-user ignore list for auto-request suppression.
|
||||
// Stores the ASIN the user clicked ignore on; works-system expansion
|
||||
// happens at check-time in request-creator.service.ts.
|
||||
// Documentation: documentation/features/ignored-audiobooks.md
|
||||
// ============================================================================
|
||||
|
||||
model IgnoredAudiobook {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
asin String // Audible ASIN that was explicitly ignored
|
||||
title String // Display only — snapshot at ignore time
|
||||
author String // Display only — snapshot at ignore time
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, asin])
|
||||
@@index([userId])
|
||||
@@index([asin])
|
||||
@@map("ignored_audiobooks")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER HOME SECTION TABLE
|
||||
// Per-user configurable home page sections (popular, new_releases, category)
|
||||
// Documentation: documentation/features/home-sections.md
|
||||
// ============================================================================
|
||||
|
||||
model UserHomeSection {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category'
|
||||
categoryId String? @map("category_id") // Audible category node ID (only for type 'category')
|
||||
categoryName String? @map("category_name") // Display name (only for type 'category')
|
||||
sortOrder Int @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, sectionType, categoryId])
|
||||
@@index([userId])
|
||||
@@index([sortOrder])
|
||||
@@map("user_home_sections")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIBLE CACHE CATEGORY TABLE
|
||||
// Join table linking AudibleCache entries to Audible categories with ranking
|
||||
// Documentation: documentation/features/home-sections.md
|
||||
// ============================================================================
|
||||
|
||||
model AudibleCacheCategory {
|
||||
id String @id @default(uuid())
|
||||
asin String
|
||||
categoryId String @map("category_id")
|
||||
rank Int
|
||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([asin, categoryId])
|
||||
@@index([categoryId])
|
||||
@@index([asin])
|
||||
@@index([categoryId, rank])
|
||||
@@map("audible_cache_categories")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATA MIGRATION TRACKING
|
||||
// Tracks which data migration SQL scripts have been executed (run-once).
|
||||
// ============================================================================
|
||||
|
||||
model DataMigration {
|
||||
name String @id
|
||||
executedAt DateTime @default(now()) @map("executed_at")
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>img-coverart</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<rect id="path-1" x="0" y="0" width="500" height="500"></rect>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Account-details:-membership-asin-doc" transform="translate(-87.000000, -867.000000)">
|
||||
<g id="Group" transform="translate(65.000000, 780.000000)">
|
||||
<g id="img-coverart" transform="translate(22.000000, 87.000000)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<use id="mask" fill="#BBBBBB" xlink:href="#path-1"></use>
|
||||
<path d="M251.314605,307.191176 L126.315789,229.090627 L126.315789,250.186562 L251.314605,328.289474 L376.315789,250.186562 L376.315789,229.090627 L251.314605,307.191176 Z M300.338486,257.198698 L318.743522,245.697622 L318.757695,245.697622 C304.238718,223.902504 279.436447,209.540923 251.277279,209.540923 C223.146464,209.540923 198.363093,223.878883 183.839389,245.643293 L183.952782,245.655104 C184.933157,244.762229 185.930063,243.885889 186.955321,243.03317 C222.033803,213.960416 272.668324,220.342816 300.338486,257.198698 Z M214.370819,264.53208 C220.980666,259.892912 228.629944,257.226098 236.796575,257.226098 C250.228874,257.226098 262.283922,264.413975 270.556862,275.811119 L288.30989,264.716324 L288.319343,264.716324 C280.157438,253.040453 266.61174,245.39669 251.277753,245.39669 C236.026448,245.39669 222.549266,252.95778 214.370819,264.53208 Z M166.789394,213.901363 C218.255462,173.164548 291.088955,184.079823 329.878678,238.171964 L330.136173,238.568797 L349.186133,226.701596 C328.31953,194.777784 292.263042,173.684211 251.278701,173.684211 C210.866048,173.684211 174.47174,194.572281 153.416152,226.633095 C157.283311,222.56083 162.29621,217.458689 166.789394,213.901363 Z" id="icn-audible" fill="#FFFFFF" mask="url(#mask-2)"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Adjust Search Terms Modal
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
interface AdjustSearchTermsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
currentSearchTerms?: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AdjustSearchTermsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
currentSearchTerms,
|
||||
onSuccess,
|
||||
}: AdjustSearchTermsModalProps) {
|
||||
const toast = useToast();
|
||||
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
const handleClose = () => {
|
||||
setSearchTerms(currentSearchTerms || title);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = async (triggerSearch: boolean) => {
|
||||
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
|
||||
setter(true);
|
||||
|
||||
try {
|
||||
// If terms match the original title, clear the override
|
||||
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
|
||||
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update search terms');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.searchTriggered) {
|
||||
toast.success('Search terms saved and search triggered');
|
||||
} else {
|
||||
toast.success('Search terms saved');
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerms(title);
|
||||
};
|
||||
|
||||
const isLoading = isSaving || isSavingAndSearching;
|
||||
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
|
||||
const isCustom = searchTerms.trim() !== title;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
|
||||
<div className="space-y-4">
|
||||
{/* Original info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Original Title
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
|
||||
</div>
|
||||
|
||||
{/* Search terms input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="search-terms"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
|
||||
>
|
||||
Search Terms
|
||||
</label>
|
||||
<input
|
||||
id="search-terms"
|
||||
type="text"
|
||||
value={searchTerms}
|
||||
onChange={(e) => setSearchTerms(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
placeholder="Enter custom search terms..."
|
||||
/>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Reset to original title
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(false)}
|
||||
disabled={isLoading || !searchTerms.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(true)}
|
||||
disabled={isLoading || !searchTerms.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
* Component: Confirm Dialog
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Reusable confirmation dialog for destructive actions
|
||||
* Reusable confirmation dialog for destructive actions.
|
||||
* Features: backdrop blur, smooth enter animation, Escape to close, focus trap, ARIA.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -30,99 +31,177 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the cancel button on open (safer default for destructive dialogs)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay to let animation start before stealing focus
|
||||
const t = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape to close + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus trap: tab cycles only within dialog
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (!focusable || focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
// Prevent body scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const confirmButtonClasses =
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white';
|
||||
const isDestructive = confirmVariant === 'danger';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-desc"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
className="animate-dialog-backdrop fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="animate-dialog-panel relative w-full max-w-sm rounded-2xl overflow-hidden bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/10 dark:ring-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon well */}
|
||||
<div className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
isDestructive
|
||||
? 'bg-red-50 dark:bg-red-500/10'
|
||||
: 'bg-blue-50 dark:bg-blue-500/10'
|
||||
}`}>
|
||||
{isDestructive ? (
|
||||
<svg
|
||||
className={`h-6 w-6 ${
|
||||
confirmVariant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
className="w-5 h-5 text-red-500 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{confirmVariant === 'danger' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.75"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div id="confirm-dialog-desc" className="mt-1.5">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 bg-gray-50/80 dark:bg-white/[0.03] border-t border-gray-100 dark:border-white/[0.06]">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-xl text-gray-700 dark:text-gray-300 bg-white dark:bg-white/[0.06] hover:bg-gray-100 dark:hover:bg-white/[0.1] border border-gray-200 dark:border-white/[0.1] transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-xl text-white transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 active:scale-[0.97] ${
|
||||
isDestructive
|
||||
? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ interface RecentRequest {
|
||||
completedAt: Date | null;
|
||||
errorMessage: string | null;
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -161,7 +163,7 @@ function getInitialParams(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
|
||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl' }: RecentRequestsTableProps) {
|
||||
const toast = useToast();
|
||||
|
||||
// Get initial filter state from URL (only evaluated once due to lazy init)
|
||||
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryDownload = async (requestId: string) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.message || 'Failed to retry download');
|
||||
}
|
||||
|
||||
toast.success(responseData.message || 'Download retry initiated');
|
||||
await mutate(apiUrl);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to retry download:', error);
|
||||
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Render loading state
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
{request.customSearchTerms && (
|
||||
<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 dark:text-blue-200"
|
||||
title={`Custom search: ${request.customSearchTerms}`}
|
||||
>
|
||||
<svg className="w-3 h-3" 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>
|
||||
Custom Search
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
type: request.type,
|
||||
asin: request.asin,
|
||||
torrentUrl: request.torrentUrl,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
onCancel={handleCancel}
|
||||
onRetryDownload={handleRetryDownload}
|
||||
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
||||
onFetchEbook={handleFetchEbook}
|
||||
onSearchTermsUpdated={() => mutate(apiUrl)}
|
||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||
annasArchiveBaseUrl={annasArchiveBaseUrl}
|
||||
isLoading={isDeleting || isFetchingEbook}
|
||||
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
}}
|
||||
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
||||
requestStatus={viewDetailsStatus}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export interface RequestActionsDropdownProps {
|
||||
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
|
||||
type?: 'audiobook' | 'ebook';
|
||||
asin?: string | null;
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => Promise<void>;
|
||||
onCancel: (requestId: string) => Promise<void>;
|
||||
onRetryDownload?: (requestId: string) => Promise<void>;
|
||||
onViewDetails?: (asin: string) => void;
|
||||
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||
onSearchTermsUpdated?: () => void;
|
||||
ebookSidecarEnabled?: boolean;
|
||||
annasArchiveBaseUrl?: string;
|
||||
isLoading?: boolean;
|
||||
@@ -37,15 +42,18 @@ export function RequestActionsDropdown({
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
onRetryDownload,
|
||||
onViewDetails,
|
||||
onFetchEbook,
|
||||
onSearchTermsUpdated,
|
||||
ebookSidecarEnabled = false,
|
||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||
annasArchiveBaseUrl = 'https://annas-archive.gl',
|
||||
isLoading = false,
|
||||
}: RequestActionsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Determine request type
|
||||
@@ -54,10 +62,11 @@ export function RequestActionsDropdown({
|
||||
// View Details: available when ASIN exists (audiobook requests only)
|
||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
@@ -120,7 +129,16 @@ export function RequestActionsDropdown({
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearch(true);
|
||||
if (isEbook) {
|
||||
setShowInteractiveSearchEbook(true);
|
||||
} else {
|
||||
setShowInteractiveSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
setIsOpen(false);
|
||||
setShowAdjustSearchTerms(true);
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbook = () => {
|
||||
@@ -128,6 +146,17 @@ export function RequestActionsDropdown({
|
||||
setShowInteractiveSearchEbook(true);
|
||||
};
|
||||
|
||||
const handleRetryDownload = async () => {
|
||||
setIsOpen(false);
|
||||
if (onRetryDownload) {
|
||||
try {
|
||||
await onRetryDownload(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to retry download:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
@@ -253,6 +282,35 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Adjust Search Terms */}
|
||||
{canAdjustSearchTerms && (
|
||||
<button
|
||||
onClick={handleAdjustSearchTerms}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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 className="flex items-center gap-1.5">
|
||||
Adjust Search Terms
|
||||
{request.customSearchTerms && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* View Source */}
|
||||
{canViewSource && viewSourceUrl && (
|
||||
<a
|
||||
@@ -328,8 +386,32 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view actions and other actions */}
|
||||
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
|
||||
{/* Retry Download */}
|
||||
{canRetryDownload && (
|
||||
<button
|
||||
onClick={handleRetryDownload}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Retry Download
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view/retry actions and other actions */}
|
||||
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
@@ -358,7 +440,7 @@ export function RequestActionsDropdown({
|
||||
)}
|
||||
|
||||
{/* Divider before delete */}
|
||||
{canDelete && (canSearch || canCancel) && (
|
||||
{canDelete && (canSearch || canRetryDownload || canCancel) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
@@ -421,6 +503,7 @@ export function RequestActionsDropdown({
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
}}
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Interactive Search Modal (Ebook) */}
|
||||
@@ -433,6 +516,18 @@ export function RequestActionsDropdown({
|
||||
author: request.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
<AdjustSearchTermsModal
|
||||
isOpen={showAdjustSearchTerms}
|
||||
onClose={() => setShowAdjustSearchTerms(false)}
|
||||
requestId={request.requestId}
|
||||
title={request.title}
|
||||
author={request.author}
|
||||
currentSearchTerms={request.customSearchTerms}
|
||||
onSuccess={onSearchTermsUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+209
-123
@@ -78,13 +78,11 @@ function AdminJobsPageContent() {
|
||||
const showEditDialog = (job: ScheduledJob) => {
|
||||
setEditForm({ schedule: job.schedule, enabled: job.enabled });
|
||||
|
||||
// Check if it's a preset
|
||||
const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule);
|
||||
if (preset) {
|
||||
setScheduleMode('preset');
|
||||
setSelectedPreset(preset.cron);
|
||||
} else {
|
||||
// Try to parse as custom schedule
|
||||
const parsed = cronToCustomSchedule(job.schedule);
|
||||
if (parsed.type === 'custom') {
|
||||
setScheduleMode('advanced');
|
||||
@@ -111,7 +109,7 @@ function AdminJobsPageContent() {
|
||||
method: 'POST',
|
||||
});
|
||||
toast.success(`Job "${jobName}" triggered successfully`);
|
||||
fetchJobs(); // Refresh list
|
||||
fetchJobs();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job';
|
||||
toast.error(errorMsg);
|
||||
@@ -124,7 +122,6 @@ function AdminJobsPageContent() {
|
||||
const saveJobSchedule = async () => {
|
||||
if (!editDialog.job) return;
|
||||
|
||||
// Calculate final cron expression based on mode
|
||||
let finalCron: string;
|
||||
if (scheduleMode === 'preset') {
|
||||
finalCron = selectedPreset;
|
||||
@@ -134,7 +131,6 @@ function AdminJobsPageContent() {
|
||||
finalCron = editForm.schedule;
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
if (!isValidCron(finalCron)) {
|
||||
toast.error('Invalid cron expression. Please check your schedule.');
|
||||
return;
|
||||
@@ -151,7 +147,7 @@ function AdminJobsPageContent() {
|
||||
});
|
||||
toast.success(`Job "${editDialog.job.name}" updated successfully`);
|
||||
hideEditDialog();
|
||||
fetchJobs(); // Refresh list
|
||||
fetchJobs();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update job';
|
||||
toast.error(errorMsg);
|
||||
@@ -173,36 +169,131 @@ function AdminJobsPageContent() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Manage recurring tasks and automated jobs
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* 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>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage recurring tasks and automated jobs
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
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-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" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<svg className="w-5 h-5" 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" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Jobs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Jobs — Card layout on mobile, Table on sm+ */}
|
||||
<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">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
|
||||
<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>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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex 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">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-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-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-3">
|
||||
Confirm Job Trigger
|
||||
</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?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -347,12 +438,27 @@ function AdminJobsPageContent() {
|
||||
|
||||
{/* Edit Job Dialog */}
|
||||
{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="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Edit Job Schedule
|
||||
</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
<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-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
{/* 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
|
||||
</h3>
|
||||
<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 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
@@ -362,46 +468,29 @@ function AdminJobsPageContent() {
|
||||
type="text"
|
||||
value={editDialog.job.name}
|
||||
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>
|
||||
|
||||
{/* Schedule Mode Tabs */}
|
||||
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Schedule Type
|
||||
</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => setScheduleMode('preset')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'preset'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
Common Schedules
|
||||
</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>
|
||||
<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
|
||||
key={mode}
|
||||
onClick={() => setScheduleMode(mode)}
|
||||
className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
scheduleMode === mode
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preset Mode */}
|
||||
@@ -418,16 +507,16 @@ function AdminJobsPageContent() {
|
||||
value={preset.cron}
|
||||
checked={selectedPreset === preset.cron}
|
||||
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">
|
||||
{preset.label}
|
||||
</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}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
|
||||
</label>
|
||||
<select
|
||||
value={customSchedule.type}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as any })}
|
||||
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"
|
||||
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 text-sm"
|
||||
>
|
||||
<option value="minutes">Every X minutes</option>
|
||||
<option value="hours">Every X hours</option>
|
||||
@@ -456,7 +545,6 @@ function AdminJobsPageContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Minutes/Hours Interval */}
|
||||
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
|
||||
<div>
|
||||
<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}
|
||||
value={customSchedule.interval || 1}
|
||||
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">
|
||||
Run every {customSchedule.interval || 1} {customSchedule.type}
|
||||
@@ -476,12 +564,11 @@ function AdminJobsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily/Weekly/Monthly Time */}
|
||||
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Hour (0-23)
|
||||
Hour (0–23)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -494,12 +581,12 @@ function AdminJobsPageContent() {
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minute (0-59)
|
||||
Minute (0–59)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -512,13 +599,12 @@ function AdminJobsPageContent() {
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Weekly Day Selection */}
|
||||
{customSchedule.type === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -527,7 +613,7 @@ function AdminJobsPageContent() {
|
||||
<select
|
||||
value={customSchedule.dayOfWeek || 0}
|
||||
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="1">Monday</option>
|
||||
@@ -540,11 +626,10 @@ function AdminJobsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Day Selection */}
|
||||
{customSchedule.type === 'monthly' && (
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
@@ -552,12 +637,11 @@ function AdminJobsPageContent() {
|
||||
max="31"
|
||||
value={customSchedule.dayOfMonth || 1}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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="text-sm font-medium text-blue-900 dark:text-blue-200">
|
||||
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
|
||||
@@ -571,30 +655,32 @@ function AdminJobsPageContent() {
|
||||
|
||||
{/* Advanced Mode */}
|
||||
{scheduleMode === 'advanced' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.schedule}
|
||||
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format: minute hour day month weekday
|
||||
</p>
|
||||
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div>• */15 * * * * = Every 15 minutes</div>
|
||||
<div>• 0 */6 * * * = Every 6 hours</div>
|
||||
<div>• 0 0 * * * = Daily at midnight</div>
|
||||
<div>• 0 0 * * 0 = Weekly on Sunday</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.schedule}
|
||||
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
|
||||
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 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format: minute hour day month weekday
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
|
||||
<div>*/15 * * * * = Every 15 minutes</div>
|
||||
<div>0 */6 * * * = Every 6 hours</div>
|
||||
<div>0 0 * * * = Daily at midnight</div>
|
||||
<div>0 0 * * 0 = Weekly on Sunday</div>
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
Preview: {cronToHuman(editForm.schedule)}
|
||||
</div>
|
||||
@@ -604,34 +690,34 @@ function AdminJobsPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enabled Checkbox */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={editForm.enabled}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Dialog footer */}
|
||||
<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
|
||||
onClick={hideEditDialog}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={saveJobSchedule}
|
||||
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'}
|
||||
</button>
|
||||
|
||||
+236
-148
@@ -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() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -65,9 +178,7 @@ export default function AdminLogsPage() {
|
||||
const { data, error } = useSWR<LogsData>(
|
||||
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
|
||||
authenticatedFetcher,
|
||||
{
|
||||
refreshInterval: 10000, // Refresh every 10 seconds
|
||||
}
|
||||
{ refreshInterval: 10000 }
|
||||
);
|
||||
|
||||
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="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">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Logs
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load system logs'}
|
||||
</p>
|
||||
@@ -101,80 +210,45 @@ export default function AdminLogsPage() {
|
||||
|
||||
const logs = data?.logs || [];
|
||||
const pagination = data?.pagination;
|
||||
|
||||
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`;
|
||||
};
|
||||
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* 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>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
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-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" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<svg className="w-5 h-5" 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" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap gap-4">
|
||||
{/* Filters — full-width stacked on mobile */}
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
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"
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
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"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
@@ -186,16 +260,13 @@ export default function AdminLogsPage() {
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value);
|
||||
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"
|
||||
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
|
||||
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"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="search_indexers">Search Indexers</option>
|
||||
@@ -215,8 +286,77 @@ export default function AdminLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<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">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
@@ -253,13 +393,11 @@ export default function AdminLogsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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>
|
||||
</td>
|
||||
<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)}`}>
|
||||
{log.status.toUpperCase()}
|
||||
</span>
|
||||
<StatusBadge status={log.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{log.request?.audiobook ? (
|
||||
@@ -285,7 +423,7 @@ export default function AdminLogsPage() {
|
||||
{log.attempts}/{log.maxAttempts}
|
||||
</td>
|
||||
<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
|
||||
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"
|
||||
@@ -298,63 +436,7 @@ export default function AdminLogsPage() {
|
||||
{expandedLog === log.id && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
<LogDetails log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -373,24 +455,31 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs)
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 order-1 sm:order-2">
|
||||
<button
|
||||
onClick={() => setPage(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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
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
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<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>• Event logs show all internal operations with timestamps (similar to Docker logs)</li>
|
||||
<li>• Tap "Show Details" to view event logs, job results, and errors</li>
|
||||
<li>• Event logs show all internal operations with timestamps</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>• All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+198
-88
@@ -13,17 +13,35 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SelectedTorrentData {
|
||||
title?: string;
|
||||
indexer?: string;
|
||||
size?: number;
|
||||
format?: string;
|
||||
ebookFormat?: string;
|
||||
seeders?: number;
|
||||
infoUrl?: string;
|
||||
source?: string;
|
||||
protocol?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
type: 'audiobook' | 'ebook';
|
||||
selectedTorrent: SelectedTorrentData | null;
|
||||
audiobook: {
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl: string | null;
|
||||
audibleAsin: string | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
@@ -32,9 +50,20 @@ interface PendingApprovalRequest {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTorrentSize(bytes: number): string {
|
||||
const gb = bytes / (1024 ** 3);
|
||||
const mb = bytes / (1024 ** 2);
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
|
||||
|
||||
const searchModalRequest = searchModalRequestId
|
||||
? requests.find((r) => r.id === searchModalRequestId)
|
||||
: null;
|
||||
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
|
||||
@@ -47,7 +76,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
toast.success('Request approved');
|
||||
|
||||
// Mutate both pending requests and recent requests caches
|
||||
await mutate('/api/admin/requests/pending-approval');
|
||||
await mutate('/api/admin/requests/recent');
|
||||
await mutate('/api/admin/metrics');
|
||||
@@ -72,7 +100,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
toast.success('Request denied');
|
||||
|
||||
// Mutate pending requests cache
|
||||
await mutate('/api/admin/requests/pending-approval');
|
||||
await mutate('/api/admin/metrics');
|
||||
} catch (error) {
|
||||
@@ -85,6 +112,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveWithTorrent = async (requestId: string, torrent: TorrentResult) => {
|
||||
await fetchJSON(`/api/admin/requests/${requestId}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'approve', selectedTorrent: torrent }),
|
||||
});
|
||||
|
||||
toast.success('Request approved and download started');
|
||||
|
||||
await mutate('/api/admin/requests/pending-approval');
|
||||
await mutate('/api/admin/requests/recent');
|
||||
await mutate('/api/admin/metrics');
|
||||
};
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
@@ -116,6 +163,9 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{requests.map((request) => {
|
||||
const isLoading = loadingStates[request.id] || false;
|
||||
const torrent = request.selectedTorrent;
|
||||
const displayFormat = torrent?.format || torrent?.ebookFormat;
|
||||
const isAnnasArchive = torrent?.source === 'annas_archive';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -127,23 +177,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Book Info */}
|
||||
@@ -205,89 +245,107 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-Selected Release */}
|
||||
{torrent && torrent.title && (
|
||||
<div className="mx-4 mb-3 px-3 py-2.5 bg-gray-50 dark:bg-gray-900/60 rounded-lg border border-gray-200 dark:border-gray-700/60">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<svg className="w-3 h-3 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
User-Selected Release
|
||||
</span>
|
||||
</div>
|
||||
{torrent.infoUrl ? (
|
||||
<a
|
||||
href={torrent.infoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors line-clamp-2 leading-snug"
|
||||
title={torrent.title}
|
||||
>
|
||||
{torrent.title}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 line-clamp-2 leading-snug" title={torrent.title}>
|
||||
{torrent.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1.5 text-[11px] text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
{isAnnasArchive ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : torrent.indexer ? (
|
||||
<span>{torrent.indexer}</span>
|
||||
) : null}
|
||||
{torrent.size && torrent.size > 0 ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatTorrentSize(torrent.size)}</span>
|
||||
</>
|
||||
) : null}
|
||||
{displayFormat ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
|
||||
{displayFormat}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{torrent.protocol === 'usenet' ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-sky-600 dark:text-sky-400 font-medium">NZB</span>
|
||||
</>
|
||||
) : torrent.seeders !== undefined && torrent.seeders !== null ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{torrent.seeders} seeds</span>
|
||||
</>
|
||||
) : null}
|
||||
{torrent.score !== undefined && torrent.score !== null ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="font-medium">Score {Math.round(torrent.score)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<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" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSearchModalRequestId(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDenyRequest(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<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" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Deny</span>
|
||||
@@ -297,11 +355,33 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
{searchModalRequest && (
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={!!searchModalRequestId}
|
||||
onClose={() => setSearchModalRequestId(null)}
|
||||
requestId={searchModalRequest.id}
|
||||
audiobook={{
|
||||
title: searchModalRequest.audiobook.title,
|
||||
author: searchModalRequest.audiobook.author,
|
||||
}}
|
||||
searchMode={searchModalRequest.type === 'ebook' ? 'ebook' : 'audiobook'}
|
||||
onConfirm={async (torrent) => {
|
||||
await handleApproveWithTorrent(searchModalRequest.id, torrent);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSearchModalRequestId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||
|
||||
// Fetch data with auto-refresh every 10 seconds
|
||||
const { data: metrics, error: metricsError } = useSWR(
|
||||
'/api/admin/metrics',
|
||||
@@ -495,7 +575,7 @@ function AdminDashboardContent() {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||
@@ -580,8 +660,38 @@ function AdminDashboardContent() {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsBulkImportOpen(true)}
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
Bulk Import
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Import Wizard Modal */}
|
||||
<BulkImportWizard
|
||||
isOpen={isBulkImportOpen}
|
||||
onClose={() => setIsBulkImportOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Requests Awaiting Approval */}
|
||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||
|
||||
@@ -210,6 +210,7 @@ export const getTabValidation = (
|
||||
return validated.paths;
|
||||
case 'ebook':
|
||||
case 'bookdate':
|
||||
case 'api':
|
||||
return true; // These tabs handle their own saving
|
||||
default:
|
||||
return false;
|
||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||
{ id: 'api' as const, label: 'API', icon: '🔑' },
|
||||
];
|
||||
|
||||
@@ -100,6 +100,10 @@ export interface PathsSettings {
|
||||
ebookPathTemplate?: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
fileRenameEnabled: boolean;
|
||||
fileRenameTemplate?: string;
|
||||
fileChmod?: string;
|
||||
dirChmod?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,4 +245,4 @@ export interface BookDateModel {
|
||||
/**
|
||||
* Tab identifier type
|
||||
*/
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
|
||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||
|
||||
// Types and Helpers
|
||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||
@@ -295,6 +296,7 @@ export default function AdminSettings() {
|
||||
{activeTab === 'prowlarr' && (
|
||||
<IndexersTab
|
||||
settings={settings}
|
||||
originalSettings={originalSettings}
|
||||
indexers={configuredIndexers}
|
||||
flagConfigs={flagConfigs}
|
||||
onChange={setSettings}
|
||||
@@ -345,8 +347,11 @@ export default function AdminSettings() {
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && <ApiTab />}
|
||||
|
||||
{/* Save Button (only for tabs that save through main page) */}
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Component: API Token Management Tab (Admin)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||
import { useApiTokens } from '@/lib/hooks/useApiTokens';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
import type { AdminApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function ApiTab() {
|
||||
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
|
||||
|
||||
// Admin-specific state
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, user selector just won't populate
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const extraBody: Record<string, string> = {};
|
||||
if (newTokenUserId) extraBody.userId = newTokenUserId;
|
||||
const created = await api.handleCreate(extraBody);
|
||||
// Reset admin-specific fields only when create succeeds
|
||||
if (created) {
|
||||
setNewTokenUserId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
api.resetForm();
|
||||
setNewTokenUserId('');
|
||||
};
|
||||
|
||||
if (api.loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage API tokens for all users. Create tokens for any user for programmatic access.{' '}
|
||||
<Link href="/api-docs" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View API documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{api.error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{api.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{api.createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{api.createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={api.handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{api.copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss token banner"
|
||||
onClick={api.dismissCreatedToken}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{api.showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={api.newTokenName}
|
||||
onChange={(e) => api.setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={api.newTokenExpiry}
|
||||
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User (acts as)
|
||||
</label>
|
||||
<select
|
||||
value={newTokenUserId}
|
||||
onChange={(e) => setNewTokenUserId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Current user (default)</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.plexUsername} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Token will inherit the selected user's role
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={api.creating || !api.newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{api.creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => api.setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{api.tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{api.tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
token.role === 'admin'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{token.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{api.formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => api.setConfirmRevokeId(token.id)}
|
||||
disabled={api.deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={api.confirmRevokeId !== null}
|
||||
title="Revoke API token"
|
||||
message={
|
||||
<>
|
||||
Are you sure you want to revoke{' '}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Revoke token"
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="danger"
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onCancel={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
? 'Leave blank for local models'
|
||||
: configured
|
||||
? '••••••••••••••••'
|
||||
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
|
||||
: (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
value={ebook.baseUrl || 'https://annas-archive.gl'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
placeholder="https://annas-archive.gl"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: ebook.flaresolverrUrl,
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
|
||||
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
|
||||
format: ebook.preferredFormat || 'epub',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
||||
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
|
||||
|
||||
interface IndexersTabProps {
|
||||
settings: Settings;
|
||||
originalSettings: Settings | null;
|
||||
indexers: SavedIndexerConfig[];
|
||||
flagConfigs: IndexerFlagConfig[];
|
||||
onChange: (settings: Settings) => void;
|
||||
@@ -27,6 +29,7 @@ interface IndexersTabProps {
|
||||
|
||||
export function IndexersTab({
|
||||
settings,
|
||||
originalSettings,
|
||||
indexers,
|
||||
flagConfigs,
|
||||
onChange,
|
||||
@@ -35,11 +38,23 @@ export function IndexersTab({
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
}: IndexersTabProps) {
|
||||
const { testing, testResult, testConnection } = useIndexersSettings({
|
||||
const {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
showConnectionChangeConfirm,
|
||||
confirmConnectionChange,
|
||||
cancelConnectionChange,
|
||||
configuredIndexersCount,
|
||||
} = useIndexersSettings({
|
||||
prowlarrUrl: settings.prowlarr.url,
|
||||
prowlarrApiKey: settings.prowlarr.apiKey,
|
||||
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
|
||||
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
|
||||
configuredIndexersCount: indexers.length,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
onClearIndexers: () => onIndexersChange([]),
|
||||
});
|
||||
|
||||
// Auto-load indexers when component mounts if prowlarr is configured
|
||||
@@ -96,7 +111,7 @@ export function IndexersTab({
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +193,19 @@ export function IndexersTab({
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,30 +5,50 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { TestResult } from '../../lib/types';
|
||||
|
||||
interface UseIndexersSettingsProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
originalProwlarrUrl: string;
|
||||
originalProwlarrApiKey: string;
|
||||
configuredIndexersCount: number;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onRefreshIndexers?: () => Promise<void>;
|
||||
onClearIndexers: () => void;
|
||||
}
|
||||
|
||||
export function useIndexersSettings({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
originalProwlarrUrl,
|
||||
originalProwlarrApiKey,
|
||||
configuredIndexersCount,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
onClearIndexers,
|
||||
}: UseIndexersSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
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);
|
||||
setTestResult(null);
|
||||
|
||||
@@ -46,14 +66,23 @@ export function useIndexersSettings({
|
||||
|
||||
if (data.success) {
|
||||
onValidationChange(true);
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
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({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onValidationChange(false);
|
||||
@@ -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 {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
showConnectionChangeConfirm,
|
||||
confirmConnectionChange,
|
||||
cancelConnectionChange,
|
||||
configuredIndexersCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ export function AudiobookshelfSection({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</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="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -164,11 +164,11 @@ export function PlexSection({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</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="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { usePathsSettings } from './usePathsSettings';
|
||||
import type { PathsSettings } from '../../lib/types';
|
||||
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
|
||||
import { validateTemplate, generateMockPreviews, validateFilenameTemplate, generateMockFilenamePreviews } from '@/lib/utils/path-template.util';
|
||||
|
||||
interface PathsTabProps {
|
||||
paths: PathsSettings;
|
||||
@@ -24,6 +24,13 @@ interface TemplatePreview {
|
||||
previewPaths?: string[];
|
||||
}
|
||||
|
||||
interface FilenamePreview {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
single?: string[];
|
||||
multi?: string[];
|
||||
}
|
||||
|
||||
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
|
||||
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
|
||||
paths,
|
||||
@@ -73,6 +80,34 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
}
|
||||
}, [paths.ebookPathTemplate]);
|
||||
|
||||
// Live preview state for filename template
|
||||
const [filenamePreview, setFilenamePreview] = useState<FilenamePreview | null>(null);
|
||||
|
||||
// Update filename live preview whenever template changes
|
||||
useEffect(() => {
|
||||
if (!paths.fileRenameEnabled) {
|
||||
setFilenamePreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = paths.fileRenameTemplate || '{title}';
|
||||
const validation = validateFilenameTemplate(template);
|
||||
|
||||
if (validation.valid) {
|
||||
const previews = generateMockFilenamePreviews(template);
|
||||
setFilenamePreview({
|
||||
isValid: true,
|
||||
single: previews.single,
|
||||
multi: previews.multi,
|
||||
});
|
||||
} else {
|
||||
setFilenamePreview({
|
||||
isValid: false,
|
||||
error: validation.error,
|
||||
});
|
||||
}
|
||||
}, [paths.fileRenameTemplate, paths.fileRenameEnabled]);
|
||||
|
||||
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
||||
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
|
||||
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
|
||||
@@ -218,6 +253,83 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Rename Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="file-rename-settings"
|
||||
checked={paths.fileRenameEnabled}
|
||||
onChange={(e) => updatePath('fileRenameEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="file-rename-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Rename files during organization
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Rename audio and ebook files using a custom naming template when organizing into the media
|
||||
library. When multiple files exist (e.g. chapterized MP3s), an index number is appended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Naming Template (shown when enabled) */}
|
||||
{paths.fileRenameEnabled && (
|
||||
<div className="mt-4 pl-9">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Naming Template
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.fileRenameTemplate || '{title}'}
|
||||
onChange={(e) => updatePath('fileRenameTemplate', e.target.value)}
|
||||
placeholder="{title}"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Uses the same variables as the organization template. Do not include the file extension.
|
||||
</p>
|
||||
|
||||
{/* Filename Validation Error */}
|
||||
{filenamePreview && !filenamePreview.isValid && (
|
||||
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
|
||||
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||
<div className="flex-1">
|
||||
<span>{filenamePreview.error || 'Invalid filename template'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filename Preview */}
|
||||
{filenamePreview && filenamePreview.isValid && (
|
||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Single File
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{filenamePreview.single?.map((preview, index) => (
|
||||
<div key={index} className="text-xs">{preview}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-3 mb-2">
|
||||
Multiple Files (chapterized)
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{filenamePreview.multi?.map((preview, index) => (
|
||||
<div key={index} className="text-xs">{preview}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable Reference Panel (shared for both templates) */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
@@ -255,6 +367,27 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional Syntax Help */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">
|
||||
Conditional Syntax
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Wrap text around a variable in <code className="text-amber-700 dark:text-amber-300 font-mono">{'{ }'}</code> to
|
||||
include that text only when the variable has a value. If the variable is empty, the entire block is removed.
|
||||
</p>
|
||||
<div className="text-sm font-mono bg-white dark:bg-gray-900 rounded px-3 py-2 border border-amber-100 dark:border-amber-900">
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<code className="text-amber-700 dark:text-amber-300">{'{Book seriesPart - }'}</code>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
With value: <span className="text-green-700 dark:text-green-400">Book 1 - </span>
|
||||
•
|
||||
Without value: <span className="text-red-700 dark:text-red-400">(removed)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Tagging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -306,6 +439,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Permissions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
File Permissions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Octal permissions applied when organizing files into the media library. These may be further restricted by the container's UMASK setting.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Permissions
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.fileChmod || '664'}
|
||||
onChange={(e) => updatePath('fileChmod', e.target.value)}
|
||||
placeholder="664"
|
||||
className={`font-mono max-w-32 ${paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||
/>
|
||||
{paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
e.g. 664 = owner/group read-write, others read
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Directory Permissions
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.dirChmod || '775'}
|
||||
onChange={(e) => updatePath('dirChmod', e.target.value)}
|
||||
placeholder="775"
|
||||
className={`font-mono max-w-32 ${paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||
/>
|
||||
{paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
e.g. 775 = owner/group full access, others read-execute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Paths Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
|
||||
+453
-247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Component: Interactive API Documentation Page
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Lists all API token-accessible endpoints with "Try it out" functionality.
|
||||
* Users can test with a custom API token or their current browser session.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { TokenInput } from '@/components/api-docs/TokenInput';
|
||||
import { EndpointCard } from '@/components/api-docs/EndpointCard';
|
||||
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getInstanceUrl } from '@/lib/utils/client-url';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { user } = useAuth();
|
||||
const [token, setToken] = useState('');
|
||||
const [useSession, setUseSession] = useState(false);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 pt-8 pb-16">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-gray-900 dark:text-white font-medium">API Documentation</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
API Reference
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-2xl">
|
||||
Interact with ReadMeABook programmatically using API tokens. These endpoints are
|
||||
available for external integrations, dashboards, and automation tools.
|
||||
</p>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Manage your tokens
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 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="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" />
|
||||
</svg>
|
||||
Admin token management
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication section */}
|
||||
<div className="mb-8">
|
||||
<TokenInput
|
||||
token={token}
|
||||
onTokenChange={setToken}
|
||||
useSession={useSession}
|
||||
onUseSessionChange={setUseSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage instructions card */}
|
||||
<div className="mb-8 rounded-2xl border border-gray-200 dark:border-gray-700/50 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Quick Start
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Include your API token in the <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-900 rounded text-xs font-mono">Authorization</code> header as a Bearer token:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${getInstanceUrl()}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Endpoints section header */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Available Endpoints
|
||||
</h2>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{API_TOKEN_ENDPOINT_DOCS.length} endpoints
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Endpoint cards */}
|
||||
<div className="space-y-4">
|
||||
{API_TOKEN_ENDPOINT_DOCS.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={`${endpoint.method}:${endpoint.path}`}
|
||||
endpoint={endpoint}
|
||||
token={token}
|
||||
useSession={useSession}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="mt-10 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
API tokens are restricted to the endpoints listed above.
|
||||
JWT session authentication has access to all endpoints.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Component: API Token Delete Route
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/api-tokens/[id]
|
||||
* Revoke (delete) an API token
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Component: Admin API Token Management Routes
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
userId: z.string().uuid().optional(), // Admin can specify which user the token acts as
|
||||
role: z.enum(['admin', 'user']).optional(), // Accepted for compatibility, but cannot differ from target user role
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/api-tokens
|
||||
* List ALL API tokens across all users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, plexUsername: true },
|
||||
},
|
||||
tokenUser: {
|
||||
select: { id: true, plexUsername: true, role: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
createdBy: t.createdBy.plexUsername,
|
||||
createdById: t.createdBy.id,
|
||||
tokenUser: t.tokenUser.plexUsername,
|
||||
tokenUserId: t.tokenUser.id,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/api-tokens
|
||||
* Create a new API token. Admin can optionally specify userId.
|
||||
* Token role is always derived from the target user's current role.
|
||||
* Returns the full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Determine target user (defaults to the admin themselves)
|
||||
const targetUserId = userId || req.user!.id;
|
||||
|
||||
// Verify the target user exists
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true, plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'Target user not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enforce per-user token cap (count only active, non-expired tokens)
|
||||
const activeTokenCount = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: targetUserId,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Security guard: token role must always match the target user's persisted role.
|
||||
// This avoids role/identity mismatch (for example: acting as user A with admin role).
|
||||
if (role && role !== targetUser.role) {
|
||||
logger.warn('Admin attempted token role override that differs from target user role', {
|
||||
requestedRole: role,
|
||||
userActualRole: targetUser.role,
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Token role must match target user's role (${targetUser.role}).`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenRole = targetUser.role;
|
||||
|
||||
// Generate the token
|
||||
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: tokenRole,
|
||||
createdById: req.user!.id,
|
||||
userId: targetUserId,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Admin API token created', {
|
||||
tokenId: apiToken.id,
|
||||
name,
|
||||
createdBy: req.user!.username,
|
||||
targetUser: targetUser.plexUsername,
|
||||
role: tokenRole,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
// Full token is returned ONLY on creation
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create API token', { 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 create API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Component: Bulk Import Execute API
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Queues manual imports for multiple audiobooks at once.
|
||||
* Reuses the same logic as the single manual import endpoint.
|
||||
* Admin-only.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.BulkImport.Execute');
|
||||
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
|
||||
/** Statuses that indicate the request is actively being worked on. */
|
||||
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||
|
||||
/** Statuses that can be recycled for a new manual import. */
|
||||
const RECYCLABLE_STATUSES = [
|
||||
'failed', 'warn', 'cancelled', 'denied', 'pending',
|
||||
'awaiting_search', 'awaiting_approval',
|
||||
];
|
||||
|
||||
interface ImportItem {
|
||||
folderPath: string;
|
||||
asin: string;
|
||||
audioFiles?: string[]; // Specific files to import (from scanner grouping)
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
folderPath: string;
|
||||
asin: string;
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Check if a directory contains audio files. */
|
||||
async function hasAudioFiles(dirPath: string): Promise<boolean> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
return children.some(
|
||||
(child) =>
|
||||
child.isFile() &&
|
||||
(AUDIO_EXTENSIONS as readonly string[]).includes(
|
||||
pathModule.extname(child.name).toLowerCase()
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { imports } = body as { imports: ImportItem[] };
|
||||
|
||||
if (!imports || !Array.isArray(imports) || imports.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'imports array is required and must not be empty' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Load allowed roots
|
||||
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||
]);
|
||||
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
if (mediaDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
try {
|
||||
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||
if (bookdropStat.isDirectory()) {
|
||||
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
}
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
const userId = req.user!.id;
|
||||
const audibleService = getAudibleService();
|
||||
const jobQueue = getJobQueueService();
|
||||
const results: ImportResult[] = [];
|
||||
|
||||
for (const item of imports) {
|
||||
const { folderPath, asin, audioFiles: itemAudioFiles } = item;
|
||||
|
||||
try {
|
||||
// Validate path
|
||||
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||
const isAllowed = allowedRoots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
results.push({ folderPath, asin, success: false, error: 'Path outside allowed directories' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify directory exists
|
||||
try {
|
||||
const stat = await fs.stat(normalizedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
results.push({ folderPath, asin, success: false, error: 'Not a directory' });
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
results.push({ folderPath, asin, success: false, error: 'Directory not found' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify audio files: if specific files provided, trust the scanner;
|
||||
// otherwise fall back to folder-level check
|
||||
if (!itemAudioFiles || itemAudioFiles.length === 0) {
|
||||
const hasAudio = await hasAudioFiles(normalizedPath);
|
||||
if (!hasAudio) {
|
||||
results.push({ folderPath, asin, success: false, error: 'No audio files' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve or create audiobook record
|
||||
let audiobookId: string;
|
||||
let existingBook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
|
||||
if (existingBook) {
|
||||
audiobookId = existingBook.id;
|
||||
} else {
|
||||
// Try Audible cache, then Audnexus
|
||||
const cached = await prisma.audibleCache.findUnique({ where: { asin } });
|
||||
if (cached) {
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: cached.title,
|
||||
author: cached.author,
|
||||
coverArtUrl: cached.coverArtUrl,
|
||||
narrator: cached.narrator,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
} else {
|
||||
try {
|
||||
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||
if (!liveData) {
|
||||
results.push({ folderPath, asin, success: false, error: 'Audiobook not found' });
|
||||
continue;
|
||||
}
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: liveData.title,
|
||||
author: liveData.author,
|
||||
coverArtUrl: liveData.coverArtUrl,
|
||||
narrator: liveData.narrator,
|
||||
series: liveData.series,
|
||||
seriesPart: liveData.seriesPart,
|
||||
seriesAsin: liveData.seriesAsin,
|
||||
year: liveData.releaseDate
|
||||
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||
: undefined,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
} catch {
|
||||
results.push({ folderPath, asin, success: false, error: 'Failed to fetch audiobook details' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing request and recycle or create
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
let requestId: string;
|
||||
|
||||
if (existingRequest) {
|
||||
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||
results.push({ folderPath, asin, success: false, error: 'Already being processed' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||
existingRequest.status === 'downloaded' ||
|
||||
existingRequest.status === 'available'
|
||||
) {
|
||||
await prisma.request.update({
|
||||
where: { id: existingRequest.id },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
errorMessage: null,
|
||||
importAttempts: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
requestId = existingRequest.id;
|
||||
} else {
|
||||
const newReq = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newReq.id;
|
||||
}
|
||||
} else {
|
||||
const newReq = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newReq.id;
|
||||
}
|
||||
|
||||
// Queue organize_files job (pass specific files if scanner provided them)
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
audiobookId,
|
||||
normalizedPath,
|
||||
undefined,
|
||||
false,
|
||||
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
|
||||
);
|
||||
|
||||
results.push({ folderPath, asin, success: true, requestId });
|
||||
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
|
||||
} catch (itemError) {
|
||||
logger.error(`Bulk import item failed: asin=${asin}, path=${folderPath}`, {
|
||||
error: itemError instanceof Error ? itemError.message : String(itemError),
|
||||
});
|
||||
results.push({
|
||||
folderPath,
|
||||
asin,
|
||||
success: false,
|
||||
error: itemError instanceof Error ? itemError.message : 'Import failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
logger.info(`Bulk import execute complete: ${succeeded} queued, ${failed} failed`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
summary: { total: results.length, succeeded, failed },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Bulk import execute failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Bulk import failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Component: Bulk Import Scan API (SSE)
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Streams audiobook discovery and Audible matching results via Server-Sent Events.
|
||||
* Admin-only. Validates path is within allowed roots.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.BulkImport.Scan');
|
||||
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
const AUDIBLE_SEARCH_DELAY_MS = 1500;
|
||||
|
||||
/** Load allowed root directories from configuration. */
|
||||
async function getAllowedRoots(): Promise<string[]> {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||
]);
|
||||
|
||||
const roots: string[] = [];
|
||||
if (downloadDirConfig?.value) {
|
||||
roots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
if (mediaDirConfig?.value) {
|
||||
roots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(BOOKDROP_PATH);
|
||||
if (stat.isDirectory()) {
|
||||
roots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
}
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Check if a path is within allowed roots. */
|
||||
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||
return roots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
}
|
||||
|
||||
/** Delay helper for rate limiting. */
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rootPath } = body;
|
||||
if (!rootPath) {
|
||||
return NextResponse.json({ error: 'rootPath is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate path
|
||||
const allowedRoots = await getAllowedRoots();
|
||||
const normalizedPath = pathModule.resolve(rootPath).replace(/\\/g, '/');
|
||||
|
||||
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify directory exists
|
||||
try {
|
||||
const stat = await fs.stat(normalizedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: 'Path is not a directory' }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
logger.info(`Bulk import scan started: ${normalizedPath}`);
|
||||
|
||||
// Create SSE stream
|
||||
const encoder = new TextEncoder();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: any) => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
);
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Phase 1: Discover audiobook folders
|
||||
const audiobooks = await discoverAudiobooks(
|
||||
normalizedPath,
|
||||
(progress) => {
|
||||
send('progress', progress);
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (audiobooks.length === 0) {
|
||||
send('complete', { audiobooks: [], message: 'No audiobooks found' });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
send('discovery_complete', {
|
||||
totalFound: audiobooks.length,
|
||||
message: `Found ${audiobooks.length} audiobook folders`,
|
||||
});
|
||||
|
||||
// Phase 2: Match each audiobook against Audible
|
||||
const audibleService = getAudibleService();
|
||||
const results: any[] = [];
|
||||
|
||||
for (let i = 0; i < audiobooks.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
|
||||
const book = audiobooks[i];
|
||||
|
||||
send('matching', {
|
||||
current: i + 1,
|
||||
total: audiobooks.length,
|
||||
folderName: book.folderName,
|
||||
searchTerm: book.searchTerm,
|
||||
});
|
||||
|
||||
let match: any = null;
|
||||
let inLibrary = false;
|
||||
let hasActiveRequest = false;
|
||||
|
||||
try {
|
||||
const searchResult = await audibleService.search(book.searchTerm);
|
||||
|
||||
if (searchResult.results.length > 0) {
|
||||
match = searchResult.results[0];
|
||||
|
||||
// Check library availability
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin: match.asin,
|
||||
title: match.title,
|
||||
author: match.author,
|
||||
narrator: match.narrator,
|
||||
});
|
||||
inLibrary = plexMatch !== null;
|
||||
|
||||
// Check for active requests
|
||||
if (!inLibrary) {
|
||||
const activeRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: { audibleAsin: match.asin },
|
||||
type: 'audiobook',
|
||||
status: {
|
||||
in: [
|
||||
'pending', 'searching', 'downloading', 'processing',
|
||||
'awaiting_search', 'awaiting_import', 'awaiting_approval',
|
||||
'downloaded', 'available',
|
||||
],
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
hasActiveRequest = activeRequest !== null;
|
||||
}
|
||||
}
|
||||
} catch (searchError) {
|
||||
logger.warn(
|
||||
`Audible search failed for "${book.searchTerm}": ${
|
||||
searchError instanceof Error ? searchError.message : String(searchError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = {
|
||||
index: i,
|
||||
folderPath: book.folderPath,
|
||||
folderName: book.folderName,
|
||||
relativePath: book.relativePath,
|
||||
audioFileCount: book.audioFileCount,
|
||||
totalSizeBytes: book.totalSizeBytes,
|
||||
metadataSource: book.metadataSource,
|
||||
searchTerm: book.searchTerm,
|
||||
audioFiles: book.audioFiles,
|
||||
match: match
|
||||
? {
|
||||
asin: match.asin,
|
||||
title: match.title,
|
||||
author: match.author,
|
||||
narrator: match.narrator,
|
||||
coverArtUrl: match.coverArtUrl,
|
||||
durationMinutes: match.durationMinutes,
|
||||
}
|
||||
: null,
|
||||
inLibrary,
|
||||
hasActiveRequest,
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
send('book_matched', result);
|
||||
|
||||
// Rate limit: wait between Audible searches (except after last)
|
||||
if (i < audiobooks.length - 1) {
|
||||
await delay(AUDIBLE_SEARCH_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
send('complete', {
|
||||
totalFound: results.length,
|
||||
matched: results.filter((r) => r.match !== null).length,
|
||||
inLibrary: results.filter((r) => r.inLibrary).length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Bulk import scan failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
send('error', {
|
||||
message: error instanceof Error ? error.message : 'Scan failed',
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
abortController.abort();
|
||||
},
|
||||
});
|
||||
|
||||
// Cast to NextResponse: SSE streams require raw Response constructor,
|
||||
// but requireAdmin types expect NextResponse. The Response is valid at runtime.
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
}) as unknown as NextResponse;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Component: Admin Filesystem Browse API
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Lets admins browse server directories for manual audiobook import.
|
||||
* Restricted to download_dir and media_dir roots only.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load allowed root directories from Configuration table.
|
||||
*/
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
|
||||
async function getAllowedRoots(): Promise<{ downloadDir: string | null; mediaDir: string | null; bookdropExists: boolean }> {
|
||||
const downloadDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_dir' },
|
||||
});
|
||||
const mediaDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'media_dir' },
|
||||
});
|
||||
|
||||
let bookdropExists = false;
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const stat = await fs.stat(BOOKDROP_PATH);
|
||||
bookdropExists = stat.isDirectory();
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
return {
|
||||
downloadDir: downloadDirConfig?.value || null,
|
||||
mediaDir: mediaDirConfig?.value || null,
|
||||
bookdropExists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a normalized path is within one of the allowed roots.
|
||||
*/
|
||||
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||
return roots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const { downloadDir, mediaDir, bookdropExists } = await getAllowedRoots();
|
||||
const requestedPath = request.nextUrl.searchParams.get('path');
|
||||
|
||||
// No path param: return root directories
|
||||
if (!requestedPath) {
|
||||
const roots: Array<{ name: string; path: string; icon: string }> = [];
|
||||
if (downloadDir) {
|
||||
roots.push({ name: 'Downloads', path: downloadDir, icon: 'download' });
|
||||
}
|
||||
if (mediaDir) {
|
||||
roots.push({ name: 'Media Library', path: mediaDir, icon: 'library' });
|
||||
}
|
||||
if (bookdropExists) {
|
||||
roots.push({ name: 'Book Drop', path: BOOKDROP_PATH, icon: 'bookdrop' });
|
||||
}
|
||||
|
||||
if (roots.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No browsable directories available' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ roots });
|
||||
}
|
||||
|
||||
// Path param provided: browse that directory
|
||||
// Normalize to forward slashes and resolve
|
||||
const normalizedPath = pathModule.resolve(requestedPath).replace(/\\/g, '/');
|
||||
|
||||
// Build list of allowed roots (normalized)
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDir) allowedRoots.push(pathModule.resolve(downloadDir).replace(/\\/g, '/'));
|
||||
if (mediaDir) allowedRoots.push(pathModule.resolve(mediaDir).replace(/\\/g, '/'));
|
||||
if (bookdropExists) allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
|
||||
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||
logger.warn(`Access denied: ${normalizedPath} is outside allowed directories`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||
|
||||
// List subdirectories (no nested stat calls — keeps browsing fast)
|
||||
const entries: DirectoryEntry[] = dirEntries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((entry) => ({ name: entry.name, type: 'directory' as const }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Gather audio files in the current directory
|
||||
const audioFiles: Array<{ name: string; size: number }> = [];
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.isFile()) {
|
||||
const ext = pathModule.extname(entry.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
try {
|
||||
const stat = await fs.stat(pathModule.join(normalizedPath, entry.name));
|
||||
audioFiles.push({ name: entry.name, size: stat.size });
|
||||
} catch {
|
||||
audioFiles.push({ name: entry.name, size: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return NextResponse.json({ path: normalizedPath, entries, audioFiles });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
logger.error('Failed to browse directory', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to browse directory' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Component: Admin Manual Import API
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Triggers the organize_files pipeline for a manually-selected folder.
|
||||
* Creates or recycles a request, then queues the organize job.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||
|
||||
/** Statuses that indicate the request is actively being worked on. */
|
||||
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||
|
||||
/** Statuses that can be recycled for a new manual import. */
|
||||
const RECYCLABLE_STATUSES = ['failed', 'warn', 'cancelled', 'denied', 'pending', 'awaiting_search', 'awaiting_approval'];
|
||||
|
||||
/**
|
||||
* Check if a directory contains at least one audio file (immediate children only).
|
||||
*/
|
||||
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
let count = 0;
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const child of children) {
|
||||
if (child.isFile()) {
|
||||
const ext = pathModule.extname(child.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* directory not readable */
|
||||
}
|
||||
|
||||
return { found: count > 0, count };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { folderPath, asin, cleanupSource, selectedFiles } = body;
|
||||
let { audiobookId } = body;
|
||||
|
||||
// Validate selectedFiles if provided
|
||||
if (selectedFiles !== undefined) {
|
||||
if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'selectedFiles must be a non-empty array of file names' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!selectedFiles.every((f: unknown) => typeof f === 'string')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'selectedFiles must contain only strings' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if ((!audiobookId && !asin) || !folderPath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'folderPath and either audiobookId or asin are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Load allowed roots
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
const downloadDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_dir' },
|
||||
});
|
||||
const mediaDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'media_dir' },
|
||||
});
|
||||
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
if (mediaDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
try {
|
||||
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||
if (bookdropStat.isDirectory()) {
|
||||
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
}
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
// Normalize and validate path
|
||||
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||
const isAllowed = allowedRoots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder exists and is a directory
|
||||
try {
|
||||
const stat = await fs.stat(normalizedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Path is not a directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify selected files exist and are audio files, or fall back to folder scan
|
||||
let audioFileCount: number;
|
||||
const validatedFiles: string[] = [];
|
||||
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
for (const fileName of selectedFiles as string[]) {
|
||||
// Prevent path traversal
|
||||
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid file name: ${fileName}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const ext = pathModule.extname(fileName).toLowerCase();
|
||||
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not an audio file: ${fileName}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
|
||||
if (!fileStat.isFile()) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not a file: ${fileName}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
validatedFiles.push(fileName);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: `File not found: ${fileName}` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
audioFileCount = validatedFiles.length;
|
||||
} else {
|
||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
audioFileCount = audioCheck.count;
|
||||
}
|
||||
|
||||
// Resolve audiobook by ASIN if audiobookId not provided
|
||||
if (!audiobookId && asin) {
|
||||
const byAsin = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
if (byAsin) {
|
||||
audiobookId = byAsin.id;
|
||||
} else {
|
||||
// Create audiobook record from Audible cache if available
|
||||
const cached = await prisma.audibleCache.findUnique({
|
||||
where: { asin },
|
||||
});
|
||||
if (cached) {
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: cached.title,
|
||||
author: cached.author,
|
||||
coverArtUrl: cached.coverArtUrl,
|
||||
narrator: cached.narrator,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
// Not in DB — fetch live from Audnexus and create a record
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||
if (liveData) {
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: liveData.title,
|
||||
author: liveData.author,
|
||||
coverArtUrl: liveData.coverArtUrl,
|
||||
narrator: liveData.narrator,
|
||||
series: liveData.series,
|
||||
seriesPart: liveData.seriesPart,
|
||||
seriesAsin: liveData.seriesAsin,
|
||||
year: liveData.releaseDate
|
||||
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||
: undefined,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from Audnexus for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
} catch (audnexusError) {
|
||||
logger.error(`Failed to fetch ASIN ${asin} from Audnexus: ${audnexusError instanceof Error ? audnexusError.message : String(audnexusError)}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify audiobook exists
|
||||
const audiobook = await prisma.audiobook.findUnique({
|
||||
where: { id: audiobookId },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
|
||||
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
|
||||
|
||||
if (audnexusData) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (!audiobook.series && audnexusData.series) {
|
||||
updates.series = audnexusData.series;
|
||||
}
|
||||
if (!audiobook.seriesPart && audnexusData.seriesPart) {
|
||||
updates.seriesPart = audnexusData.seriesPart;
|
||||
}
|
||||
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
|
||||
updates.seriesAsin = audnexusData.seriesAsin;
|
||||
}
|
||||
if (!audiobook.year && audnexusData.releaseDate) {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
updates.year = releaseYear;
|
||||
}
|
||||
}
|
||||
if (!audiobook.narrator && audnexusData.narrator) {
|
||||
updates.narrator = audnexusData.narrator;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: series enrichment failure should never block the import
|
||||
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing requests
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
let requestId: string;
|
||||
|
||||
if (existingRequest) {
|
||||
// Check if already in an active state
|
||||
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This audiobook is already being processed' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Recycle the existing request
|
||||
if (RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||
existingRequest.status === 'downloaded' ||
|
||||
existingRequest.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: existingRequest.id },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
errorMessage: null,
|
||||
importAttempts: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
requestId = existingRequest.id;
|
||||
logger.info(`Recycled existing request ${requestId} for manual import`);
|
||||
} else {
|
||||
// Unknown status - create new
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newRequest.id;
|
||||
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
|
||||
}
|
||||
} else {
|
||||
// No existing request - create one
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newRequest.id;
|
||||
logger.info(`Created new request ${requestId} for manual import`);
|
||||
}
|
||||
|
||||
// Queue organize_files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
audiobookId,
|
||||
normalizedPath,
|
||||
undefined,
|
||||
cleanupSource === true,
|
||||
validatedFiles.length > 0 ? validatedFiles : undefined
|
||||
);
|
||||
|
||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
message: `Import started for ${audiobook.title}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Manual import failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Manual import failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export async function POST(request: NextRequest) {
|
||||
title: "The Hitchhiker's Guide to the Galaxy",
|
||||
author: 'Douglas Adams',
|
||||
userName: 'Test User',
|
||||
requestType: 'audiobook',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const logger = RMABLogger.create('API.Admin.Requests.Approve');
|
||||
|
||||
const ApprovalActionSchema = z.object({
|
||||
action: z.enum(['approve', 'deny']),
|
||||
selectedTorrent: z.any().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -37,8 +38,8 @@ export async function POST(
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate action
|
||||
const { action } = ApprovalActionSchema.parse(body);
|
||||
// Validate action and optional admin-selected torrent
|
||||
const { action, selectedTorrent: adminSelectedTorrent } = ApprovalActionSchema.parse(body);
|
||||
|
||||
// Fetch the request
|
||||
const existingRequest = await prisma.request.findUnique({
|
||||
@@ -78,12 +79,15 @@ export async function POST(
|
||||
const jobQueue = getJobQueueService();
|
||||
const isEbookRequest = existingRequest.type === 'ebook';
|
||||
|
||||
// Check if request has a pre-selected torrent (from interactive search)
|
||||
if (existingRequest.selectedTorrent) {
|
||||
const selectedTorrent = existingRequest.selectedTorrent as any;
|
||||
// Use admin-provided torrent (from admin interactive search) or fall back to user's pre-selected torrent
|
||||
const effectiveTorrent = adminSelectedTorrent || existingRequest.selectedTorrent;
|
||||
|
||||
// User pre-selected a specific torrent - download that torrent directly
|
||||
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
|
||||
if (effectiveTorrent) {
|
||||
const selectedTorrent = effectiveTorrent as any;
|
||||
const torrentSource = adminSelectedTorrent ? 'admin' : 'user';
|
||||
|
||||
// Download the selected torrent directly
|
||||
logger.info(`Request ${id} has ${torrentSource}-selected torrent, starting download`, {
|
||||
requestId: id,
|
||||
userId: existingRequest.userId,
|
||||
adminId: req.user.sub,
|
||||
@@ -167,17 +171,20 @@ export async function POST(
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, {
|
||||
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading ${torrentSource}-selected torrent`, {
|
||||
requestId: id,
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: existingRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
type: existingRequest.type,
|
||||
torrentSource,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Request approved and download started with pre-selected torrent',
|
||||
message: adminSelectedTorrent
|
||||
? 'Request approved and download started with admin-selected torrent'
|
||||
: 'Request approved and download started with pre-selected torrent',
|
||||
request: updatedRequest,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Component: Admin Retry Download API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Retries a failed download by either resuming monitoring of a still-alive
|
||||
* download in the client, or re-adding the download using metadata from the
|
||||
* most recent selected DownloadHistory record.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
|
||||
|
||||
/** Download statuses considered "alive" — monitoring can be resumed */
|
||||
const ALIVE_STATUSES = new Set([
|
||||
'downloading',
|
||||
'queued',
|
||||
'paused',
|
||||
'checking',
|
||||
'seeding',
|
||||
'completed',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST /api/admin/requests/[id]/retry-download
|
||||
* Retry a failed download for an admin request.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch the request with audiobook info
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingRequest.status !== 'failed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidStatus',
|
||||
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
|
||||
currentStatus: existingRequest.status,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recent selected DownloadHistory record
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId: id, selected: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!downloadHistory) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NoHistory',
|
||||
message: 'No previous download attempt found to retry',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Require a download URL to be able to re-add
|
||||
if (!downloadHistory.magnetLink) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NoDownloadUrl',
|
||||
message: 'No download URL available in history to retry',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
let retryPath: 'resumed_monitoring' | 're_added';
|
||||
|
||||
// Determine if we can attempt to resume monitoring.
|
||||
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
|
||||
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
|
||||
const rawClientType: string | null = downloadHistory.downloadClient;
|
||||
const clientId = downloadHistory.downloadClientId;
|
||||
const isDirect = rawClientType === 'direct';
|
||||
|
||||
// Only attempt to query the download client if we have a known DownloadClientType,
|
||||
// a clientId, and it is not a direct (HTTP) download.
|
||||
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
|
||||
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
|
||||
const clientType = rawClientType as DownloadClientType | null;
|
||||
|
||||
if (canCheckClient) {
|
||||
// Try to look up the download in the client
|
||||
try {
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (client) {
|
||||
const downloadInfo = await client.getDownload(clientId!);
|
||||
|
||||
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
|
||||
// Download is still alive — restart monitoring
|
||||
logger.info(`Retry download: resuming monitoring for request ${id}`, {
|
||||
requestId: id,
|
||||
downloadClientId: clientId,
|
||||
downloadStatus: downloadInfo.status,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await jobQueue.addMonitorJob(
|
||||
id,
|
||||
downloadHistory.id,
|
||||
clientId!, // canCheckClient guard ensures clientId is non-null
|
||||
clientType as DownloadClientType,
|
||||
0 // no delay — start immediately
|
||||
);
|
||||
|
||||
retryPath = 'resumed_monitoring';
|
||||
} else {
|
||||
// Download not found or is failed — re-add
|
||||
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} else {
|
||||
// No client configured for that protocol — fall through to re-add
|
||||
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} catch (clientError) {
|
||||
// Client lookup failed (connection error etc.) — re-add to be safe
|
||||
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
|
||||
requestId: id,
|
||||
error: clientError instanceof Error ? clientError.message : String(clientError),
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
} else {
|
||||
// Direct download (ebook), no clientId, or no clientType — re-add
|
||||
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
|
||||
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
|
||||
retryPath = 're_added';
|
||||
}
|
||||
|
||||
// Increment downloadAttempts, clear errorMessage, set status to downloading
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
errorMessage: null,
|
||||
downloadAttempts: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const message =
|
||||
retryPath === 'resumed_monitoring'
|
||||
? 'Download monitoring resumed'
|
||||
: 'Download re-added to client';
|
||||
|
||||
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
|
||||
requestId: id,
|
||||
adminId: req.user.sub,
|
||||
path: retryPath,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message,
|
||||
path: retryPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to retry download', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'RetryError',
|
||||
message: 'Failed to retry download',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-add the download to the queue using metadata from DownloadHistory.
|
||||
* Reconstructs a TorrentResult from the stored history fields.
|
||||
*/
|
||||
async function reAddDownload(
|
||||
jobQueue: ReturnType<typeof getJobQueueService>,
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
history: {
|
||||
torrentName: string | null;
|
||||
magnetLink: string | null;
|
||||
indexerName: string;
|
||||
indexerId: number | null;
|
||||
torrentSizeBytes: bigint | null;
|
||||
seeders: number | null;
|
||||
leechers: number | null;
|
||||
torrentHash: string | null;
|
||||
torrentUrl: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const torrent: TorrentResult = {
|
||||
title: history.torrentName ?? audiobook.title,
|
||||
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
|
||||
indexer: history.indexerName,
|
||||
indexerId: history.indexerId ?? undefined,
|
||||
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
|
||||
seeders: history.seeders ?? undefined,
|
||||
leechers: history.leechers ?? undefined,
|
||||
infoHash: history.torrentHash ?? undefined,
|
||||
infoUrl: history.torrentUrl ?? undefined,
|
||||
guid: history.torrentUrl ?? history.magnetLink!,
|
||||
publishDate: new Date(), // Not stored; use current date as a safe default
|
||||
};
|
||||
|
||||
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Component: Admin Custom Search Terms API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.SearchTerms');
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/requests/[id]/search-terms
|
||||
* Update custom search terms for a request (admin only)
|
||||
* Body: { searchTerms: string | null, triggerSearch?: boolean }
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Parse body
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'Invalid JSON body' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchTerms, triggerSearch } = body;
|
||||
|
||||
// Validate searchTerms is string or null
|
||||
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Trim and normalize
|
||||
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
|
||||
|
||||
// Find the request
|
||||
const existingRequest = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
audiobook: {
|
||||
select: { id: true, title: true, author: true, audibleAsin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRequest || existingRequest.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update custom search terms
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customSearchTerms: normalizedTerms,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
|
||||
requestId: id,
|
||||
customSearchTerms: normalizedTerms,
|
||||
adminId: req.user.id,
|
||||
});
|
||||
|
||||
// Optionally trigger a new search
|
||||
let searchTriggered = false;
|
||||
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
|
||||
// Reset status to pending and clear error
|
||||
await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Queue search job based on request type
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
const audiobookData = {
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||
};
|
||||
|
||||
if (existingRequest.type === 'ebook') {
|
||||
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||
} else {
|
||||
await jobQueue.addSearchJob(id, audiobookData);
|
||||
}
|
||||
|
||||
searchTriggered = true;
|
||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
customSearchTerms: normalizedTerms,
|
||||
searchTriggered,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update search terms', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to update search terms' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
|
||||
completedAt: request.completedAt,
|
||||
errorMessage: request.errorMessage,
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Admin Download Access Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadAccess');
|
||||
|
||||
const CONFIG_KEY = 'download_access';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/download-access
|
||||
* Get current global download access setting
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: CONFIG_KEY },
|
||||
});
|
||||
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const downloadAccess = config === null ? true : config.value === 'true';
|
||||
|
||||
return NextResponse.json({ downloadAccess });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch download access setting', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch download access setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/settings/download-access
|
||||
* Update global download access setting
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { downloadAccess } = body;
|
||||
|
||||
// Validate input
|
||||
if (typeof downloadAccess !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input. downloadAccess must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: CONFIG_KEY },
|
||||
create: {
|
||||
key: CONFIG_KEY,
|
||||
value: downloadAccess.toString(),
|
||||
},
|
||||
update: {
|
||||
value: downloadAccess.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Download access setting updated to: ${downloadAccess}`, {
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({ downloadAccess });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update download access setting', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update download access setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
|
||||
// Anna's Archive specific settings
|
||||
{
|
||||
key: 'ebook_sidecar_base_url',
|
||||
value: baseUrl || 'https://annas-archive.li',
|
||||
value: baseUrl || 'https://annas-archive.gl',
|
||||
category: 'ebook',
|
||||
description: 'Base URL for Anna\'s Archive',
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
|
||||
|
||||
if (!downloadDir || !mediaDir) {
|
||||
return NextResponse.json(
|
||||
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate octal permission strings (3-4 digits, each 0-7)
|
||||
const octalRegex = /^[0-7]{3,4}$/;
|
||||
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
@@ -97,6 +112,60 @@ export async function PUT(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Update file rename setting
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_rename_enabled' },
|
||||
update: { value: String(fileRenameEnabled ?? false) },
|
||||
create: {
|
||||
key: 'file_rename_enabled',
|
||||
value: String(fileRenameEnabled ?? false),
|
||||
category: 'automation',
|
||||
description: 'Rename audio and ebook files using a custom naming template during organization',
|
||||
},
|
||||
});
|
||||
|
||||
// Update file rename template
|
||||
if (fileRenameTemplate !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_rename_template' },
|
||||
update: { value: fileRenameTemplate },
|
||||
create: {
|
||||
key: 'file_rename_template',
|
||||
value: fileRenameTemplate,
|
||||
category: 'automation',
|
||||
description: 'Template for renaming audio and ebook files during organization',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update file permissions (octal chmod)
|
||||
if (fileChmod !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_chmod' },
|
||||
update: { value: fileChmod },
|
||||
create: {
|
||||
key: 'file_chmod',
|
||||
value: fileChmod,
|
||||
category: 'automation',
|
||||
description: 'Octal permissions applied to organized files',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update directory permissions (octal chmod)
|
||||
if (dirChmod !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'dir_chmod' },
|
||||
update: { value: dirChmod },
|
||||
create: {
|
||||
key: 'dir_chmod',
|
||||
value: dirChmod,
|
||||
category: 'automation',
|
||||
description: 'Octal permissions applied to created directories',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Paths settings updated');
|
||||
|
||||
// Clear config cache for all updated keys so services get fresh values
|
||||
@@ -107,6 +176,10 @@ export async function PUT(request: NextRequest) {
|
||||
configService.clearCache('ebook_path_template');
|
||||
configService.clearCache('metadata_tagging_enabled');
|
||||
configService.clearCache('chapter_merging_enabled');
|
||||
configService.clearCache('file_rename_enabled');
|
||||
configService.clearCache('file_rename_template');
|
||||
configService.clearCache('file_chmod');
|
||||
configService.clearCache('dir_chmod');
|
||||
|
||||
// Invalidate all download client singletons to force reload of download_dir
|
||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
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');
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -128,6 +128,10 @@ export async function GET(request: NextRequest) {
|
||||
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
||||
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||
fileChmod: configMap.get('file_chmod') || '664',
|
||||
dirChmod: configMap.get('dir_chmod') || '775',
|
||||
},
|
||||
ebook: {
|
||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||
@@ -136,7 +140,7 @@ export async function GET(request: NextRequest) {
|
||||
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
|
||||
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
|
||||
// Anna's Archive specific settings
|
||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
|
||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.gl',
|
||||
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
||||
// General settings
|
||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Component: Admin User Login Token
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { plexUsername: true, deletedAt: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot generate token for deleted user' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { fullToken, tokenHash } = generateApiToken();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { loginTokenHash: tokenHash },
|
||||
});
|
||||
|
||||
logger.info('Admin generated login token for user', {
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json({ fullToken }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate login token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info('Admin revoked login token for user', {
|
||||
targetUser: targetUser.plexUsername,
|
||||
revokedBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke login token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function PUT(
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { role, autoApproveRequests, interactiveSearchAccess } = body;
|
||||
const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
|
||||
|
||||
// Validate role
|
||||
if (!role || (role !== 'user' && role !== 'admin')) {
|
||||
@@ -45,6 +45,14 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate downloadAccess (optional)
|
||||
if (downloadAccess !== undefined && downloadAccess !== null && typeof downloadAccess !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid downloadAccess. Must be a boolean or null' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent user from demoting themselves
|
||||
if (req.user && id === req.user.sub) {
|
||||
return NextResponse.json(
|
||||
@@ -112,15 +120,24 @@ export async function PUT(
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (role === 'admin' && downloadAccess === false) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admins always have download access. Cannot set downloadAccess to false for admin users.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
|
||||
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null; downloadAccess?: boolean | null } = { role };
|
||||
if (autoApproveRequests !== undefined) {
|
||||
updateData.autoApproveRequests = autoApproveRequests;
|
||||
}
|
||||
if (interactiveSearchAccess !== undefined) {
|
||||
updateData.interactiveSearchAccess = interactiveSearchAccess;
|
||||
}
|
||||
if (downloadAccess !== undefined) {
|
||||
updateData.downloadAccess = downloadAccess;
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updatedUser = await prisma.user.update({
|
||||
@@ -132,6 +149,7 @@ export async function PUT(
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export async function GET(request: NextRequest) {
|
||||
lastLoginAt: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
loginTokenHash: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
@@ -43,7 +45,12 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
return NextResponse.json({
|
||||
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||
...u,
|
||||
hasLoginToken: loginTokenHash !== null,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Component: Audible Categories API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Live scrape of top-level Audible categories for the home section config modal.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audible.Categories');
|
||||
|
||||
/**
|
||||
* GET /api/audible/categories
|
||||
* Returns top-level Audible categories scraped live from audible.com/categories
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (_req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||
const audibleService = getAudibleService();
|
||||
const categories = await audibleService.getCategories();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
categories,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch categories', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch Audible categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Component: Audiobook Download Status API Route
|
||||
* Documentation: documentation/backend/api.md
|
||||
*
|
||||
* Returns whether a downloadable file exists for this audiobook (by ASIN).
|
||||
* Used by AudiobookDetailsModal to show the download link regardless of context.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
import { resolveDownloadAccess } from '@/lib/utils/permissions';
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/[asin]/download-status
|
||||
* Returns { downloadAvailable, requestId } for the current user's completed request.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check download permission - if denied, don't reveal file existence
|
||||
const userRecord = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { role: true, downloadAccess: true },
|
||||
});
|
||||
const hasDownloadAccess = await resolveDownloadAccess(
|
||||
userRecord?.role ?? 'user',
|
||||
userRecord?.downloadAccess ?? null
|
||||
);
|
||||
if (!hasDownloadAccess) {
|
||||
return NextResponse.json({ downloadAvailable: false, requestId: null });
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
const audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
select: { id: true, filePath: true },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
return NextResponse.json({ downloadAvailable: false, requestId: null });
|
||||
}
|
||||
|
||||
// Find any completed request for this audiobook that has a file
|
||||
const completedRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
status: { in: [...COMPLETED_STATUSES] },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const downloadAvailable = !!completedRequest && !!audiobook.filePath;
|
||||
|
||||
return NextResponse.json({
|
||||
downloadAvailable,
|
||||
requestId: downloadAvailable ? completedRequest!.id : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,7 @@ export async function POST(
|
||||
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -292,6 +293,7 @@ export async function POST(
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
@@ -225,7 +227,12 @@ export async function POST(
|
||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
|
||||
|
||||
// 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) {
|
||||
return NextResponse.json(
|
||||
@@ -250,7 +257,8 @@ export async function POST(
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
flaresolverrUrl || undefined,
|
||||
languageCode
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
@@ -322,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
flaresolverrUrl?: string,
|
||||
languageCode: string = 'en'
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
@@ -330,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Try ASIN search first
|
||||
if (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) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
@@ -340,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
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) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
@@ -461,6 +470,10 @@ async function searchIndexersForInteractive(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||
|
||||
// Rank results with ebook scoring
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
title,
|
||||
@@ -470,6 +483,8 @@ async function searchIndexersForInteractive(
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
stopWords: rankLangConfig.stopWords,
|
||||
characterReplacements: rankLangConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Convert to unified result type
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobook,
|
||||
audibleBaseUrl: audibleService.getBaseUrl(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -252,6 +252,7 @@ export async function POST(
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
selectedTorrent: selectedEbook as any,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
||||
@@ -296,6 +297,7 @@ export async function POST(
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Component: Category Audiobooks API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
|
||||
* with the same enrichment pattern as popular/new-releases routes.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ categoryId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { categoryId } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Get excluded ASINs when hideAvailable
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
// Query AudibleCacheCategory joined with AudibleCache
|
||||
const whereClause: any = { categoryId };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
if (totalCount === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: [],
|
||||
count: 0,
|
||||
totalCount: 0,
|
||||
page,
|
||||
totalPages: 0,
|
||||
hasMore: false,
|
||||
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Enrich with library matching and request status
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get category audiobooks', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
* Component: Audiobook Covers API Route
|
||||
* Documentation: documentation/frontend/pages/login.md
|
||||
*
|
||||
* Serves random popular audiobook covers for login page floating animations
|
||||
* Serves random popular audiobook covers for login page floating animations.
|
||||
* Queries AudibleCacheCategory with '__popular__' categoryId for cover sources.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
|
||||
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Fetch all popular audiobooks with covers (up to 200)
|
||||
// Get popular ASINs from category table (up to 200)
|
||||
const categoryEntries = await prisma.audibleCacheCategory.findMany({
|
||||
where: { categoryId: POPULAR_CATEGORY_ID },
|
||||
orderBy: { rank: 'asc' },
|
||||
take: 200,
|
||||
select: { asin: true },
|
||||
});
|
||||
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
|
||||
// Fetch cover data from AudibleCache for popular ASINs with cached covers
|
||||
const audiobooks = await prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
cachedCoverPath: {
|
||||
not: null,
|
||||
},
|
||||
asin: { in: asins },
|
||||
cachedCoverPath: { not: null },
|
||||
},
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
take: 200,
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
* Component: New Releases API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves new release audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves new release audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* New releases are stored with categoryId '__new_releases__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/new-releases?page=1&limit=20
|
||||
* Get new release audiobooks from audible_cache with pagination
|
||||
* Get new release audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -24,6 +27,7 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,38 +42,28 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Query audible_cache for new release audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query AudibleCacheCategory for new release audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -86,30 +80,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
@@ -117,18 +137,21 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich with real-time Plex library matching and request status
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
* Component: Popular Audiobooks API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves popular audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves popular audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* Popular books are stored with categoryId '__popular__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/popular?page=1&limit=20
|
||||
* Get popular audiobooks from audible_cache with pagination
|
||||
* Get popular audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -24,6 +27,7 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,38 +42,28 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Query audible_cache for popular audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query AudibleCacheCategory for popular audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -86,30 +80,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
@@ -117,18 +137,21 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich with real-time Plex library matching and request status
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get popular audiobooks', { 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 { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
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 { 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)`);
|
||||
}
|
||||
|
||||
// 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
|
||||
// Note: rankTorrents now filters out results < 20 MB internally
|
||||
// requireAuthor: false - interactive search, show all results for user decision
|
||||
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false // Interactive mode - let user decide
|
||||
requireAuthor: false, // Interactive mode - let user decide
|
||||
stopWords: langConfig.stopWords,
|
||||
characterReplacements: langConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Log filter results
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||
|
||||
@@ -38,14 +41,25 @@ export async function GET(request: NextRequest) {
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
// Enrich search results with availability and request status information
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: results.query,
|
||||
results: enrichedResults,
|
||||
totalResults: results.totalResults,
|
||||
results: annotatedResults,
|
||||
totalResults: enrichedResults.length,
|
||||
page: results.page,
|
||||
hasMore: results.hasMore,
|
||||
});
|
||||
|
||||
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
|
||||
// Find user by local admin identifier
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { plexId: `local-${username}` },
|
||||
where: { plexId: `local-${normalizedUsername}` },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +64,13 @@ export async function GET(request: NextRequest) {
|
||||
globalInteractiveSearch
|
||||
);
|
||||
|
||||
const globalDownload = await getGlobalBooleanSetting('download_access', true);
|
||||
const effectiveDownload = resolvePermission(
|
||||
user.role,
|
||||
user.downloadAccess,
|
||||
globalDownload
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
@@ -77,6 +85,7 @@ export async function GET(request: NextRequest) {
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
permissions: {
|
||||
interactiveSearch: effectiveInteractiveSearch,
|
||||
download: effectiveDownload,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
deletedAt: true,
|
||||
sessionsInvalidatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if session was invalidated after this refresh token was issued
|
||||
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = generateAccessToken({
|
||||
sub: user.id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user