mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04dbb05a6e | |||
| cb9f1b81bc | |||
| 5d8ac2f73d | |||
| c146383735 | |||
| 3820b9b21d | |||
| 20798b3dc0 | |||
| 3f8180a246 | |||
| c97df7798a | |||
| c0096cda1a | |||
| 98a2cc2813 | |||
| 4df49633b4 | |||
| 6f0d71ee9b | |||
| a145dc9877 | |||
| 89422fc77a | |||
| e40e77c8fe | |||
| 7addb1dc70 | |||
| eca24e46a8 | |||
| b1561a8311 | |||
| 20c8fb0898 | |||
| b013538b63 | |||
| bceb13f4dd | |||
| 6b83e5dac1 | |||
| af0eaceb98 | |||
| 1d25f7f7b2 | |||
| 4e84887d33 | |||
| 4a38dd3da8 | |||
| f9947b745e | |||
| 7e53f037af | |||
| 4b90b35748 | |||
| d7acd67aa4 | |||
| a663452658 | |||
| d0e3c9c665 | |||
| 95e63dfc36 | |||
| 03371be81d | |||
| 4c1d1c89e8 | |||
| d25d93680e | |||
| 312421a96b |
@@ -53,6 +53,33 @@ services:
|
|||||||
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here"
|
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here"
|
||||||
# POSTGRES_PASSWORD: "your-custom-postgres-password-here"
|
# POSTGRES_PASSWORD: "your-custom-postgres-password-here"
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# OPTIONAL: External PostgreSQL and Redis
|
||||||
|
# ========================================================================
|
||||||
|
# To use external PostgreSQL or Redis instances instead of the internal ones,
|
||||||
|
# uncomment and configure the appropriate URL(s):
|
||||||
|
#
|
||||||
|
# External PostgreSQL example:
|
||||||
|
# DATABASE_URL: "postgresql://username:password@postgres.example.com:5432/readmeabook"
|
||||||
|
#
|
||||||
|
# External Redis example:
|
||||||
|
# REDIS_URL: "redis://redis.example.com:6379"
|
||||||
|
# REDIS_URL: "redis://:password@redis.example.com:6379" # With password
|
||||||
|
#
|
||||||
|
# Note: When using external services:
|
||||||
|
# - The internal PostgreSQL/Redis will NOT start (smart detection)
|
||||||
|
# - You do NOT need to mount ./pgdata or ./redis volumes
|
||||||
|
# - Ensure your external services are accessible from the container
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# OPTIONAL: Rootless Podman Support
|
||||||
|
# ========================================================================
|
||||||
|
# Set to "true" ONLY if running with rootless Podman.
|
||||||
|
# This skips gosu UID/GID switching since the user namespace already
|
||||||
|
# handles mapping. Do NOT enable for Docker or LXC - it will cause
|
||||||
|
# files to be created as root.
|
||||||
|
# ROOTLESS_CONTAINER: "true"
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# OPTIONAL: Application Configuration
|
# OPTIONAL: Application Configuration
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -62,6 +89,9 @@ services:
|
|||||||
# PLEX_CLIENT_IDENTIFIER: "readmeabook-custom-id"
|
# PLEX_CLIENT_IDENTIFIER: "readmeabook-custom-id"
|
||||||
# PLEX_PRODUCT_NAME: "ReadMeABook"
|
# PLEX_PRODUCT_NAME: "ReadMeABook"
|
||||||
# LOG_LEVEL: "info"
|
# LOG_LEVEL: "info"
|
||||||
|
# DISABLE_LOCAL_LOGIN: "true" # Set to "true" to disable local login (force OAuth)
|
||||||
|
# ALLOW_WEAK_PASSWORD: "true" # Set to "true" to remove minimum password length requirement
|
||||||
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# IMPORTANT: Public URL Configuration (Required for OAuth)
|
# IMPORTANT: Public URL Configuration (Required for OAuth)
|
||||||
|
|||||||
+78
-56
@@ -3,42 +3,11 @@
|
|||||||
# Uses gosu to ensure correct PUID:PGID for file operations
|
# Uses gosu to ensure correct PUID:PGID for file operations
|
||||||
#
|
#
|
||||||
# Supports:
|
# Supports:
|
||||||
# - Docker: Uses gosu to switch to PUID:PGID
|
# - Docker/LXC: Uses gosu to switch to PUID:PGID (default)
|
||||||
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
|
# - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu
|
||||||
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# USER NAMESPACE DETECTION
|
|
||||||
# =============================================================================
|
|
||||||
# Detects if running in a user namespace where UID 0 is remapped to a non-root
|
|
||||||
# user on the host (e.g., rootless Podman). In this case, using gosu would
|
|
||||||
# cause a double-mapping that breaks volume permissions.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# - /proc/self/uid_map shows the UID mapping for the current namespace
|
|
||||||
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
|
|
||||||
# - In a normal container: "0 0 4294967295" (root maps to root)
|
|
||||||
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
|
|
||||||
#
|
|
||||||
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
|
|
||||||
# =============================================================================
|
|
||||||
is_user_namespace_root() {
|
|
||||||
if [ -f /proc/self/uid_map ]; then
|
|
||||||
# Read the first mapping line (covers UID 0)
|
|
||||||
read -r inside outside count < /proc/self/uid_map
|
|
||||||
# Trim whitespace (uid_map has leading spaces for alignment)
|
|
||||||
inside=$(echo "$inside" | xargs)
|
|
||||||
outside=$(echo "$outside" | xargs)
|
|
||||||
# If UID 0 inside maps to non-0 outside, we're in a user namespace
|
|
||||||
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
|
|
||||||
return 0 # true - rootless container detected
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
return 1 # false - normal container (Docker or rootful Podman)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load environment from /etc/environment (set by entrypoint)
|
# Load environment from /etc/environment (set by entrypoint)
|
||||||
if [ -f /etc/environment ]; then
|
if [ -f /etc/environment ]; then
|
||||||
set -a
|
set -a
|
||||||
@@ -58,23 +27,18 @@ cd /app
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# START SERVER WITH APPROPRIATE UID:GID HANDLING
|
# START SERVER WITH APPROPRIATE UID:GID HANDLING
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Three scenarios:
|
# Two scenarios:
|
||||||
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
|
# 1. Default: Running as root, use gosu to switch to PUID:PGID
|
||||||
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
|
# 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping)
|
||||||
# 3. Non-root fallback: Already running as non-root, run directly
|
|
||||||
|
|
||||||
start_server() {
|
start_server() {
|
||||||
if [ "$(id -u)" = "0" ]; then
|
if [ "$(id -u)" = "0" ]; then
|
||||||
if is_user_namespace_root; then
|
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
|
||||||
# Rootless container (e.g., rootless Podman)
|
# Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user
|
||||||
# Skip gosu - the user namespace already maps our "root" to the correct host UID
|
echo "[App] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)"
|
||||||
echo "[App] Detected rootless container (user namespace with remapped root)"
|
|
||||||
echo "[App] Skipping gosu to preserve user namespace UID mapping"
|
|
||||||
echo "[App] Process will run as namespace UID 0 (mapped to host user)"
|
|
||||||
node server.js &
|
node server.js &
|
||||||
else
|
else
|
||||||
# Normal container (Docker or rootful Podman)
|
# Default: Use gosu to switch to the specified PUID:PGID
|
||||||
# Use gosu to switch to the specified PUID:PGID
|
|
||||||
echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
|
echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
|
||||||
gosu "$PUID:$PGID" node server.js &
|
gosu "$PUID:$PGID" node server.js &
|
||||||
fi
|
fi
|
||||||
@@ -89,14 +53,75 @@ start_server() {
|
|||||||
start_server
|
start_server
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
||||||
echo "[App] Waiting for server to be ready..."
|
# =============================================================================
|
||||||
sleep 5
|
# WAIT FOR SERVER READINESS
|
||||||
|
# =============================================================================
|
||||||
|
# The health endpoint (/api/health) checks both the Next.js server AND database
|
||||||
|
# connectivity. We must wait for both before initializing scheduled jobs.
|
||||||
|
|
||||||
# Initialize application services (creates default scheduled jobs)
|
HEALTH_URL="http://localhost:3030/api/health"
|
||||||
echo "[App] Initializing application services..."
|
INIT_URL="http://localhost:3030/api/init"
|
||||||
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
|
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)
|
# Verify the process is running with correct UID:GID (for debugging)
|
||||||
if [ -f "/proc/$SERVER_PID/status" ]; then
|
if [ -f "/proc/$SERVER_PID/status" ]; then
|
||||||
@@ -104,11 +129,8 @@ if [ -f "/proc/$SERVER_PID/status" ]; then
|
|||||||
ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}')
|
ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}')
|
||||||
echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID"
|
echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID"
|
||||||
|
|
||||||
# Only warn about mismatch in non-rootless scenarios
|
if [ "${ROOTLESS_CONTAINER}" != "true" ] && { [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; }; then
|
||||||
if ! is_user_namespace_root; then
|
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
|
||||||
if [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; then
|
|
||||||
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+176
-100
@@ -157,67 +157,104 @@ export PLEX_PRODUCT_NAME="${PLEX_PRODUCT_NAME:-ReadMeABook}"
|
|||||||
export LOG_LEVEL="${LOG_LEVEL:-info}"
|
export LOG_LEVEL="${LOG_LEVEL:-info}"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# INITIALIZE POSTGRESQL
|
# DETECT EXTERNAL SERVICES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
PGDATA="/var/lib/postgresql/data"
|
# Check if user provided external DATABASE_URL or REDIS_URL
|
||||||
PG_WAS_EMPTY=0
|
USE_EXTERNAL_POSTGRES=false
|
||||||
|
USE_EXTERNAL_REDIS=false
|
||||||
|
|
||||||
# Ensure correct ownership of data directories (critical for bind mounts)
|
if [ -n "$DATABASE_URL" ]; then
|
||||||
echo "🔧 Setting up directory permissions..."
|
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
|
||||||
|
if [ "$DB_HOST" != "127.0.0.1" ] && [ "$DB_HOST" != "localhost" ]; then
|
||||||
# PostgreSQL directories - owned by postgres user, group accessible
|
USE_EXTERNAL_POSTGRES=true
|
||||||
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
|
echo "ℹ️ External PostgreSQL detected at $DB_HOST"
|
||||||
echo ""
|
fi
|
||||||
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
|
fi
|
||||||
|
|
||||||
if [ -n "$PGID" ]; then
|
if [ -n "$REDIS_URL" ]; then
|
||||||
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
|
# Extract host from REDIS_URL - handles both redis://host:port and redis://:password@host:port
|
||||||
# This allows the PGID group to read PostgreSQL files if needed
|
if echo "$REDIS_URL" | grep -q '@'; then
|
||||||
chmod 750 "$PGDATA"
|
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
|
||||||
chmod 775 /var/run/postgresql
|
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
|
else
|
||||||
# Without PUID/PGID: Use strict 700 permissions (owner only)
|
echo "⏭️ Skipping internal PostgreSQL setup (using external database)"
|
||||||
chmod 700 "$PGDATA"
|
|
||||||
chmod 775 /var/run/postgresql
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Redis directory - owned by redis user (remapped to PUID:PGID if set)
|
# Redis directory - owned by redis user (remapped to PUID:PGID if set)
|
||||||
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
|
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
|
||||||
echo ""
|
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
|
||||||
echo "❌ ERROR: Failed to set ownership on Redis directory"
|
echo ""
|
||||||
echo " See solutions above for PostgreSQL directories"
|
echo "❌ ERROR: Failed to set ownership on Redis directory"
|
||||||
echo ""
|
echo " See solutions above for PostgreSQL directories"
|
||||||
exit 1
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod 770 /var/lib/redis
|
||||||
|
else
|
||||||
|
echo "⏭️ Skipping internal Redis setup (using external Redis)"
|
||||||
fi
|
fi
|
||||||
chmod 770 /var/lib/redis
|
|
||||||
|
|
||||||
# App directories - owned by node user (remapped to PUID:PGID if set)
|
# App directories - owned by node user (remapped to PUID:PGID if set)
|
||||||
# These need group write permissions for shared access
|
# These need group write permissions for shared access
|
||||||
@@ -232,18 +269,20 @@ chmod 775 /app/config /app/cache
|
|||||||
|
|
||||||
echo "✅ Directory permissions configured"
|
echo "✅ Directory permissions configured"
|
||||||
|
|
||||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||||
PG_WAS_EMPTY=1
|
# Only initialize/setup PostgreSQL if using internal instance
|
||||||
echo "📦 Initializing PostgreSQL database..."
|
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||||
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
|
PG_WAS_EMPTY=1
|
||||||
|
echo "📦 Initializing PostgreSQL database..."
|
||||||
|
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
|
||||||
|
|
||||||
# Configure PostgreSQL for local access
|
# Configure PostgreSQL for local access
|
||||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
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 "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
|
||||||
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
|
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
|
||||||
|
|
||||||
# Update postgresql.conf for performance
|
# Update postgresql.conf for performance
|
||||||
cat >> "$PGDATA/postgresql.conf" <<EOF
|
cat >> "$PGDATA/postgresql.conf" <<EOF
|
||||||
listen_addresses = '127.0.0.1'
|
listen_addresses = '127.0.0.1'
|
||||||
max_connections = 100
|
max_connections = 100
|
||||||
shared_buffers = 128MB
|
shared_buffers = 128MB
|
||||||
@@ -254,31 +293,31 @@ log_destination = 'stderr'
|
|||||||
logging_collector = off
|
logging_collector = off
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "✅ PostgreSQL initialized"
|
echo "✅ PostgreSQL initialized"
|
||||||
else
|
else
|
||||||
echo "✅ PostgreSQL data directory already exists"
|
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
|
|
||||||
fi
|
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
|
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
|
||||||
echo "👤 Ensuring database user and database exist..."
|
# ========================================================================
|
||||||
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
|
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 \$\$
|
DO \$\$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$POSTGRES_USER') THEN
|
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;
|
ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
|
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
|
||||||
echo "✅ Database initialized and setup complete"
|
echo "✅ Database initialized and setup complete"
|
||||||
else
|
else
|
||||||
echo "✅ Database user and permissions verified"
|
echo "✅ Database user and permissions verified"
|
||||||
|
fi
|
||||||
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SET ENVIRONMENT VARIABLES FOR APP
|
# SET ENVIRONMENT VARIABLES FOR APP
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# URL-encode the password to handle special characters
|
# Set DATABASE_URL and REDIS_URL based on whether we're using internal or external services
|
||||||
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
|
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||||
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
|
# URL-encode the password to handle special characters
|
||||||
export REDIS_URL="redis://127.0.0.1:6379"
|
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 NODE_ENV="production"
|
||||||
export PORT="3030"
|
export PORT="3030"
|
||||||
export HOSTNAME="0.0.0.0"
|
export HOSTNAME="0.0.0.0"
|
||||||
@@ -318,6 +374,8 @@ export HOSTNAME="0.0.0.0"
|
|||||||
cat > /etc/environment <<EOF
|
cat > /etc/environment <<EOF
|
||||||
DATABASE_URL=$DATABASE_URL
|
DATABASE_URL=$DATABASE_URL
|
||||||
REDIS_URL=$REDIS_URL
|
REDIS_URL=$REDIS_URL
|
||||||
|
USE_EXTERNAL_POSTGRES=$USE_EXTERNAL_POSTGRES
|
||||||
|
USE_EXTERNAL_REDIS=$USE_EXTERNAL_REDIS
|
||||||
JWT_SECRET=$JWT_SECRET
|
JWT_SECRET=$JWT_SECRET
|
||||||
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
|
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
|
||||||
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
|
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
|
||||||
@@ -329,20 +387,27 @@ PORT=$PORT
|
|||||||
HOSTNAME=$HOSTNAME
|
HOSTNAME=$HOSTNAME
|
||||||
PUID=${PUID:-}
|
PUID=${PUID:-}
|
||||||
PGID=${PGID:-}
|
PGID=${PGID:-}
|
||||||
|
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "✅ Environment configured"
|
echo "✅ Environment configured"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# RUN PRISMA MIGRATIONS (while PostgreSQL is still running)
|
# RUN PRISMA MIGRATIONS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
|
||||||
|
echo "⚠️ Running schema sync against EXTERNAL database - prisma db push --accept-data-loss"
|
||||||
|
echo " This runs on every container start. Ensure your external database is backed up."
|
||||||
|
fi
|
||||||
echo "🔄 Running Prisma migrations..."
|
echo "🔄 Running Prisma migrations..."
|
||||||
cd /app
|
cd /app
|
||||||
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
||||||
|
|
||||||
# Stop PostgreSQL (supervisord will start it)
|
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
||||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
|
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||||
|
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
|
||||||
|
fi
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DISPLAY STARTUP INFO
|
# DISPLAY STARTUP INFO
|
||||||
@@ -360,10 +425,21 @@ if [ "$POSTGRES_PASSWORD" = "$(generate_secret)" ]; then
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "📊 Services starting:"
|
echo "📊 Services starting:"
|
||||||
echo " - PostgreSQL (internal, user=postgres)"
|
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||||
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})"
|
echo " - PostgreSQL (internal, 127.0.0.1:5432)"
|
||||||
|
else
|
||||||
|
echo " - PostgreSQL (external - local instance disabled)"
|
||||||
|
fi
|
||||||
|
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
|
||||||
|
echo " - Redis (internal, 127.0.0.1:6379, UID:GID=${PUID:-102}:${PGID:-102})"
|
||||||
|
else
|
||||||
|
echo " - Redis (external - local instance disabled)"
|
||||||
|
fi
|
||||||
echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})"
|
echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})"
|
||||||
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
|
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "🔐 ROOTLESS_CONTAINER=true - gosu will be skipped (user namespace handles UID mapping)"
|
||||||
|
elif [ -n "$PUID" ] && [ -n "$PGID" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔐 Using gosu for reliable UID:GID switching"
|
echo "🔐 Using gosu for reliable UID:GID switching"
|
||||||
echo " App and Redis will run as $PUID:$PGID"
|
echo " App and Redis will run as $PUID:$PGID"
|
||||||
|
|||||||
@@ -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,44 +1,16 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Redis startup wrapper for unified container
|
# Redis startup wrapper for unified container
|
||||||
|
# Checks USE_EXTERNAL_REDIS flag (set by entrypoint) to decide whether
|
||||||
|
# to start the local instance or sleep to keep supervisord happy.
|
||||||
|
#
|
||||||
# Uses gosu to ensure correct PUID:PGID for file operations
|
# Uses gosu to ensure correct PUID:PGID for file operations
|
||||||
#
|
#
|
||||||
# Supports:
|
# Supports:
|
||||||
# - Docker: Uses gosu to switch to PUID:PGID
|
# - Docker/LXC: Uses gosu to switch to PUID:PGID (default)
|
||||||
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
|
# - Rootless Podman: Set ROOTLESS_CONTAINER=true to skip gosu
|
||||||
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# USER NAMESPACE DETECTION
|
|
||||||
# =============================================================================
|
|
||||||
# Detects if running in a user namespace where UID 0 is remapped to a non-root
|
|
||||||
# user on the host (e.g., rootless Podman). In this case, using gosu would
|
|
||||||
# cause a double-mapping that breaks volume permissions.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# - /proc/self/uid_map shows the UID mapping for the current namespace
|
|
||||||
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
|
|
||||||
# - In a normal container: "0 0 4294967295" (root maps to root)
|
|
||||||
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
|
|
||||||
#
|
|
||||||
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
|
|
||||||
# =============================================================================
|
|
||||||
is_user_namespace_root() {
|
|
||||||
if [ -f /proc/self/uid_map ]; then
|
|
||||||
# Read the first mapping line (covers UID 0)
|
|
||||||
read -r inside outside count < /proc/self/uid_map
|
|
||||||
# Trim whitespace (uid_map has leading spaces for alignment)
|
|
||||||
inside=$(echo "$inside" | xargs)
|
|
||||||
outside=$(echo "$outside" | xargs)
|
|
||||||
# If UID 0 inside maps to non-0 outside, we're in a user namespace
|
|
||||||
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
|
|
||||||
return 0 # true - rootless container detected
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
return 1 # false - normal container (Docker or rootful Podman)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load environment from /etc/environment (set by entrypoint)
|
# Load environment from /etc/environment (set by entrypoint)
|
||||||
if [ -f /etc/environment ]; then
|
if [ -f /etc/environment ]; then
|
||||||
set -a
|
set -a
|
||||||
@@ -46,34 +18,35 @@ if [ -f /etc/environment ]; then
|
|||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$USE_EXTERNAL_REDIS" = "true" ]; then
|
||||||
|
echo "[Redis] External Redis configured - skipping local instance"
|
||||||
|
exec sleep infinity
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[Redis] Starting local Redis server..."
|
||||||
|
|
||||||
# Get PUID/PGID (default to redis user's current IDs if not set)
|
# Get PUID/PGID (default to redis user's current IDs if not set)
|
||||||
PUID=${PUID:-$(id -u redis)}
|
PUID=${PUID:-$(id -u redis)}
|
||||||
PGID=${PGID:-$(id -g redis)}
|
PGID=${PGID:-$(id -g redis)}
|
||||||
|
|
||||||
echo "[Redis] Starting Redis server..."
|
|
||||||
echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
|
echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# START REDIS WITH APPROPRIATE UID:GID HANDLING
|
# START REDIS WITH APPROPRIATE UID:GID HANDLING
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Three scenarios:
|
# Two scenarios:
|
||||||
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
|
# 1. Default: Running as root, use gosu to switch to PUID:PGID
|
||||||
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
|
# 2. ROOTLESS_CONTAINER=true: Skip gosu (rootless Podman user namespace handles UID mapping)
|
||||||
# 3. Non-root fallback: Already running as non-root, run directly
|
|
||||||
|
|
||||||
REDIS_CMD="/usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379"
|
REDIS_CMD="/usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379"
|
||||||
|
|
||||||
if [ "$(id -u)" = "0" ]; then
|
if [ "$(id -u)" = "0" ]; then
|
||||||
if is_user_namespace_root; then
|
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
|
||||||
# Rootless container (e.g., rootless Podman)
|
# Rootless Podman: Skip gosu - user namespace already maps UID 0 to host user
|
||||||
# Skip gosu - the user namespace already maps our "root" to the correct host UID
|
echo "[Redis] ROOTLESS_CONTAINER=true - skipping gosu (user namespace handles UID mapping)"
|
||||||
echo "[Redis] Detected rootless container (user namespace with remapped root)"
|
|
||||||
echo "[Redis] Skipping gosu to preserve user namespace UID mapping"
|
|
||||||
echo "[Redis] Process will run as namespace UID 0 (mapped to host user)"
|
|
||||||
exec $REDIS_CMD
|
exec $REDIS_CMD
|
||||||
else
|
else
|
||||||
# Normal container (Docker or rootful Podman)
|
# Default: Use gosu to switch to the specified PUID:PGID
|
||||||
# Use gosu to switch to the specified PUID:PGID
|
|
||||||
echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
|
echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
|
||||||
exec gosu "$PUID:$PGID" $REDIS_CMD
|
exec gosu "$PUID:$PGID" $REDIS_CMD
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ loglevel=info
|
|||||||
pidfile=/var/run/supervisord.pid
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
[program:postgresql]
|
[program:postgresql]
|
||||||
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
|
command=/app/postgres-start.sh
|
||||||
user=postgres
|
user=postgres
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ COPY --chown=root:root docker/unified/redis-start.sh /app/redis-start.sh
|
|||||||
# Convert line endings and make executable
|
# Convert line endings and make executable
|
||||||
RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh
|
RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh
|
||||||
|
|
||||||
|
# Copy postgres startup wrapper
|
||||||
|
COPY --chown=root:root docker/unified/postgres-start.sh /app/postgres-start.sh
|
||||||
|
# Convert line endings and make executable
|
||||||
|
RUN sed -i 's/\r$//' /app/postgres-start.sh && chmod +x /app/postgres-start.sh
|
||||||
|
|
||||||
# Expose app port
|
# Expose app port
|
||||||
EXPOSE 3030
|
EXPOSE 3030
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
||||||
- **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md)
|
- **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md)
|
||||||
- **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md)
|
- **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md)
|
||||||
- **Multi-download-client support (qBittorrent + SABnzbd)** → [phase3/download-clients.md](phase3/download-clients.md)
|
- **Multi-download-client support (qBittorrent, Transmission, SABnzbd, NZBGet)** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||||
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
||||||
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
|
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
|
||||||
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
|
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
|
||||||
@@ -111,8 +111,11 @@
|
|||||||
**"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation)
|
**"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation)
|
||||||
**"How do I configure multiple download clients?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
**"How do I configure multiple download clients?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||||
**"How do torrent downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md)
|
**"How do torrent downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md)
|
||||||
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md)
|
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [phase3/download-clients.md](phase3/download-clients.md), [backend/services/jobs.md](backend/services/jobs.md)
|
||||||
**"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
**"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||||
|
**"How do I use NZBGet instead of SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||||
|
**"How do I use Transmission instead of qBittorrent?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||||
|
**"How do I set different download paths per client?"** → [phase3/download-clients.md](phase3/download-clients.md#per-client-custom-download-path)
|
||||||
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
|
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
|
||||||
**"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
**"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||||
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar)
|
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||||
@@ -134,7 +137,7 @@
|
|||||||
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
|
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
|
||||||
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
|
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
|
||||||
**"Why can't RMAB find my downloaded files?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
|
**"Why can't RMAB find my downloaded files?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
|
||||||
**"How do I set up volume mapping for qBittorrent/SABnzbd?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
|
**"How do I set up volume mapping for qBittorrent/Transmission/SABnzbd/NZBGet?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md)
|
||||||
**"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md)
|
**"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md)
|
||||||
**"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md)
|
**"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md)
|
||||||
**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
|
**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ docker-compose logs -f app
|
|||||||
## 📊 Feature Highlights
|
## 📊 Feature Highlights
|
||||||
|
|
||||||
### AI-Powered Recommendations
|
### AI-Powered Recommendations
|
||||||
- **Providers:** OpenAI (GPT-4o+) or Claude (Sonnet 4.5, Opus 4, Haiku)
|
- **Providers:** OpenAI (GPT-4+) or Claude (dynamically fetched from Anthropic Models API)
|
||||||
- **Personalization:** Based on your Plex library + swipe history
|
- **Personalization:** Based on your Plex library + swipe history
|
||||||
- **Context:** Max 50 books (40 library + 10 swipes)
|
- **Context:** Max 50 books (40 library + 10 swipes)
|
||||||
- **Filtering:** Excludes books already in library, already requested, or already swiped
|
- **Filtering:** Excludes books already in library, already requested, or already swiped
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Notification System
|
# Notification System
|
||||||
|
|
||||||
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support
|
**Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
|
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
|
||||||
|
|
||||||
## Key Details
|
## Key Details
|
||||||
- **Backends:** Discord (webhooks), Pushover (API)
|
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
|
||||||
- **Events:** request_pending_approval, request_approved, request_available, request_error
|
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
|
||||||
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys)
|
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
|
||||||
- **Delivery:** Async via Bull job queue (priority 5)
|
- **Delivery:** Async via Bull job queue (priority 5)
|
||||||
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
|
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
|
|||||||
```prisma
|
```prisma
|
||||||
model NotificationBackend {
|
model NotificationBackend {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type String // 'discord' | 'pushover'
|
type String // 'apprise' | 'discord' | 'ntfy' | 'pushover'
|
||||||
name String // User-friendly label
|
name String // User-friendly label
|
||||||
config Json // Encrypted sensitive values
|
config Json // Encrypted sensitive values
|
||||||
events Json // Array of subscribed events
|
events Json // Array of subscribed events
|
||||||
@@ -33,8 +33,15 @@ model NotificationBackend {
|
|||||||
|-------|---------|------------------------|
|
|-------|---------|------------------------|
|
||||||
| request_pending_approval | User creates request | Request needs admin approval |
|
| request_pending_approval | User creates request | Request needs admin approval |
|
||||||
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
|
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
|
||||||
| request_available | Plex/ABS scan completes | Audiobook available in library |
|
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
|
||||||
| request_error | Download/import fails | Request failed at any stage |
|
| request_error | Download/import fails | Request failed at any stage |
|
||||||
|
| issue_reported | User reports issue | User reports problem with available audiobook |
|
||||||
|
|
||||||
|
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
|
||||||
|
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
|
||||||
|
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
|
||||||
|
- `request_available` + no requestType → "Request Available" (fallback)
|
||||||
|
- Use `getEventTitle(event, requestType?)` to resolve titles in providers
|
||||||
|
|
||||||
## Notification Triggers
|
## Notification Triggers
|
||||||
|
|
||||||
@@ -59,18 +66,28 @@ model NotificationBackend {
|
|||||||
- Approve (with or without pre-selected torrent): After job triggered → request_approved
|
- Approve (with or without pre-selected torrent): After job triggered → request_approved
|
||||||
- Deny: No notification
|
- Deny: No notification
|
||||||
|
|
||||||
**Request Available (processors: scan-plex, plex-recently-added)**
|
**Audiobook Available (processors: scan-plex, plex-recently-added)**
|
||||||
- After `status: 'available'` update → request_available
|
- After `status: 'available'` update → request_available (requestType: 'audiobook')
|
||||||
- Includes user info in query (plexUsername)
|
- Includes user info in query (plexUsername)
|
||||||
|
|
||||||
|
**Ebook Available (processor: organize-files)**
|
||||||
|
- After ebook `status: 'downloaded'` (terminal) → request_available (requestType: 'ebook')
|
||||||
|
- Ebooks don't transition to 'available' via Plex matching
|
||||||
|
|
||||||
**Request Error (processors: monitor-download, organize-files)**
|
**Request Error (processors: monitor-download, organize-files)**
|
||||||
- After `status: 'failed'` or `status: 'warn'` update → request_error
|
- After `status: 'failed'` or `status: 'warn'` update → request_error
|
||||||
- Includes error message in payload
|
- Includes error message in payload
|
||||||
|
|
||||||
|
**Issue Reported (reported-issue.service.ts)**
|
||||||
|
- After user reports issue with available audiobook → issue_reported
|
||||||
|
- Payload: issue ID (as requestId), book title/author, reporter username, reason (as message)
|
||||||
|
|
||||||
## Configuration Encryption
|
## Configuration Encryption
|
||||||
|
|
||||||
**Encrypted Values:**
|
**Encrypted Values:**
|
||||||
|
- Apprise: `urls`, `authToken`
|
||||||
- Discord: `webhookUrl`
|
- Discord: `webhookUrl`
|
||||||
|
- ntfy: `accessToken`
|
||||||
- Pushover: `userKey`, `appToken`
|
- Pushover: `userKey`, `appToken`
|
||||||
|
|
||||||
**Pattern:** `iv:authTag:encryptedData` (base64)
|
**Pattern:** `iv:authTag:encryptedData` (base64)
|
||||||
@@ -81,12 +98,27 @@ model NotificationBackend {
|
|||||||
|
|
||||||
## Message Formatting
|
## Message Formatting
|
||||||
|
|
||||||
|
**Apprise (JSON via Apprise API):**
|
||||||
|
- Type: info (pending), success (approved/available), failure (error)
|
||||||
|
- Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter)
|
||||||
|
- Endpoint: `{serverUrl}/notify/` (stateless) or `{serverUrl}/notify/{configKey}` (stateful)
|
||||||
|
- Auth: Optional Bearer token via `authToken` config field
|
||||||
|
- Format: Event title + book details + user + error (if applicable)
|
||||||
|
|
||||||
**Discord (Rich Embeds):**
|
**Discord (Rich Embeds):**
|
||||||
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
|
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue)
|
||||||
- Fields: Title, Author, Requested By, Error (if applicable)
|
- Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable)
|
||||||
- Footer: Request ID
|
- Footer: Request/Issue ID
|
||||||
- Timestamp: Event time
|
- Timestamp: Event time
|
||||||
|
|
||||||
|
**ntfy (JSON Publishing to Base URL):**
|
||||||
|
- Endpoint: POST to base `serverUrl` (default: https://ntfy.sh), topic in JSON body
|
||||||
|
- Tags: mailbox_with_mail, white_check_mark, tada, x, triangular_flag_on_post (rendered as emojis by ntfy)
|
||||||
|
- Priority: Default (3) for pending/approved, High (4) for available/error
|
||||||
|
- Format: Event title + book details + user + error (if applicable)
|
||||||
|
- Auth: Optional Bearer token via `accessToken` config field
|
||||||
|
- Server: Configurable `serverUrl` (default: https://ntfy.sh)
|
||||||
|
|
||||||
**Pushover (Plain Text with Emojis):**
|
**Pushover (Plain Text with Emojis):**
|
||||||
- Emojis: 📬 📬 🎉 ❌
|
- Emojis: 📬 📬 🎉 ❌
|
||||||
- Priority: Normal (0) for pending/approved, High (1) for available/error
|
- Priority: Normal (0) for pending/approved, High (1) for available/error
|
||||||
@@ -126,7 +158,7 @@ model NotificationBackend {
|
|||||||
**Modal Features:**
|
**Modal Features:**
|
||||||
- Type-first selection (user clicks "Add Discord" or "Add Pushover")
|
- Type-first selection (user clicks "Add Discord" or "Add Pushover")
|
||||||
- Password inputs for sensitive values
|
- Password inputs for sensitive values
|
||||||
- Event subscription checkboxes (4 events, default: available + error)
|
- Event subscription checkboxes (5 events, default: available + error)
|
||||||
- Test button (sends synchronous test notification)
|
- Test button (sends synchronous test notification)
|
||||||
- Save button (validates and creates/updates backend)
|
- Save button (validates and creates/updates backend)
|
||||||
|
|
||||||
@@ -144,6 +176,7 @@ model NotificationBackend {
|
|||||||
author: string,
|
author: string,
|
||||||
userName: string,
|
userName: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
|
requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -152,28 +185,67 @@ model NotificationBackend {
|
|||||||
- Calls NotificationService.sendNotification()
|
- Calls NotificationService.sendNotification()
|
||||||
- Non-blocking error handling (logs but doesn't throw)
|
- Non-blocking error handling (logs but doesn't throw)
|
||||||
|
|
||||||
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
|
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?, requestType?)`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Provider Pattern:** `INotificationProvider` interface + registry (matches `IAuthProvider` pattern)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/services/notification/
|
||||||
|
INotificationProvider.ts # Interface + shared types
|
||||||
|
notification.service.ts # Core service with registry
|
||||||
|
index.ts # Re-exports
|
||||||
|
providers/
|
||||||
|
apprise.provider.ts # Apprise API (100+ services)
|
||||||
|
discord.provider.ts # Discord webhook
|
||||||
|
ntfy.provider.ts # ntfy API
|
||||||
|
pushover.provider.ts # Pushover API
|
||||||
|
```
|
||||||
|
|
||||||
|
**Registry:** Module-level `Map<string, INotificationProvider>` with `registerProvider()` / `getProvider()`
|
||||||
|
|
||||||
|
**INotificationProvider interface:**
|
||||||
|
- `type: string` — provider identifier (registry key)
|
||||||
|
- `sensitiveFields: string[]` — fields needing encryption/masking
|
||||||
|
- `metadata: ProviderMetadata` — self-describing UI/validation metadata
|
||||||
|
- `send(config, payload): Promise<void>` — receives decrypted config
|
||||||
|
|
||||||
|
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
|
||||||
|
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
|
||||||
|
|
||||||
|
**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
|
## Extensibility
|
||||||
|
|
||||||
**Adding New Backend (e.g., Email):**
|
**Adding New Backend (2 steps):**
|
||||||
1. Add 'email' to NotificationBackendType enum
|
1. Create `providers/email.provider.ts` implementing `INotificationProvider`:
|
||||||
2. Create EmailConfig interface
|
- Set `type = 'email'`, `sensitiveFields = ['smtpPassword']`
|
||||||
3. Add encryption logic for smtpPassword
|
- Set `metadata` with displayName, description, iconLabel, iconColor, configFields
|
||||||
4. Implement sendEmail() method in NotificationService
|
- Implement `send()` with email-specific logic
|
||||||
5. Add email card to type selector (green "E" badge)
|
2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts`
|
||||||
6. Add email form fields to modal
|
|
||||||
|
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
|
||||||
|
|
||||||
**Adding New Event (e.g., download_complete):**
|
**Adding New Event (e.g., download_complete):**
|
||||||
1. Add 'download_complete' to NotificationEvent enum
|
1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
|
||||||
2. Add to event labels in UI
|
2. Optionally add `titleByRequestType` for type-specific titles
|
||||||
3. Add trigger point in processor
|
3. Add trigger point in processor, passing `requestType` if relevant
|
||||||
4. Add message formatting in Discord/Pushover formatters
|
4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- Bull (job queue)
|
- Bull (job queue)
|
||||||
- Node.js crypto (AES-256-GCM encryption)
|
- Node.js crypto (AES-256-GCM encryption)
|
||||||
- Discord webhooks, Pushover API
|
- Apprise API, Discord webhooks, ntfy API, Pushover API
|
||||||
- React (UI), Tailwind CSS (styling)
|
- React (UI), Tailwind CSS (styling)
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -200,32 +200,23 @@ export async function POST(req: NextRequest) {
|
|||||||
.map((m: any) => ({ id: m.id, name: m.id }));
|
.map((m: any) => ({ id: m.id, name: m.id }));
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
// Claude: Hardcoded list (Anthropic doesn't have a models API endpoint)
|
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||||
models = [
|
const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
|
||||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
|
||||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
|
||||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
|
||||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Test connection with a simple API call
|
|
||||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': apiKey,
|
'x-api-key': apiKey,
|
||||||
'anthropic-version': '2023-06-01',
|
'anthropic-version': '2023-06-01',
|
||||||
'content-type': 'application/json'
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'claude-3-5-haiku-20241022',
|
|
||||||
max_tokens: 10,
|
|
||||||
messages: [{ role: 'user', content: 'Hi' }]
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
models = data.data.map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.display_name || m.id,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
|
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
|
||||||
|
|
||||||
## Key Details
|
## Key Details
|
||||||
- **AI Providers:** OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku)
|
- **AI Providers:** OpenAI (GPT-4+), Claude (dynamically fetched from Anthropic Models API)
|
||||||
- **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
|
- **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
|
||||||
- **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
|
- **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
|
||||||
- **Library Scopes (per-user):**
|
- **Library Scopes (per-user):**
|
||||||
|
|||||||
@@ -26,11 +26,19 @@ Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallbac
|
|||||||
Configurable Audible region for accurate metadata matching across different international Audible stores.
|
Configurable Audible region for accurate metadata matching across different international Audible stores.
|
||||||
|
|
||||||
**Supported Regions:**
|
**Supported Regions:**
|
||||||
- United States (`us`) - `audible.com` (default)
|
- United States (`us`) - `audible.com` (default, English)
|
||||||
- Canada (`ca`) - `audible.ca`
|
- Canada (`ca`) - `audible.ca` (English)
|
||||||
- United Kingdom (`uk`) - `audible.co.uk`
|
- United Kingdom (`uk`) - `audible.co.uk` (English)
|
||||||
- Australia (`au`) - `audible.com.au`
|
- Australia (`au`) - `audible.com.au` (English)
|
||||||
- India (`in`) - `audible.in`
|
- India (`in`) - `audible.in` (English)
|
||||||
|
- Germany (`de`) - `audible.de` (non-English)
|
||||||
|
- Spain (`es`) - `audible.es` (non-English)
|
||||||
|
|
||||||
|
**`isEnglish` Flag:**
|
||||||
|
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||||
|
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
|
||||||
|
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
|
||||||
|
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *")
|
||||||
|
|
||||||
**Why Regions Matter:**
|
**Why Regions Matter:**
|
||||||
- Each Audible region uses different ASINs for the same audiobook
|
- Each Audible region uses different ASINs for the same audiobook
|
||||||
@@ -48,7 +56,7 @@ Configurable Audible region for accurate metadata matching across different inte
|
|||||||
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
|
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
|
||||||
- Audnexus API calls include region parameter: `?region={code}`
|
- Audnexus API calls include region parameter: `?region={code}`
|
||||||
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
|
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
|
||||||
- **Locale enforcement:** Cookie `lc-acbus=en_US` + `handleLocaleRedirect()` detects non-English culture codes in response URLs and re-requests using the English URL from Audible's locale picker
|
- **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
|
||||||
- Configuration service helper: `getAudibleRegion()` returns configured region
|
- Configuration service helper: `getAudibleRegion()` returns configured region
|
||||||
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
|
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
|
||||||
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
|
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
|
||||||
@@ -228,12 +236,8 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
|||||||
- **Affects:** All Audiobookshelf metadata matching operations
|
- **Affects:** All Audiobookshelf metadata matching operations
|
||||||
|
|
||||||
**Non-English locale pages served to users outside US (2026-02-05)**
|
**Non-English locale pages served to users outside US (2026-02-05)**
|
||||||
- **Problem:** Audible uses IP geolocation to add culture codes (e.g., `es_US`, `fr_CA`) to URLs, serving locale-specific pages. `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale redirects within the same region.
|
- **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes.
|
||||||
- **Impact:** Users self-hosting from non-English-speaking countries (e.g., Dominican Republic) got Spanish bestsellers/new releases on their homepage because the `audible_refresh` job scraped locale-redirected pages.
|
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
|
||||||
- **Fix:** Three-layer defense in `AudibleService`:
|
- **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available.
|
||||||
1. **Cookie:** `lc-acbus=en_US` header hints English locale preference
|
- **Location:** `src/lib/integrations/audible.service.ts` — `initialize()` (axios default params)
|
||||||
2. **Locale picker detection (primary):** After every request, checks response URL for non-`en_*` culture codes (`xx_YY` pattern). If found, parses page HTML for Audible's `<adbl-toggle-chip>` locale picker, extracts the English option's `data-value` URL, and re-requests. Data-driven — uses Audible's own English URL rather than guessing.
|
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
|
||||||
3. **Fallback URL rewrite:** If no locale picker found, strips the culture code from the path and adds `language=en_US` query param (mirrors picker pattern).
|
|
||||||
- **Verification:** After correction, validates the response URL no longer contains a non-English culture code and logs success/failure.
|
|
||||||
- **Location:** `src/lib/integrations/audible.service.ts` — `handleLocaleRedirect()`, `initialize()`
|
|
||||||
- **Affects:** All Audible scraping: popular, new releases, search, detail pages (via `fetchWithRetry`)
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Request → search_indexers → rank_results → download_torrent
|
|||||||
|
|
||||||
1. **search_indexers** - Search Prowlarr for torrents
|
1. **search_indexers** - Search Prowlarr for torrents
|
||||||
2. **rank_results** - Apply ranking algorithm, select best
|
2. **rank_results** - Apply ranking algorithm, select best
|
||||||
3. **download_torrent** - Add to qBittorrent
|
3. **download_torrent** - Add to download client (qBittorrent/Transmission/SABnzbd)
|
||||||
4. **monitor_download** - Poll progress (10s intervals)
|
4. **monitor_download** - Poll progress (10s intervals)
|
||||||
5. **process_audiobook** - Organize files to media directory
|
5. **process_audiobook** - Organize files to media directory
|
||||||
6. **update_plex** - Trigger scan, fuzzy match
|
6. **update_plex** - Trigger scan, fuzzy match
|
||||||
@@ -23,7 +23,7 @@ Request → search_indexers → rank_results → download_torrent
|
|||||||
## Integration Points
|
## Integration Points
|
||||||
|
|
||||||
**Indexers:** Prowlarr (primary), Jackett (fallback)
|
**Indexers:** Prowlarr (primary), Jackett (fallback)
|
||||||
**Download Clients:** qBittorrent (primary), Transmission (fallback)
|
**Download Clients:** qBittorrent or Transmission (torrent), SABnzbd (usenet) — [details](./download-clients.md)
|
||||||
**Media Server:** Plex (scan + match)
|
**Media Server:** Plex (scan + match)
|
||||||
|
|
||||||
## Job Queue (Bull)
|
## Job Queue (Bull)
|
||||||
@@ -43,7 +43,9 @@ Request → search_indexers → rank_results → download_torrent
|
|||||||
## Related Docs
|
## Related Docs
|
||||||
|
|
||||||
- [Prowlarr](./prowlarr.md)
|
- [Prowlarr](./prowlarr.md)
|
||||||
|
- [Download Clients](./download-clients.md) - Multi-client management, protocol routing
|
||||||
- [qBittorrent](./qbittorrent.md)
|
- [qBittorrent](./qbittorrent.md)
|
||||||
|
- [SABnzbd](./sabnzbd.md)
|
||||||
- [Ranking Algorithm](./ranking-algorithm.md)
|
- [Ranking Algorithm](./ranking-algorithm.md)
|
||||||
- [File Organization](./file-organization.md)
|
- [File Organization](./file-organization.md)
|
||||||
- [Plex Integration](../integrations/plex.md)
|
- [Plex Integration](../integrations/plex.md)
|
||||||
|
|||||||
@@ -1,45 +1,127 @@
|
|||||||
# Multi-Download-Client Support
|
# Multi-Download-Client Support
|
||||||
|
|
||||||
**Status:** ✅ Implemented | Simultaneous qBittorrent + SABnzbd support
|
**Status:** ✅ Implemented | qBittorrent, Transmission, SABnzbd, and NZBGet support
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Users can configure both qBittorrent (torrents) and SABnzbd (Usenet) simultaneously. System selects best release across all indexer types regardless of protocol.
|
Users can configure one torrent client (qBittorrent or Transmission) and one usenet client (SABnzbd or NZBGet) simultaneously. System selects best release across all indexer types regardless of protocol.
|
||||||
|
|
||||||
**Constraint:** 1 client per type (torrent/usenet) for now; architecture supports future expansion.
|
**Constraint:** 1 client per protocol (torrent/usenet). Users must remove an existing torrent client before adding a different one.
|
||||||
|
|
||||||
## Key Details
|
## Key Details
|
||||||
|
|
||||||
|
### Supported Clients
|
||||||
|
|
||||||
|
| Client | Protocol | Auth | Categories |
|
||||||
|
|--------|----------|------|------------|
|
||||||
|
| qBittorrent | torrent | Cookie-based (login endpoint) | Categories |
|
||||||
|
| Transmission | torrent | HTTP Basic Auth + CSRF (`X-Transmission-Session-Id`) | Labels |
|
||||||
|
| SABnzbd | usenet | API key | Categories |
|
||||||
|
| NZBGet | usenet | HTTP Basic Auth (JSON-RPC) | Config-based categories |
|
||||||
|
|
||||||
|
### Protocol Map
|
||||||
|
**File:** `src/lib/interfaces/download-client.interface.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
|
||||||
|
qbittorrent: 'torrent',
|
||||||
|
sabnzbd: 'usenet',
|
||||||
|
nzbget: 'usenet',
|
||||||
|
transmission: 'torrent',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by manager's `getClientForProtocol()` and UI's protocol-level enforcement.
|
||||||
|
|
||||||
### Configuration Structure
|
### Configuration Structure
|
||||||
**Key:** `download_clients` (JSON array, replaces legacy flat keys)
|
**Key:** `download_clients` (JSON array, replaces legacy flat keys)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface DownloadClientConfig {
|
interface DownloadClientConfig {
|
||||||
id: string; // UUID
|
id: string; // UUID
|
||||||
type: 'qbittorrent' | 'sabnzbd';
|
type: 'qbittorrent' | 'sabnzbd' | 'nzbget' | 'transmission';
|
||||||
name: string; // User-friendly name
|
name: string; // User-friendly name
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
username?: string; // qBittorrent only
|
username?: string; // qBittorrent/Transmission/NZBGet only
|
||||||
password: string; // Password or API key
|
password: string; // Password or API key
|
||||||
disableSSLVerify: boolean;
|
disableSSLVerify: boolean;
|
||||||
remotePathMappingEnabled: boolean;
|
remotePathMappingEnabled: boolean;
|
||||||
remotePath?: string;
|
remotePath?: string;
|
||||||
localPath?: string;
|
localPath?: string;
|
||||||
category?: string; // Default: 'readmeabook'
|
category?: string; // Default: 'readmeabook'
|
||||||
|
customPath?: string; // Relative sub-path appended to download_dir
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Transmission Service
|
||||||
|
**File:** `src/lib/integrations/transmission.service.ts`
|
||||||
|
|
||||||
|
- **RPC endpoint:** `POST /transmission/rpc` (JSON-RPC)
|
||||||
|
- **CSRF:** 409 → capture `X-Transmission-Session-Id` header → retry
|
||||||
|
- **Auth:** HTTP Basic Auth (optional)
|
||||||
|
- **Categories:** Uses `labels` array on `torrent-add`
|
||||||
|
- **Download path:** `download-dir` argument on `torrent-add`
|
||||||
|
- **Torrent files:** Base64-encoded via `metainfo` field
|
||||||
|
- **Status codes:** 0=stopped→paused, 1=check-pending→checking, 2=checking→checking, 3=download-pending→queued, 4=downloading→downloading, 5=seed-pending→seeding, 6=seeding→seeding
|
||||||
|
- **Error handling:** `error > 0` → failed status
|
||||||
|
- **postProcess():** No-op (same as qBittorrent)
|
||||||
|
|
||||||
|
### NZBGet Service
|
||||||
|
**File:** `src/lib/integrations/nzbget.service.ts`
|
||||||
|
|
||||||
|
- **RPC endpoint:** `POST /jsonrpc` (JSON-RPC with Basic Auth)
|
||||||
|
- **Auth:** HTTP Basic Auth (username + password)
|
||||||
|
- **Categories:** Config-based (`Category1.Name`, `Category1.DestDir`), managed via `config()` + `saveconfig()`
|
||||||
|
- **Adding NZBs:** Downloads NZB content from Prowlarr, base64-encodes, uploads via `append()`
|
||||||
|
- **Queue status:** `listgroups(0)` — QUEUED, PAUSED, DOWNLOADING, FETCHING, PP_* (processing states)
|
||||||
|
- **History status:** `history(false)` — SUCCESS/*, WARNING/* → completed; FAILURE/*, DELETED/* → failed
|
||||||
|
- **Pause/Resume/Delete:** `editqueue()` with GroupPause/GroupResume/GroupDelete/HistoryDelete commands
|
||||||
|
- **postProcess():** `editqueue('HistoryDelete')` — archives from visible history (preserves in hidden archive)
|
||||||
|
- **IDs:** Integer NZBIDs (stored as strings in RMAB system)
|
||||||
|
|
||||||
|
### Per-Client Custom Download Path
|
||||||
|
**Field:** `customPath` (optional string, blank = use base `download_dir` as-is)
|
||||||
|
|
||||||
|
Allows each download client to download to a different subdirectory under `download_dir`. Useful for separating torrent and usenet downloads.
|
||||||
|
|
||||||
|
**Path Resolution (in `createService()`):**
|
||||||
|
```
|
||||||
|
finalPath = config.customPath ? path.join(downloadDir, config.customPath) : downloadDir
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- `download_dir` = `/downloads`, qBittorrent `customPath` = `torrents` → `/downloads/torrents`
|
||||||
|
- `download_dir` = `/downloads`, SABnzbd `customPath` = `usenet` → `/downloads/usenet`
|
||||||
|
- `download_dir` = `/downloads`, `customPath` = blank → `/downloads`
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- Leading/trailing slashes stripped on save
|
||||||
|
- Paths containing `..` rejected (frontend + API)
|
||||||
|
- Backward-compatible: existing configs without `customPath` default to base `download_dir`
|
||||||
|
|
||||||
|
**Resolved path used by:**
|
||||||
|
- Service constructors (`defaultSavePath` / `defaultDownloadDir`)
|
||||||
|
- Category creation (qBittorrent `ensureCategory`, SABnzbd `ensureCategory`)
|
||||||
|
- Torrent/NZB addition (save path / download-dir)
|
||||||
|
- Remote path mapping (applied after customPath resolution)
|
||||||
|
- Singleton getters (`getQBittorrentService`, `getSABnzbdService`)
|
||||||
|
- Retry fallback path construction (`retry-failed-imports.processor.ts`)
|
||||||
|
|
||||||
|
**UI:** Modal shows real-time path preview: `Downloads to: /downloads/torrents`
|
||||||
|
|
||||||
### Download Client Manager Service
|
### Download Client Manager Service
|
||||||
**File:** `src/lib/services/download-client-manager.service.ts`
|
**File:** `src/lib/services/download-client-manager.service.ts`
|
||||||
|
|
||||||
**Methods:**
|
**Methods:**
|
||||||
- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol
|
- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol (uses `CLIENT_PROTOCOL_MAP`)
|
||||||
- `hasClientForProtocol(protocol)` - Check if protocol configured
|
- `hasClientForProtocol(protocol)` - Check if protocol configured
|
||||||
- `getAllClients()` - List all configs
|
- `getAllClients()` - List all configs
|
||||||
- `testConnection(config)` - Test specific config
|
- `testConnection(config)` - Test specific config
|
||||||
- `invalidate()` - Clear cache on config change
|
- `invalidate()` - Clear cache on config change
|
||||||
- `getClientServiceForProtocol(protocol)` - Get instantiated service
|
- `getClientServiceForProtocol(protocol)` - Get instantiated service
|
||||||
|
|
||||||
|
**Factory Cases:** `qbittorrent` → `QBittorrentService`, `sabnzbd` → `SABnzbdService`, `nzbget` → `NZBGetService`, `transmission` → `TransmissionService`
|
||||||
|
|
||||||
**Singleton Pattern:** Uses caching with invalidation on config changes.
|
**Singleton Pattern:** Uses caching with invalidation on config changes.
|
||||||
|
|
||||||
### Protocol Filtering
|
### Protocol Filtering
|
||||||
@@ -57,7 +139,7 @@ interface DownloadClientConfig {
|
|||||||
**Logic:**
|
**Logic:**
|
||||||
1. Detect protocol from result (`ProwlarrService.isNZBResult()`)
|
1. Detect protocol from result (`ProwlarrService.isNZBResult()`)
|
||||||
2. Get appropriate client via manager (`getClientForProtocol()`)
|
2. Get appropriate client via manager (`getClientForProtocol()`)
|
||||||
3. Route to qBittorrent or SABnzbd service
|
3. Route to correct service (qBittorrent, Transmission, or SABnzbd)
|
||||||
4. Create download history record
|
4. Create download history record
|
||||||
|
|
||||||
### Migration
|
### Migration
|
||||||
@@ -76,7 +158,7 @@ interface DownloadClientConfig {
|
|||||||
**POST /api/admin/settings/download-clients/test** - Test connection
|
**POST /api/admin/settings/download-clients/test** - Test connection
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
- Only 1 client per type allowed (enforced on add)
|
- Only 1 client per protocol allowed (enforced on add via `CLIENT_PROTOCOL_MAP`)
|
||||||
- Test connection required before save
|
- Test connection required before save
|
||||||
- Password masking in responses (`********`)
|
- Password masking in responses (`********`)
|
||||||
|
|
||||||
@@ -86,14 +168,18 @@ interface DownloadClientConfig {
|
|||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `DownloadClientManagement.tsx` | Container with add buttons + configured cards |
|
| `DownloadClientManagement.tsx` | Container with add cards (4-column: qBittorrent, Transmission, SABnzbd, NZBGet) + configured cards; protocol-level enforcement (grayed out when protocol taken) |
|
||||||
| `DownloadClientCard.tsx` | Card with name, type badge, edit/delete |
|
| `DownloadClientCard.tsx` | Card with name, type badge (blue=qBittorrent, green=Transmission, purple=SABnzbd, orange=NZBGet), custom path display, edit/delete |
|
||||||
| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields |
|
| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields; Username shown for qBittorrent + Transmission + NZBGet; URL placeholder per-type |
|
||||||
|
|
||||||
**UI Flow:**
|
**UI Flow:**
|
||||||
1. **Add Client Section:** Two cards (qBittorrent, SABnzbd) with "Add" button or "Already configured" badge
|
1. **Add Client Section:** Four cards (qBittorrent, Transmission, SABnzbd, NZBGet) with "Add" button or "Protocol already configured" when protocol is taken (card grayed out with `opacity-50`)
|
||||||
2. **Configured Clients:** Grid of cards showing name, type, URL, status
|
2. **Configured Clients:** Grid of cards showing name, type, URL, custom path (if set), status
|
||||||
3. **Modal:** Type-specific fields, SSL toggle, path mapping, test connection
|
3. **Modal:** Type-specific fields, custom download path with live preview, SSL toggle, path mapping, test connection
|
||||||
|
|
||||||
|
**downloadDir Prop Flow:**
|
||||||
|
- **Settings mode:** `DownloadClientManagement` fetches from `GET /api/admin/settings` → `settings.paths.downloadDir` on mount
|
||||||
|
- **Wizard mode:** `setup/page.tsx` passes `state.downloadDir` → `DownloadClientStep` → `DownloadClientManagement` → `DownloadClientModal`
|
||||||
|
|
||||||
## Integration Points
|
## Integration Points
|
||||||
|
|
||||||
@@ -107,6 +193,8 @@ Replaced legacy form with `<DownloadClientManagement mode="settings" />`
|
|||||||
|
|
||||||
Replaced single-client form with `<DownloadClientManagement mode="wizard" />`
|
Replaced single-client form with `<DownloadClientManagement mode="wizard" />`
|
||||||
|
|
||||||
|
**Props:** Accepts `downloadDir` from setup page state, passes to management component
|
||||||
|
|
||||||
**Validation:** At least 1 enabled client required to proceed
|
**Validation:** At least 1 enabled client required to proceed
|
||||||
|
|
||||||
### Setup Complete API
|
### Setup Complete API
|
||||||
@@ -123,25 +211,38 @@ Accepts both legacy single client and new array format:
|
|||||||
**Client disabled:** Results for that protocol filtered out
|
**Client disabled:** Results for that protocol filtered out
|
||||||
**Connection failure:** Per-download error handling (existing)
|
**Connection failure:** Per-download error handling (existing)
|
||||||
**Mixed results:** Best release selected regardless of protocol when both clients configured
|
**Mixed results:** Best release selected regardless of protocol when both clients configured
|
||||||
|
**Custom path blank:** Uses base `download_dir` (backward-compatible default)
|
||||||
|
**Custom path with slashes:** Leading/trailing slashes stripped automatically
|
||||||
|
**Custom path with `..`:** Rejected by frontend validation and API validation
|
||||||
|
**Switching torrent clients:** Must delete existing torrent client before adding Transmission (or vice versa)
|
||||||
|
|
||||||
## Verification Steps
|
## Verification Steps
|
||||||
|
|
||||||
1. **Migration:** Existing single-client users see config as card after update
|
1. **Migration:** Existing single-client users see config as card after update
|
||||||
2. **Single client:** Configure only qBittorrent → only torrent results shown
|
2. **Single client:** Configure only qBittorrent → only torrent results shown
|
||||||
3. **Both clients:** Configure both → mixed results, best selected across protocols
|
3. **Both clients:** Configure torrent + usenet → mixed results, best selected across protocols
|
||||||
4. **Download routing:** Torrent result → qBittorrent; NZB result → SABnzbd
|
4. **Download routing:** Torrent result → torrent client; NZB result → usenet client (SABnzbd or NZBGet)
|
||||||
5. **Wizard:** Must add at least one client to proceed
|
5. **Wizard:** Must add at least one client to proceed
|
||||||
6. **Settings:** Can add/edit/delete/test clients; changes persist
|
6. **Settings:** Can add/edit/delete/test clients; changes persist
|
||||||
|
7. **Custom path:** Set `torrents` on torrent client → save path includes subdirectory
|
||||||
|
8. **Custom path preview:** Modal shows resolved path in real-time as user types
|
||||||
|
9. **Custom path persistence:** Save, reopen modal → value persists
|
||||||
|
10. **Custom path on card:** Configured cards show custom path if set
|
||||||
|
11. **Transmission CSRF:** First RPC call gets 409, captures session ID, retry succeeds
|
||||||
|
12. **Protocol enforcement:** Adding qBittorrent grays out Transmission card (and vice versa)
|
||||||
|
|
||||||
## Critical Files
|
## Critical Files
|
||||||
|
|
||||||
| File | Changes |
|
| File | Changes |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `src/lib/services/download-client-manager.service.ts` | **NEW** - Core multi-client service |
|
| `src/lib/interfaces/download-client.interface.ts` | Client types, display names, `CLIENT_PROTOCOL_MAP` |
|
||||||
|
| `src/lib/integrations/nzbget.service.ts` | NZBGet JSON-RPC implementation |
|
||||||
|
| `src/lib/integrations/transmission.service.ts` | Transmission RPC implementation |
|
||||||
|
| `src/lib/services/download-client-manager.service.ts` | Core multi-client service, protocol-based routing |
|
||||||
| `src/lib/integrations/prowlarr.service.ts:379` | Protocol filtering logic (both clients = all results) |
|
| `src/lib/integrations/prowlarr.service.ts:379` | Protocol filtering logic (both clients = all results) |
|
||||||
| `src/lib/processors/download-torrent.processor.ts:44` | Download routing (detect protocol → route) |
|
| `src/lib/processors/download-torrent.processor.ts:44` | Download routing (detect protocol → route) |
|
||||||
| `src/app/api/admin/settings/download-clients/*` | **NEW** - CRUD API routes |
|
| `src/app/api/admin/settings/download-clients/*` | CRUD API routes, protocol-level duplicate check |
|
||||||
| `src/components/admin/download-clients/*` | **NEW** - UI components (card-based) |
|
| `src/components/admin/download-clients/*` | UI components (3-column card layout, protocol enforcement) |
|
||||||
| `src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx` | Replaced with management component |
|
| `src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx` | Replaced with management component |
|
||||||
| `src/app/setup/steps/DownloadClientStep.tsx` | Replaced with management component |
|
| `src/app/setup/steps/DownloadClientStep.tsx` | Replaced with management component |
|
||||||
| `src/app/api/setup/complete/route.ts` | Save as JSON array, support legacy |
|
| `src/app/api/setup/complete/route.ts` | Save as JSON array, support legacy |
|
||||||
@@ -149,5 +250,5 @@ Accepts both legacy single client and new array format:
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [qBittorrent Integration](./qbittorrent.md) - Torrent client details
|
- [qBittorrent Integration](./qbittorrent.md) - Torrent client details
|
||||||
- [SABnzbd Integration](./sabnzbd.md) - Usenet client details
|
- [SABnzbd Integration](./sabnzbd.md) - Usenet client details (SABnzbd)
|
||||||
- [Prowlarr Integration](./prowlarr.md) - Indexer search
|
- [Prowlarr Integration](./prowlarr.md) - Indexer search
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
|
|||||||
## Process
|
## Process
|
||||||
|
|
||||||
1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file)
|
1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file)
|
||||||
2. Identify audiobook files (.m4b, .m4a, .mp3) - supports both directories and single files
|
1b. **Path stored** in `DownloadHistory.downloadPath` (mapped local path) for retry reliability — avoids reconstructing path from `torrentName` which may differ from actual folder name
|
||||||
|
2. Identify audiobook files (.m4b, .m4a, .mp3, .mp4, .aa, .aax, .flac, .ogg) - supports both directories and single files
|
||||||
3. Read media directory and path template from database config (`media_dir`, `audiobook_path_template`)
|
3. Read media directory and path template from database config (`media_dir`, `audiobook_path_template`)
|
||||||
4. Apply template to create target path: `[media_dir]/[template result]/`
|
4. Apply template to create target path: `[media_dir]/[template result]/`
|
||||||
5. **Copy** files (not move - originals stay for seeding)
|
5. **Copy** files (not move - originals stay for seeding)
|
||||||
@@ -94,6 +95,7 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
|
|||||||
**Supported Formats:**
|
**Supported Formats:**
|
||||||
- m4b, m4a, mp4 (AAC audiobooks)
|
- m4b, m4a, mp4 (AAC audiobooks)
|
||||||
- mp3 (ID3v2 tags)
|
- mp3 (ID3v2 tags)
|
||||||
|
- flac (Vorbis comment tags)
|
||||||
|
|
||||||
**Metadata Written:**
|
**Metadata Written:**
|
||||||
- `title` - Book title
|
- `title` - Book title
|
||||||
@@ -206,7 +208,7 @@ async function organize(
|
|||||||
|
|
||||||
## Fixed Issues ✅
|
## Fixed Issues ✅
|
||||||
|
|
||||||
**1. EPERM errors** - Fixed with `fs.readFile/writeFile` instead of `copyFile`
|
**1. EPERM errors** - Fixed with stream-based copy (`pipeline` + `createReadStream`/`createWriteStream`) instead of `fs.copyFile()` which uses `copy_file_range()` — a syscall that returns EPERM on cross-export NFS4 and some FUSE mounts
|
||||||
**2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
|
**2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
|
||||||
**3. Files moved not copied** - Now copies to support seeding
|
**3. Files moved not copied** - Now copies to support seeding
|
||||||
**4. Single file downloads** - Now supports files directly in downloads folder (not just directories)
|
**4. Single file downloads** - Now supports files directly in downloads folder (not just directories)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Free, open-source BitTorrent client with comprehensive Web API.
|
|||||||
- `download_client_url` - qBittorrent Web UI URL (supports HTTP and HTTPS)
|
- `download_client_url` - qBittorrent Web UI URL (supports HTTP and HTTPS)
|
||||||
- `download_client_username` - qBittorrent username
|
- `download_client_username` - qBittorrent username
|
||||||
- `download_client_password` - qBittorrent password
|
- `download_client_password` - qBittorrent password
|
||||||
- `download_dir` - Download save path (passed to qBittorrent for all torrents)
|
- `download_dir` - Base download save path (joined with per-client `customPath` if configured)
|
||||||
|
|
||||||
**Optional (SSL/TLS):**
|
**Optional (SSL/TLS):**
|
||||||
- `download_client_disable_ssl_verify` - Disable SSL certificate verification for HTTPS (boolean as string "true"/"false", default: "false")
|
- `download_client_disable_ssl_verify` - Disable SSL certificate verification for HTTPS (boolean as string "true"/"false", default: "false")
|
||||||
@@ -65,7 +65,8 @@ Validation: All required fields checked before service initialization. Path mapp
|
|||||||
Service uses singleton pattern for performance. When settings change (via admin settings page), singleton is invalidated to force reload:
|
Service uses singleton pattern for performance. When settings change (via admin settings page), singleton is invalidated to force reload:
|
||||||
- `invalidateQBittorrentService()` called after updating paths or download client settings
|
- `invalidateQBittorrentService()` called after updating paths or download client settings
|
||||||
- Forces service to re-read database config on next torrent addition
|
- Forces service to re-read database config on next torrent addition
|
||||||
- Ensures category save path and credentials are always current
|
- Ensures category save path, credentials, and `customPath` resolution are always current
|
||||||
|
- Singleton getter resolves `customPath` from client config (consistent with manager's `createService()`)
|
||||||
|
|
||||||
## Category Management
|
## Category Management
|
||||||
|
|
||||||
@@ -73,9 +74,10 @@ Service uses singleton pattern for performance. When settings change (via admin
|
|||||||
|
|
||||||
**Save Path Synchronization:**
|
**Save Path Synchronization:**
|
||||||
- Category created/updated on every torrent addition
|
- Category created/updated on every torrent addition
|
||||||
- Category save path always synced with `download_dir` config
|
- Category save path synced with resolved download path (`download_dir` + per-client `customPath`)
|
||||||
- Handles config changes: if user changes `download_dir`, category updates automatically
|
- Handles config changes: if user changes `download_dir` or `customPath`, category updates automatically
|
||||||
- Uses both `createCategory` and `editCategory` APIs for reliability
|
- Uses both `createCategory` and `editCategory` APIs for reliability
|
||||||
|
- Remote path mapping applied after `customPath` resolution (outgoing: local → remote)
|
||||||
|
|
||||||
**Why Both Create and Edit:**
|
**Why Both Create and Edit:**
|
||||||
1. Create: Ensures category exists (idempotent, won't fail if exists)
|
1. Create: Ensures category exists (idempotent, won't fail if exists)
|
||||||
@@ -83,6 +85,11 @@ Service uses singleton pattern for performance. When settings change (via admin
|
|||||||
|
|
||||||
This prevents issues where category retains old save path after user changes `download_dir` setting.
|
This prevents issues where category retains old save path after user changes `download_dir` setting.
|
||||||
|
|
||||||
|
**Per-Client Custom Path:**
|
||||||
|
- If `customPath` is set (e.g., `torrents`), category save path becomes `/downloads/torrents`
|
||||||
|
- Remote path mapping applies to the resolved path: `reverseTransform(/downloads/torrents)` → remote equivalent
|
||||||
|
- See [download-clients.md](./download-clients.md#per-client-custom-download-path) for details
|
||||||
|
|
||||||
## Remote Path Mapping
|
## Remote Path Mapping
|
||||||
|
|
||||||
**Use Case:** qBittorrent runs on different machine/container with different filesystem perspective.
|
**Use Case:** qBittorrent runs on different machine/container with different filesystem perspective.
|
||||||
@@ -167,8 +174,22 @@ interface TorrentInfo {
|
|||||||
completionDate: number;
|
completionDate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
|
type TorrentState =
|
||||||
'pausedDL' | 'queuedDL' | 'checkingDL' | 'error' | 'missingFiles';
|
// Core states (*DL = download phase, *UP = upload/post-download phase)
|
||||||
|
| 'downloading' | 'uploading'
|
||||||
|
| 'stalledDL' | 'stalledUP' // stalledUP → completed (download done)
|
||||||
|
| 'pausedDL' | 'pausedUP' // pausedUP → completed (download done, paused seeding)
|
||||||
|
| 'queuedDL' | 'queuedUP' // queuedUP → completed (download done)
|
||||||
|
| 'checkingDL' | 'checkingUP' // checkingUP → completed (download done, rechecking)
|
||||||
|
| 'error' | 'missingFiles' | 'allocating'
|
||||||
|
// Forced states (user clicked "Force Resume")
|
||||||
|
| 'forcedDL' | 'forcedUP' // forcedUP → completed (download done)
|
||||||
|
// Metadata fetching
|
||||||
|
| 'metaDL' | 'forcedMetaDL'
|
||||||
|
// qBittorrent v5.0+ (renamed paused → stopped)
|
||||||
|
| 'stoppedDL' | 'stoppedUP' // stoppedUP → completed (download done)
|
||||||
|
// Other
|
||||||
|
| 'checkingResumeData' | 'moving';
|
||||||
```
|
```
|
||||||
|
|
||||||
## Fixed Issues ✅
|
## Fixed Issues ✅
|
||||||
@@ -216,6 +237,18 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
|
|||||||
- Service constructor accepts `PathMappingConfig` parameter
|
- Service constructor accepts `PathMappingConfig` parameter
|
||||||
- Singleton loads path mapping config from database
|
- Singleton loads path mapping config from database
|
||||||
|
|
||||||
|
**15. Missing qBittorrent torrent states** - Monitor never detected completion for force-resumed torrents (`forcedDL`/`forcedUP`), causing infinite polling at 100%. Also missing metadata states (`metaDL`/`forcedMetaDL`), qBittorrent v5.x renamed states (`stoppedDL`/`stoppedUP`), and utility states (`checkingResumeData`/`moving`). Fixed by:
|
||||||
|
- Adding all 8 missing states to `TorrentState` type union
|
||||||
|
- Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface)
|
||||||
|
- `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:
|
||||||
|
- `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
|
||||||
|
- Key insight: any `*UP` state is post-download; any `*DL` state is pre-completion
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- axios (HTTP + cookie mgmt)
|
- axios (HTTP + cookie mgmt)
|
||||||
|
|||||||
@@ -135,12 +135,13 @@ Evaluates and scores torrents to automatically select best audiobook download.
|
|||||||
- Proportional credit: If 2 of 3 authors match → 10 pts (2/3 × 15)
|
- Proportional credit: If 2 of 3 authors match → 10 pts (2/3 × 15)
|
||||||
- Full credit: If all authors match → 15 pts
|
- Full credit: If all authors match → 15 pts
|
||||||
|
|
||||||
**2. Format Quality (25 pts max)**
|
**2. Format Quality (10 pts max)**
|
||||||
- M4B with chapters: 25
|
- M4B with chapters: 10
|
||||||
- M4B without chapters: 22
|
- M4B without chapters: 9
|
||||||
- M4A: 16
|
- FLAC: 7 (lossless audio)
|
||||||
- MP3: 10
|
- M4A: 6
|
||||||
- Other: 3
|
- MP3: 4
|
||||||
|
- Other: 1
|
||||||
|
|
||||||
**3. Seeder Count (15 pts max)**
|
**3. Seeder Count (15 pts max)**
|
||||||
- Formula: `Math.min(15, Math.log10(seeders + 1) * 6)`
|
- Formula: `Math.min(15, Math.log10(seeders + 1) * 6)`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr
|
|||||||
**Format:** All requests use `output=json` for JSON responses
|
**Format:** All requests use `output=json` for JSON responses
|
||||||
|
|
||||||
**GET /api?mode=version&output=json&apikey={key}** - Get SABnzbd version
|
**GET /api?mode=version&output=json&apikey={key}** - Get SABnzbd version
|
||||||
**GET /api?mode=addurl&name={url}&cat={category}&output=json&apikey={key}** - Add NZB by URL
|
**POST /api (multipart: mode=addfile, nzbfile={binary})** - Add NZB by file upload (RMAB downloads NZB from Prowlarr, uploads to SABnzbd)
|
||||||
**GET /api?mode=queue&output=json&apikey={key}** - Get active downloads
|
**GET /api?mode=queue&output=json&apikey={key}** - Get active downloads
|
||||||
**GET /api?mode=history&limit=100&output=json&apikey={key}** - Get completed/failed downloads
|
**GET /api?mode=history&limit=100&output=json&apikey={key}** - Get completed/failed downloads
|
||||||
**GET /api?mode=pause&value={nzbId}&output=json&apikey={key}** - Pause download
|
**GET /api?mode=pause&value={nzbId}&output=json&apikey={key}** - Pause download
|
||||||
@@ -37,7 +37,7 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr
|
|||||||
- `download_client_type` - Must be 'sabnzbd'
|
- `download_client_type` - Must be 'sabnzbd'
|
||||||
- `download_client_url` - SABnzbd Web UI URL (supports HTTP and HTTPS)
|
- `download_client_url` - SABnzbd Web UI URL (supports HTTP and HTTPS)
|
||||||
- `download_client_password` - API key (reuses password field)
|
- `download_client_password` - API key (reuses password field)
|
||||||
- `download_dir` - Download save path (passed to SABnzbd category)
|
- `download_dir` - Base download save path (joined with per-client `customPath` if configured)
|
||||||
|
|
||||||
**Optional (SSL/TLS):**
|
**Optional (SSL/TLS):**
|
||||||
- `download_client_disable_ssl_verify` - Disable SSL certificate verification (boolean as string "true"/"false", default: "false")
|
- `download_client_disable_ssl_verify` - Disable SSL certificate verification (boolean as string "true"/"false", default: "false")
|
||||||
@@ -58,7 +58,8 @@ Validation: All required fields checked before service initialization. Path mapp
|
|||||||
Service uses singleton pattern. When settings change, singleton invalidated to force reload:
|
Service uses singleton pattern. When settings change, singleton invalidated to force reload:
|
||||||
- `invalidateSABnzbdService()` called after updating settings
|
- `invalidateSABnzbdService()` called after updating settings
|
||||||
- Forces service to re-read database config
|
- Forces service to re-read database config
|
||||||
- Ensures category and credentials are always current
|
- Ensures category, credentials, and `customPath` resolution are always current
|
||||||
|
- Singleton getter resolves `customPath` from client config (consistent with manager's `createService()`)
|
||||||
|
|
||||||
## Category Management
|
## Category Management
|
||||||
|
|
||||||
@@ -67,16 +68,21 @@ Service uses singleton pattern. When settings change, singleton invalidated to f
|
|||||||
**Save Path Synchronization:**
|
**Save Path Synchronization:**
|
||||||
- Category created/updated on every download (matches qBittorrent behavior)
|
- Category created/updated on every download (matches qBittorrent behavior)
|
||||||
- Fetches SABnzbd's `complete_dir` setting via API to understand download location
|
- Fetches SABnzbd's `complete_dir` setting via API to understand download location
|
||||||
- Applies remote path mapping to translate RMAB's `download_dir` to SABnzbd's perspective
|
- Applies remote path mapping to translate RMAB's resolved download path to SABnzbd's perspective
|
||||||
- Calculates optimal category path (relative, absolute, or root)
|
- Calculates optimal category path (relative, absolute, or root)
|
||||||
|
- Resolved path includes per-client `customPath` if configured (e.g., `/downloads/usenet`)
|
||||||
|
|
||||||
**Smart Path Calculation:**
|
**Smart Path Calculation:**
|
||||||
1. Get SABnzbd's `complete_dir` from `misc.complete_dir` config
|
1. Get SABnzbd's `complete_dir` from `misc.complete_dir` config
|
||||||
2. Apply `PathMapper.reverseTransform()` to RMAB's `download_dir`
|
2. Apply `PathMapper.reverseTransform()` to RMAB's resolved download path (`download_dir` + `customPath`)
|
||||||
3. Compare transformed path to `complete_dir`:
|
3. Compare transformed path to `complete_dir`:
|
||||||
- **Match:** Use empty string (downloads go to complete_dir root)
|
- **Match:** Use empty string (downloads go to complete_dir root)
|
||||||
- **Subdirectory:** Use relative path (e.g., `audiobooks`)
|
- **Subdirectory:** Use relative path (e.g., `usenet`)
|
||||||
- **Different:** Use absolute path (e.g., `/mnt/media/audiobooks`)
|
- **Different:** Use absolute path (e.g., `/mnt/media/usenet`)
|
||||||
|
|
||||||
|
**Per-Client Custom Path:**
|
||||||
|
- If `customPath` is set (e.g., `usenet`), category path calculated from `/downloads/usenet`
|
||||||
|
- See [download-clients.md](./download-clients.md#per-client-custom-download-path) for details
|
||||||
|
|
||||||
## Post-Processing
|
## Post-Processing
|
||||||
|
|
||||||
@@ -290,9 +296,20 @@ organizePath = PathMapper.transform(sabPath, config)
|
|||||||
| Path Mapping | ✅ Bidirectional (same as qBit) | ✅ Bidirectional |
|
| Path Mapping | ✅ Bidirectional (same as qBit) | ✅ Bidirectional |
|
||||||
| Category Sync | ✅ Every download | ✅ Every download |
|
| Category Sync | ✅ Every download | ✅ Every download |
|
||||||
|
|
||||||
|
## NZB Download Proxy
|
||||||
|
|
||||||
|
**RMAB proxies NZB files** — SABnzbd does not need network access to Prowlarr.
|
||||||
|
|
||||||
|
Prowlarr returns download URLs that point back to itself (proxy URLs like `http://prowlarr:9696/3/download?apikey=...&link=...`).
|
||||||
|
RMAB downloads the NZB file content from that URL, then uploads it to SABnzbd via `mode=addfile` (multipart POST).
|
||||||
|
This matches qBittorrent's pattern where RMAB downloads `.torrent` files and uploads the binary content.
|
||||||
|
|
||||||
|
**Result:** Download clients only need network access to RMAB. No direct Prowlarr connectivity required.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- axios (HTTP client)
|
- axios (HTTP client, NZB file download)
|
||||||
|
- form-data (multipart file upload to SABnzbd)
|
||||||
- Node.js https (SSL/TLS agent)
|
- Node.js https (SSL/TLS agent)
|
||||||
- JSON API responses
|
- JSON API responses
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ src/app/admin/settings/
|
|||||||
│ ├── IndexersTab.tsx
|
│ ├── IndexersTab.tsx
|
||||||
│ ├── useIndexersSettings.ts
|
│ ├── useIndexersSettings.ts
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── DownloadTab/ # qBittorrent/SABnzbd
|
├── DownloadTab/ # qBittorrent/Transmission/SABnzbd
|
||||||
│ ├── DownloadTab.tsx
|
│ ├── DownloadTab.tsx
|
||||||
│ ├── useDownloadSettings.ts
|
│ ├── useDownloadSettings.ts
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
@@ -67,7 +67,7 @@ src/app/admin/settings/
|
|||||||
1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle
|
1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||||
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
|
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||||
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
|
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
|
||||||
4. **Download Client** - Type, URL, credentials (masked)
|
4. **Download Client** - Type (qBittorrent, Transmission, SABnzbd), URL, credentials (masked), custom download path (per-client relative sub-path with live preview)
|
||||||
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
|
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
|
||||||
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
|
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
|
||||||
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
||||||
@@ -271,7 +271,7 @@ src/app/admin/settings/
|
|||||||
|
|
||||||
**PUT /api/admin/settings/audible**
|
**PUT /api/admin/settings/audible**
|
||||||
- Updates Audible region
|
- Updates Audible region
|
||||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in)
|
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
|
||||||
- No validation required
|
- No validation required
|
||||||
|
|
||||||
**PUT /api/admin/settings/prowlarr/indexers**
|
**PUT /api/admin/settings/prowlarr/indexers**
|
||||||
@@ -324,7 +324,7 @@ src/app/admin/settings/
|
|||||||
|
|
||||||
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
|
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
|
||||||
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean
|
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean
|
||||||
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent' or 'transmission'
|
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
|
||||||
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
|
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.1",
|
"version": "1.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "download_history" ADD COLUMN "download_path" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "interactive_search_access" BOOLEAN;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "goodreads_shelves" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"rss_url" TEXT NOT NULL,
|
||||||
|
"last_sync_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "goodreads_shelves_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "goodreads_book_mappings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"goodreads_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 "goodreads_book_mappings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goodreads_shelves_user_id_idx" ON "goodreads_shelves"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "goodreads_shelves_user_id_rss_url_key" ON "goodreads_shelves"("user_id", "rss_url");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "goodreads_book_mappings_goodreads_book_id_key" ON "goodreads_book_mappings"("goodreads_book_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goodreads_book_mappings_goodreads_book_id_idx" ON "goodreads_book_mappings"("goodreads_book_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goodreads_book_mappings_audible_asin_idx" ON "goodreads_book_mappings"("audible_asin");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "goodreads_shelves" ADD CONSTRAINT "goodreads_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add cached book count and cover URLs to goodreads_shelves for rich UI display
|
||||||
|
ALTER TABLE "goodreads_shelves" ADD COLUMN "book_count" INTEGER;
|
||||||
|
ALTER TABLE "goodreads_shelves" ADD COLUMN "cover_urls" TEXT;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "reported_issues" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"audiobook_id" TEXT NOT NULL,
|
||||||
|
"reporter_id" TEXT NOT NULL,
|
||||||
|
"reason" VARCHAR(250) NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"resolved_at" TIMESTAMP(3),
|
||||||
|
"resolved_by_id" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "reported_issues_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "reported_issues_audiobook_id_idx" ON "reported_issues"("audiobook_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "reported_issues_reporter_id_idx" ON "reported_issues"("reporter_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "reported_issues_status_idx" ON "reported_issues"("status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_audiobook_id_fkey" FOREIGN KEY ("audiobook_id") REFERENCES "audiobooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
+78
-1
@@ -53,6 +53,9 @@ model User {
|
|||||||
// Request approval preferences
|
// Request approval preferences
|
||||||
autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval
|
autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval
|
||||||
|
|
||||||
|
// Fine-grained permissions
|
||||||
|
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||||
|
|
||||||
// Soft delete support
|
// Soft delete support
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||||
@@ -61,6 +64,9 @@ model User {
|
|||||||
requests Request[]
|
requests Request[]
|
||||||
bookDateRecommendations BookDateRecommendation[]
|
bookDateRecommendations BookDateRecommendation[]
|
||||||
bookDateSwipes BookDateSwipe[]
|
bookDateSwipes BookDateSwipe[]
|
||||||
|
goodreadsShelves GoodreadsShelf[]
|
||||||
|
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||||
|
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -170,6 +176,7 @@ model Audiobook {
|
|||||||
year Int? // Release year extracted from releaseDate
|
year Int? // Release year extracted from releaseDate
|
||||||
series String? // Book series name (e.g., "The Mistborn Saga")
|
series String? // Book series name (e.g., "The Mistborn Saga")
|
||||||
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
|
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
|
||||||
|
seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
|
||||||
|
|
||||||
// Request tracking
|
// Request tracking
|
||||||
status String @default("requested") // requested, downloading, processing, completed, failed
|
status String @default("requested") // requested, downloading, processing, completed, failed
|
||||||
@@ -194,7 +201,8 @@ model Audiobook {
|
|||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
requests Request[]
|
requests Request[]
|
||||||
|
reportedIssues ReportedIssue[]
|
||||||
|
|
||||||
@@index([audibleAsin])
|
@@index([audibleAsin])
|
||||||
@@index([plexGuid])
|
@@index([plexGuid])
|
||||||
@@ -275,6 +283,7 @@ model DownloadHistory {
|
|||||||
downloadStatus String? @map("download_status")
|
downloadStatus String? @map("download_status")
|
||||||
// Status values: queued, downloading, completed, failed, stalled
|
// Status values: queued, downloading, completed, failed, stalled
|
||||||
downloadError String? @map("download_error") @db.Text
|
downloadError String? @map("download_error") @db.Text
|
||||||
|
downloadPath String? @map("download_path") @db.Text
|
||||||
startedAt DateTime? @map("started_at")
|
startedAt DateTime? @map("started_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -452,3 +461,71 @@ model NotificationBackend {
|
|||||||
@@index([enabled])
|
@@index([enabled])
|
||||||
@@map("notification_backends")
|
@@map("notification_backends")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REPORTED ISSUES TABLE
|
||||||
|
// User-reported problems with available audiobooks (corrupted, wrong book, etc.)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model ReportedIssue {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
audiobookId String @map("audiobook_id")
|
||||||
|
reporterId String @map("reporter_id")
|
||||||
|
reason String @db.VarChar(250)
|
||||||
|
status String @default("open") // open, dismissed, replaced
|
||||||
|
resolvedAt DateTime? @map("resolved_at")
|
||||||
|
resolvedById String? @map("resolved_by_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
|
||||||
|
reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade)
|
||||||
|
resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([audiobookId])
|
||||||
|
@@index([reporterId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("reported_issues")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GOODREADS SYNC TABLES
|
||||||
|
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model GoodreadsShelf {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
name String // Extracted from RSS <title>
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, rssUrl])
|
||||||
|
@@index([userId])
|
||||||
|
@@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")
|
||||||
|
|
||||||
|
@@index([goodreadsBookId])
|
||||||
|
@@index([audibleAsin])
|
||||||
|
@@map("goodreads_book_mappings")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import { RequestActionsDropdown } from './RequestActionsDropdown';
|
|||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
|
|
||||||
interface RecentRequest {
|
interface RecentRequest {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
asin?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
type?: 'audiobook' | 'ebook';
|
type?: 'audiobook' | 'ebook';
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -43,6 +45,7 @@ interface RequestsResponse {
|
|||||||
|
|
||||||
interface RecentRequestsTableProps {
|
interface RecentRequestsTableProps {
|
||||||
ebookSidecarEnabled?: boolean;
|
ebookSidecarEnabled?: boolean;
|
||||||
|
annasArchiveBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
@@ -158,7 +161,7 @@ function getInitialParams(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) {
|
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// Get initial filter state from URL (only evaluated once due to lazy init)
|
// Get initial filter state from URL (only evaluated once due to lazy init)
|
||||||
@@ -185,6 +188,10 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
|
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
|
||||||
|
|
||||||
|
// View Details modal state
|
||||||
|
const [viewDetailsAsin, setViewDetailsAsin] = useState<string | null>(null);
|
||||||
|
const [viewDetailsStatus, setViewDetailsStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
// Build API URL with current local filters
|
// Build API URL with current local filters
|
||||||
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||||
|
|
||||||
@@ -314,6 +321,11 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
|||||||
const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
|
const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
|
||||||
|
|
||||||
// Action handlers
|
// Action handlers
|
||||||
|
const handleViewDetails = (asin: string, requestStatus?: string) => {
|
||||||
|
setViewDetailsAsin(asin);
|
||||||
|
setViewDetailsStatus(requestStatus || null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (requestId: string, title: string) => {
|
const handleDeleteClick = (requestId: string, title: string) => {
|
||||||
setSelectedRequest({ id: requestId, title });
|
setSelectedRequest({ id: requestId, title });
|
||||||
setShowDeleteConfirm(true);
|
setShowDeleteConfirm(true);
|
||||||
@@ -659,13 +671,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
|||||||
author: request.author,
|
author: request.author,
|
||||||
status: request.status,
|
status: request.status,
|
||||||
type: request.type,
|
type: request.type,
|
||||||
|
asin: request.asin,
|
||||||
torrentUrl: request.torrentUrl,
|
torrentUrl: request.torrentUrl,
|
||||||
}}
|
}}
|
||||||
onDelete={handleDeleteClick}
|
onDelete={handleDeleteClick}
|
||||||
onManualSearch={handleManualSearch}
|
onManualSearch={handleManualSearch}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
||||||
onFetchEbook={handleFetchEbook}
|
onFetchEbook={handleFetchEbook}
|
||||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||||
|
annasArchiveBaseUrl={annasArchiveBaseUrl}
|
||||||
isLoading={isDeleting || isFetchingEbook}
|
isLoading={isDeleting || isFetchingEbook}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -808,6 +823,21 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
|||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
onCancel={handleDeleteCancel}
|
onCancel={handleDeleteCancel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Audiobook Details Modal */}
|
||||||
|
{viewDetailsAsin && (
|
||||||
|
<AudiobookDetailsModal
|
||||||
|
asin={viewDetailsAsin}
|
||||||
|
isOpen={!!viewDetailsAsin}
|
||||||
|
onClose={() => {
|
||||||
|
setViewDetailsAsin(null);
|
||||||
|
setViewDetailsStatus(null);
|
||||||
|
}}
|
||||||
|
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
||||||
|
requestStatus={viewDetailsStatus}
|
||||||
|
hideRequestActions
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Reported Issues Section
|
||||||
|
* Documentation: documentation/backend/services/reported-issues.md
|
||||||
|
*
|
||||||
|
* Displays open reported issues on the admin dashboard.
|
||||||
|
* Allows dismiss or search-for-replacement actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
|
import { fetchJSON } from '@/lib/utils/api';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
|
||||||
|
interface ReportedIssue {
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
audiobook: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverArtUrl: string | null;
|
||||||
|
audibleAsin: string | null;
|
||||||
|
};
|
||||||
|
reporter: {
|
||||||
|
id: string;
|
||||||
|
plexUsername: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportedIssuesSectionProps {
|
||||||
|
issues: ReportedIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
|
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
|
||||||
|
|
||||||
|
const handleDismiss = async (issueId: string) => {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action: 'dismiss' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Issue dismissed');
|
||||||
|
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplaceSuccess = async () => {
|
||||||
|
toast.success('Replacement download started');
|
||||||
|
setReplaceIssue(null);
|
||||||
|
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||||
|
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-8">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-orange-600 dark:text-orange-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Reported Issues
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||||
|
{issues.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{issues.map((issue) => {
|
||||||
|
const isLoading = loadingStates[issue.id] || false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={issue.id}
|
||||||
|
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Card Content */}
|
||||||
|
<div className="p-4">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{issue.audiobook.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||||
|
{issue.audiobook.author}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Reporter */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{issue.reporter.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={issue.reporter.avatarUrl}
|
||||||
|
alt={issue.reporter.plexUsername}
|
||||||
|
className="w-5 h-5 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 text-gray-600 dark:text-gray-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{issue.reporter.plexUsername}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
|
||||||
|
{issue.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDismiss(issue.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-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" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>Dismiss</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setReplaceIssue(issue)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 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>Replace</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Search Modal for Replacement */}
|
||||||
|
{replaceIssue && createPortal(
|
||||||
|
<div className="fixed inset-0 z-[60]">
|
||||||
|
<InteractiveTorrentSearchModal
|
||||||
|
isOpen={!!replaceIssue}
|
||||||
|
onClose={() => setReplaceIssue(null)}
|
||||||
|
onSuccess={handleReplaceSuccess}
|
||||||
|
audiobook={{
|
||||||
|
title: replaceIssue.audiobook.title,
|
||||||
|
author: replaceIssue.audiobook.author,
|
||||||
|
}}
|
||||||
|
asin={replaceIssue.audiobook.audibleAsin || undefined}
|
||||||
|
replaceIssueId={replaceIssue.id}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,16 @@ export interface RequestActionsDropdownProps {
|
|||||||
author: string;
|
author: string;
|
||||||
status: string;
|
status: string;
|
||||||
type?: 'audiobook' | 'ebook';
|
type?: 'audiobook' | 'ebook';
|
||||||
|
asin?: string | null;
|
||||||
torrentUrl?: string | null;
|
torrentUrl?: string | null;
|
||||||
};
|
};
|
||||||
onDelete: (requestId: string, title: string) => void;
|
onDelete: (requestId: string, title: string) => void;
|
||||||
onManualSearch: (requestId: string) => Promise<void>;
|
onManualSearch: (requestId: string) => Promise<void>;
|
||||||
onCancel: (requestId: string) => Promise<void>;
|
onCancel: (requestId: string) => Promise<void>;
|
||||||
|
onViewDetails?: (asin: string) => void;
|
||||||
onFetchEbook?: (requestId: string) => Promise<void>;
|
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||||
ebookSidecarEnabled?: boolean;
|
ebookSidecarEnabled?: boolean;
|
||||||
|
annasArchiveBaseUrl?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +37,10 @@ export function RequestActionsDropdown({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onManualSearch,
|
onManualSearch,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onViewDetails,
|
||||||
onFetchEbook,
|
onFetchEbook,
|
||||||
ebookSidecarEnabled = false,
|
ebookSidecarEnabled = false,
|
||||||
|
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: RequestActionsDropdownProps) {
|
}: RequestActionsDropdownProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -46,6 +51,9 @@ export function RequestActionsDropdown({
|
|||||||
// Determine request type
|
// Determine request type
|
||||||
const isEbook = request.type === 'ebook';
|
const isEbook = request.type === 'ebook';
|
||||||
|
|
||||||
|
// View Details: available when ASIN exists (audiobook requests only)
|
||||||
|
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||||
|
|
||||||
// Determine available actions based on status and type
|
// Determine available actions based on status and type
|
||||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||||
@@ -64,7 +72,7 @@ export function RequestActionsDropdown({
|
|||||||
if (Array.isArray(urls) && urls.length > 0) {
|
if (Array.isArray(urls) && urls.length > 0) {
|
||||||
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
|
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
|
||||||
if (md5Match) {
|
if (md5Match) {
|
||||||
viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`;
|
viewSourceUrl = `${annasArchiveBaseUrl.replace(/\/+$/, '')}/md5/${md5Match[1]}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,6 +155,13 @@ export function RequestActionsDropdown({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewDetails = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (request.asin && onViewDetails) {
|
||||||
|
onViewDetails(request.asin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Dropdown menu content (rendered via portal)
|
// Dropdown menu content (rendered via portal)
|
||||||
const dropdownMenu = isOpen && style && (
|
const dropdownMenu = isOpen && style && (
|
||||||
<div
|
<div
|
||||||
@@ -155,6 +170,41 @@ export function RequestActionsDropdown({
|
|||||||
className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
|
className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="py-1" role="menu">
|
<div className="py-1" role="menu">
|
||||||
|
{/* View Details */}
|
||||||
|
{canViewDetails && (
|
||||||
|
<button
|
||||||
|
onClick={handleViewDetails}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider after View Details */}
|
||||||
|
{canViewDetails && (canSearch || canViewSource || canFetchEbook || canCancel || canDelete) && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Manual Search */}
|
{/* Manual Search */}
|
||||||
{canSearch && (
|
{canSearch && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
+40
-24
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
|
|||||||
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||||
|
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: reportedIssuesData } = useSWR(
|
||||||
|
'/api/admin/reported-issues',
|
||||||
|
authenticatedFetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: 10000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { data: settingsData } = useSWR(
|
const { data: settingsData } = useSWR(
|
||||||
'/api/admin/settings',
|
'/api/admin/settings',
|
||||||
authenticatedFetcher,
|
authenticatedFetcher,
|
||||||
@@ -485,31 +494,8 @@ function AdminDashboardContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requests Awaiting Approval */}
|
|
||||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
|
||||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active Downloads */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
|
||||||
Active Downloads
|
|
||||||
</h2>
|
|
||||||
<ActiveDownloadsTable downloads={downloadsData.downloads} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Request Management */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
|
||||||
Request Management
|
|
||||||
</h2>
|
|
||||||
<RecentRequestsTable
|
|
||||||
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/settings"
|
href="/admin/settings"
|
||||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||||
@@ -595,6 +581,36 @@ function AdminDashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Requests Awaiting Approval */}
|
||||||
|
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||||
|
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reported Issues */}
|
||||||
|
{reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
|
||||||
|
<ReportedIssuesSection issues={reportedIssuesData.issues} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Downloads */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Active Downloads
|
||||||
|
</h2>
|
||||||
|
<ActiveDownloadsTable downloads={downloadsData.downloads} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Management */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Request Management
|
||||||
|
</h2>
|
||||||
|
<RecentRequestsTable
|
||||||
|
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
|
||||||
|
annasArchiveBaseUrl={settingsData?.ebook?.baseUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface PathsSettings {
|
|||||||
downloadDir: string;
|
downloadDir: string;
|
||||||
mediaDir: string;
|
mediaDir: string;
|
||||||
audiobookPathTemplate?: string;
|
audiobookPathTemplate?: string;
|
||||||
|
ebookPathTemplate?: string;
|
||||||
metadataTaggingEnabled: boolean;
|
metadataTaggingEnabled: boolean;
|
||||||
chapterMergingEnabled: boolean;
|
chapterMergingEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export default function AdminSettings() {
|
|||||||
{activeTab === 'prowlarr' && (
|
{activeTab === 'prowlarr' && (
|
||||||
<IndexersTab
|
<IndexersTab
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
originalSettings={originalSettings}
|
||||||
indexers={configuredIndexers}
|
indexers={configuredIndexers}
|
||||||
flagConfigs={flagConfigs}
|
flagConfigs={flagConfigs}
|
||||||
onChange={setSettings}
|
onChange={setSettings}
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
|||||||
const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', {
|
const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url: ebook.flaresolverrUrl }),
|
body: JSON.stringify({
|
||||||
|
url: ebook.flaresolverrUrl,
|
||||||
|
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||||
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
|
|||||||
|
|
||||||
interface IndexersTabProps {
|
interface IndexersTabProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
originalSettings: Settings | null;
|
||||||
indexers: SavedIndexerConfig[];
|
indexers: SavedIndexerConfig[];
|
||||||
flagConfigs: IndexerFlagConfig[];
|
flagConfigs: IndexerFlagConfig[];
|
||||||
onChange: (settings: Settings) => void;
|
onChange: (settings: Settings) => void;
|
||||||
@@ -27,6 +29,7 @@ interface IndexersTabProps {
|
|||||||
|
|
||||||
export function IndexersTab({
|
export function IndexersTab({
|
||||||
settings,
|
settings,
|
||||||
|
originalSettings,
|
||||||
indexers,
|
indexers,
|
||||||
flagConfigs,
|
flagConfigs,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -35,11 +38,23 @@ export function IndexersTab({
|
|||||||
onValidationChange,
|
onValidationChange,
|
||||||
onRefreshIndexers,
|
onRefreshIndexers,
|
||||||
}: IndexersTabProps) {
|
}: IndexersTabProps) {
|
||||||
const { testing, testResult, testConnection } = useIndexersSettings({
|
const {
|
||||||
|
testing,
|
||||||
|
testResult,
|
||||||
|
testConnection,
|
||||||
|
showConnectionChangeConfirm,
|
||||||
|
confirmConnectionChange,
|
||||||
|
cancelConnectionChange,
|
||||||
|
configuredIndexersCount,
|
||||||
|
} = useIndexersSettings({
|
||||||
prowlarrUrl: settings.prowlarr.url,
|
prowlarrUrl: settings.prowlarr.url,
|
||||||
prowlarrApiKey: settings.prowlarr.apiKey,
|
prowlarrApiKey: settings.prowlarr.apiKey,
|
||||||
|
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
|
||||||
|
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
|
||||||
|
configuredIndexersCount: indexers.length,
|
||||||
onValidationChange,
|
onValidationChange,
|
||||||
onRefreshIndexers,
|
onRefreshIndexers,
|
||||||
|
onClearIndexers: () => onIndexersChange([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load indexers when component mounts if prowlarr is configured
|
// Auto-load indexers when component mounts if prowlarr is configured
|
||||||
@@ -96,7 +111,7 @@ export function IndexersTab({
|
|||||||
placeholder="Enter API key"
|
placeholder="Enter API key"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Found in Prowlarr Settings → General → Security → API Key
|
Found in Prowlarr Settings → General → Security → API Key
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,6 +193,19 @@ export function IndexersTab({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation modal for Prowlarr connection change */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConnectionChangeConfirm}
|
||||||
|
onClose={cancelConnectionChange}
|
||||||
|
onConfirm={confirmConnectionChange}
|
||||||
|
title="Prowlarr Connection Change"
|
||||||
|
message={`Changing your Prowlarr connection will remove your ${configuredIndexersCount} configured indexer${configuredIndexersCount === 1 ? '' : 's'}. Indexer IDs are specific to each Prowlarr instance, so existing configurations cannot be preserved. You will need to re-add indexers from the new instance after saving.`}
|
||||||
|
confirmText="Continue"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={testing}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,50 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
import type { TestResult } from '../../lib/types';
|
import type { TestResult } from '../../lib/types';
|
||||||
|
|
||||||
interface UseIndexersSettingsProps {
|
interface UseIndexersSettingsProps {
|
||||||
prowlarrUrl: string;
|
prowlarrUrl: string;
|
||||||
prowlarrApiKey: string;
|
prowlarrApiKey: string;
|
||||||
|
originalProwlarrUrl: string;
|
||||||
|
originalProwlarrApiKey: string;
|
||||||
|
configuredIndexersCount: number;
|
||||||
onValidationChange: (isValid: boolean) => void;
|
onValidationChange: (isValid: boolean) => void;
|
||||||
onRefreshIndexers?: () => Promise<void>;
|
onRefreshIndexers?: () => Promise<void>;
|
||||||
|
onClearIndexers: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIndexersSettings({
|
export function useIndexersSettings({
|
||||||
prowlarrUrl,
|
prowlarrUrl,
|
||||||
prowlarrApiKey,
|
prowlarrApiKey,
|
||||||
|
originalProwlarrUrl,
|
||||||
|
originalProwlarrApiKey,
|
||||||
|
configuredIndexersCount,
|
||||||
onValidationChange,
|
onValidationChange,
|
||||||
onRefreshIndexers,
|
onRefreshIndexers,
|
||||||
|
onClearIndexers,
|
||||||
}: UseIndexersSettingsProps) {
|
}: UseIndexersSettingsProps) {
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [showConnectionChangeConfirm, setShowConnectionChangeConfirm] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Prowlarr connection
|
* Detect if the Prowlarr URL or API key has changed from the saved values.
|
||||||
|
* A masked API key (starting with dots) means the user hasn't touched it.
|
||||||
*/
|
*/
|
||||||
const testConnection = async () => {
|
const hasConnectionChanged = useCallback((): boolean => {
|
||||||
|
const urlChanged = prowlarrUrl.trim() !== originalProwlarrUrl.trim();
|
||||||
|
const apiKeyChanged = !prowlarrApiKey.startsWith('••••') &&
|
||||||
|
prowlarrApiKey !== originalProwlarrApiKey;
|
||||||
|
return urlChanged || apiKeyChanged;
|
||||||
|
}, [prowlarrUrl, prowlarrApiKey, originalProwlarrUrl, originalProwlarrApiKey]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the actual Prowlarr connection test
|
||||||
|
*/
|
||||||
|
const executeTest = async (shouldClearIndexers: boolean) => {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
@@ -46,14 +66,23 @@ export function useIndexersSettings({
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
onValidationChange(true);
|
onValidationChange(true);
|
||||||
setTestResult({
|
|
||||||
success: true,
|
|
||||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh indexers from database if callback provided
|
if (shouldClearIndexers) {
|
||||||
if (onRefreshIndexers) {
|
onClearIndexers();
|
||||||
await onRefreshIndexers();
|
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 {
|
} else {
|
||||||
onValidationChange(false);
|
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 {
|
return {
|
||||||
testing,
|
testing,
|
||||||
testResult,
|
testResult,
|
||||||
testConnection,
|
testConnection,
|
||||||
|
showConnectionChangeConfirm,
|
||||||
|
confirmConnectionChange,
|
||||||
|
cancelConnectionChange,
|
||||||
|
configuredIndexersCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Settings, ABSLibrary } from '../../lib/types';
|
import { Settings, ABSLibrary } from '../../lib/types';
|
||||||
|
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
|
||||||
|
|
||||||
interface AudiobookshelfSectionProps {
|
interface AudiobookshelfSectionProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
@@ -161,12 +162,39 @@ export function AudiobookshelfSection({
|
|||||||
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="us">United States</option>
|
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||||
<option value="ca">Canada</option>
|
<option key={region.code} value={region.code}>
|
||||||
<option value="uk">United Kingdom</option>
|
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||||
<option value="au">Australia</option>
|
</option>
|
||||||
<option value="in">India</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{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
|
||||||
|
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
|
Non-English Region
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
Many features such as search, discovery, and metadata matching are not yet fully
|
||||||
|
supported for non-English regions. You may still proceed, but expect limited
|
||||||
|
functionality.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
||||||
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
|
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Settings, PlexLibrary } from '../../lib/types';
|
import { Settings, PlexLibrary } from '../../lib/types';
|
||||||
|
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
|
||||||
|
|
||||||
interface PlexSectionProps {
|
interface PlexSectionProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
@@ -161,12 +162,39 @@ export function PlexSection({
|
|||||||
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="us">United States</option>
|
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||||
<option value="ca">Canada</option>
|
<option key={region.code} value={region.code}>
|
||||||
<option value="uk">United Kingdom</option>
|
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||||
<option value="au">Australia</option>
|
</option>
|
||||||
<option value="in">India</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{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
|
||||||
|
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
|
Non-English Region
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
Many features such as search, discovery, and metadata matching are not yet fully
|
||||||
|
supported for non-English regions. You may still proceed, but expect limited
|
||||||
|
functionality.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
||||||
configuration in Plex. This ensures accurate book matching and metadata.
|
configuration in Plex. This ensures accurate book matching and metadata.
|
||||||
|
|||||||
@@ -3,9 +3,29 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { EVENT_LABELS } from '@/lib/constants/notification-events';
|
||||||
|
|
||||||
const logger = RMABLogger.create('NotificationsTab');
|
const logger = RMABLogger.create('NotificationsTab');
|
||||||
|
|
||||||
|
interface ProviderConfigField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'password' | 'select' | 'number';
|
||||||
|
required: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string | number;
|
||||||
|
options?: { label: string; value: string | number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderMetadata {
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
iconLabel: string;
|
||||||
|
iconColor: string;
|
||||||
|
configFields: ProviderConfigField[];
|
||||||
|
}
|
||||||
|
|
||||||
interface NotificationBackend {
|
interface NotificationBackend {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -24,24 +44,11 @@ interface ModalState {
|
|||||||
backend?: NotificationBackend;
|
backend?: NotificationBackend;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const eventLabels: Record<string, string> = EVENT_LABELS;
|
||||||
discord: 'bg-indigo-500',
|
|
||||||
pushover: 'bg-blue-500',
|
|
||||||
email: 'bg-green-500',
|
|
||||||
slack: 'bg-purple-500',
|
|
||||||
telegram: 'bg-sky-500',
|
|
||||||
webhook: 'bg-gray-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
const eventLabels: Record<string, string> = {
|
|
||||||
request_pending_approval: 'Request Pending Approval',
|
|
||||||
request_approved: 'Request Approved',
|
|
||||||
request_available: 'Audiobook Available',
|
|
||||||
request_error: 'Request Error',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NotificationsTab() {
|
export function NotificationsTab() {
|
||||||
const [backends, setBackends] = useState<NotificationBackend[]>([]);
|
const [backends, setBackends] = useState<NotificationBackend[]>([]);
|
||||||
|
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [modalState, setModalState] = useState<ModalState>({
|
const [modalState, setModalState] = useState<ModalState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
@@ -59,8 +66,23 @@ export function NotificationsTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBackends();
|
fetchBackends();
|
||||||
|
fetchProviderMetadata();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchProviderMetadata = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/notifications/providers');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setProviderMetadata(data.providers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBackends = async () => {
|
const fetchBackends = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -83,11 +105,23 @@ export function NotificationsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMetadataForType = (type: string): ProviderMetadata | undefined => {
|
||||||
|
return providerMetadata.find((p) => p.type === type);
|
||||||
|
};
|
||||||
|
|
||||||
const openAddModal = (type: string) => {
|
const openAddModal = (type: string) => {
|
||||||
|
const meta = getMetadataForType(type);
|
||||||
|
const defaultConfig: Record<string, any> = {};
|
||||||
|
if (meta) {
|
||||||
|
for (const field of meta.configFields) {
|
||||||
|
defaultConfig[field.name] = field.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setModalState({ isOpen: true, mode: 'add', selectedType: type });
|
setModalState({ isOpen: true, mode: 'add', selectedType: type });
|
||||||
setFormData({
|
setFormData({
|
||||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`,
|
name: `${meta?.displayName ?? type} Notifications`,
|
||||||
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 },
|
config: defaultConfig,
|
||||||
events: ['request_available', 'request_error'],
|
events: ['request_available', 'request_error'],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
@@ -193,6 +227,49 @@ export function NotificationsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderConfigField = (field: ProviderConfigField) => {
|
||||||
|
if (field.type === 'select' && field.options) {
|
||||||
|
return (
|
||||||
|
<div key={field.name}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{field.label}{field.required ? ' *' : ''}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.config[field.name] ?? field.defaultValue ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = field.options?.some((o) => typeof o.value === 'number')
|
||||||
|
? Number(e.target.value)
|
||||||
|
: e.target.value;
|
||||||
|
setFormData({ ...formData, config: { ...formData.config, [field.name]: value } });
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<option key={String(opt.value)} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.name}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{field.label}{field.required ? ' *' : field.label.includes('optional') ? '' : ' (optional)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={field.type === 'password' ? 'password' : 'text'}
|
||||||
|
value={formData.config[field.name] ?? ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, [field.name]: e.target.value } })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -206,32 +283,22 @@ export function NotificationsTab() {
|
|||||||
{/* Type Selector */}
|
{/* Type Selector */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<button
|
{providerMetadata.map((meta) => (
|
||||||
onClick={() => openAddModal('discord')}
|
<button
|
||||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
key={meta.type}
|
||||||
>
|
onClick={() => openAddModal(meta.type)}
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||||
D
|
>
|
||||||
</div>
|
<div className={`flex-shrink-0 w-12 h-12 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
|
||||||
<div className="ml-4 text-left">
|
{meta.iconLabel}
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">Discord</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div>
|
<div className="ml-4 text-left">
|
||||||
</div>
|
<div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
|
||||||
</button>
|
<div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</div>
|
||||||
|
</div>
|
||||||
<button
|
</button>
|
||||||
onClick={() => openAddModal('pushover')}
|
))}
|
||||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
|
||||||
P
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 text-left">
|
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -244,43 +311,46 @@ export function NotificationsTab() {
|
|||||||
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
|
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{backends.map((backend) => (
|
{backends.map((backend) => {
|
||||||
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
const meta = getMetadataForType(backend.type);
|
||||||
<div className="flex items-start justify-between mb-3">
|
return (
|
||||||
<div className="flex items-center space-x-3">
|
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||||
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}>
|
<div className="flex items-start justify-between mb-3">
|
||||||
{backend.type.charAt(0).toUpperCase()}
|
<div className="flex items-center space-x-3">
|
||||||
</div>
|
<div className={`w-10 h-10 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
|
||||||
<div>
|
{meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()}
|
||||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div>
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{meta?.displayName ?? backend.type}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-2 mb-3">
|
||||||
<div className="space-y-2 mb-3">
|
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||||
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex space-x-2">
|
||||||
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
<button
|
||||||
|
onClick={() => openEditModal(backend)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(backend.id)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
);
|
||||||
<button
|
})}
|
||||||
onClick={() => openEditModal(backend)}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(backend.id)}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +362,7 @@ export function NotificationsTab() {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification
|
{modalState.mode === 'add' ? 'Add' : 'Edit'} {currentMeta?.displayName ?? modalState.selectedType} Notification
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -314,70 +384,8 @@ export function NotificationsTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config Fields */}
|
{/* Dynamic Config Fields */}
|
||||||
{modalState.selectedType === 'discord' && (
|
{currentMeta?.configFields.map((field) => renderConfigField(field))}
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.config.webhookUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.config.username}
|
|
||||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="ReadMeABook"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{modalState.selectedType === 'pushover' && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.config.userKey}
|
|
||||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="Your Pushover user key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.config.appToken}
|
|
||||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="Your Pushover app token"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
|
|
||||||
<select
|
|
||||||
value={formData.config.priority}
|
|
||||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="-2">Lowest</option>
|
|
||||||
<option value="-1">Low</option>
|
|
||||||
<option value="0">Normal</option>
|
|
||||||
<option value="1">High</option>
|
|
||||||
<option value="2">Emergency</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Events */}
|
{/* Events */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ interface PathsTabProps {
|
|||||||
onValidationChange: (isValid: boolean) => void;
|
onValidationChange: (isValid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TemplatePreview {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
previewPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
|
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
|
||||||
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
|
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
|
||||||
paths,
|
paths,
|
||||||
@@ -25,31 +31,52 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
onValidationChange,
|
onValidationChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live preview state (client-side validation)
|
// Live preview state for audiobook template
|
||||||
const [livePreview, setLivePreview] = useState<{
|
const [audiobookPreview, setAudiobookPreview] = useState<TemplatePreview | null>(null);
|
||||||
isValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
previewPaths?: string[];
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Update live preview whenever template changes
|
// Live preview state for ebook template
|
||||||
|
const [ebookPreview, setEbookPreview] = useState<TemplatePreview | null>(null);
|
||||||
|
|
||||||
|
// Update audiobook live preview whenever template changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const template = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
const template = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
||||||
const validation = validateTemplate(template);
|
const validation = validateTemplate(template);
|
||||||
|
|
||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
setLivePreview({
|
setAudiobookPreview({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
previewPaths: generateMockPreviews(template),
|
previewPaths: generateMockPreviews(template),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setLivePreview({
|
setAudiobookPreview({
|
||||||
isValid: false,
|
isValid: false,
|
||||||
error: validation.error,
|
error: validation.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [paths.audiobookPathTemplate]);
|
}, [paths.audiobookPathTemplate]);
|
||||||
|
|
||||||
|
// Update ebook live preview whenever template changes
|
||||||
|
useEffect(() => {
|
||||||
|
const template = paths.ebookPathTemplate || '{author}/{title} {asin}';
|
||||||
|
const validation = validateTemplate(template);
|
||||||
|
|
||||||
|
if (validation.valid) {
|
||||||
|
setEbookPreview({
|
||||||
|
isValid: true,
|
||||||
|
previewPaths: generateMockPreviews(template),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEbookPreview({
|
||||||
|
isValid: false,
|
||||||
|
error: validation.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [paths.ebookPathTemplate]);
|
||||||
|
|
||||||
|
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
||||||
|
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
|
||||||
|
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
@@ -74,7 +101,7 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Temporary location for torrent downloads (kept for seeding)
|
Temporary location for downloads before they are organized into the media library
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,61 +138,24 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
Customize how audiobooks are organized within the media directory
|
Customize how audiobooks are organized within the media directory
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Variable Reference Panel */}
|
{/* Audiobook Validation Error */}
|
||||||
<div className="mt-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
{audiobookPreview && !audiobookPreview.isValid && (
|
||||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
|
||||||
Available Variables
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live Preview - Client-side validation */}
|
|
||||||
{livePreview && !livePreview.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">
|
<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>
|
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span>{livePreview.error || 'Invalid template format'}</span>
|
<span>{audiobookPreview.error || 'Invalid template format'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live Preview Examples - Show while editing */}
|
{/* Audiobook Preview Examples */}
|
||||||
{livePreview && livePreview.isValid && livePreview.previewPaths && (
|
{audiobookPreview && audiobookPreview.isValid && audiobookPreview.previewPaths && (
|
||||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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">
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
Preview Examples
|
Preview Examples
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||||
{livePreview.previewPaths.map((preview, index) => (
|
{audiobookPreview.previewPaths.map((preview, index) => (
|
||||||
<div key={index} className="text-xs">
|
<div key={index} className="text-xs">
|
||||||
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +165,96 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ebook Organization Template */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Ebook Organization Template
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.ebookPathTemplate || '{author}/{title} {asin}'}
|
||||||
|
onChange={(e) => updatePath('ebookPathTemplate', e.target.value)}
|
||||||
|
placeholder="{author}/{title} {asin}"
|
||||||
|
className="font-mono flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => updatePath('ebookPathTemplate', paths.audiobookPathTemplate || '{author}/{title} {asin}')}
|
||||||
|
disabled={ebookMatchesAudiobook}
|
||||||
|
className="whitespace-nowrap text-sm"
|
||||||
|
>
|
||||||
|
Match Audiobook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Customize how ebooks are organized within the media directory
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Ebook Validation Error */}
|
||||||
|
{ebookPreview && !ebookPreview.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>{ebookPreview.error || 'Invalid template format'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ebook Preview Examples */}
|
||||||
|
{ebookPreview && ebookPreview.isValid && ebookPreview.previewPaths && (
|
||||||
|
<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">
|
||||||
|
Preview Examples
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
{ebookPreview.previewPaths.map((preview, index) => (
|
||||||
|
<div key={index} className="text-xs">
|
||||||
|
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
||||||
|
</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">
|
||||||
|
Available Variables
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Metadata Tagging Toggle */}
|
{/* 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="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">
|
<div className="flex items-start gap-4">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
import type { PathsSettings, TestResult } from '../../lib/types';
|
import type { PathsSettings, TestResult } from '../../lib/types';
|
||||||
|
|
||||||
interface UsePathsSettingsProps {
|
interface UsePathsSettingsProps {
|
||||||
@@ -34,13 +35,14 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
|
|||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/setup/test-paths', {
|
const response = await fetchWithAuth('/api/setup/test-paths', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
downloadDir: paths.downloadDir,
|
downloadDir: paths.downloadDir,
|
||||||
mediaDir: paths.mediaDir,
|
mediaDir: paths.mediaDir,
|
||||||
audiobookPathTemplate: paths.audiobookPathTemplate,
|
audiobookPathTemplate: paths.audiobookPathTemplate,
|
||||||
|
ebookPathTemplate: paths.ebookPathTemplate,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+131
-49
@@ -11,6 +11,8 @@ import Link from 'next/link';
|
|||||||
import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api';
|
import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api';
|
||||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
|
import { GlobalUserSettingsModal } from '@/components/admin/users/GlobalUserSettingsModal';
|
||||||
|
import { UserPermissionsModal } from '@/components/admin/users/UserPermissionsModal';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +27,7 @@ interface User {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
|
interactiveSearchAccess: boolean | null;
|
||||||
_count: {
|
_count: {
|
||||||
requests: number;
|
requests: number;
|
||||||
};
|
};
|
||||||
@@ -48,6 +51,10 @@ function AdminUsersPageContent() {
|
|||||||
'/api/admin/settings/auto-approve',
|
'/api/admin/settings/auto-approve',
|
||||||
authenticatedFetcher
|
authenticatedFetcher
|
||||||
);
|
);
|
||||||
|
const { data: globalInteractiveSearchData, mutate: mutateGlobalInteractiveSearch } = useSWR(
|
||||||
|
'/api/admin/settings/interactive-search',
|
||||||
|
authenticatedFetcher
|
||||||
|
);
|
||||||
const [editDialog, setEditDialog] = useState<{
|
const [editDialog, setEditDialog] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -66,6 +73,9 @@ function AdminUsersPageContent() {
|
|||||||
}>({ isOpen: false, user: null });
|
}>({ isOpen: false, user: null });
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
|
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
|
||||||
|
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
|
||||||
|
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||||
|
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
@@ -81,6 +91,15 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
}, [globalAutoApproveData]);
|
}, [globalAutoApproveData]);
|
||||||
|
|
||||||
|
// Sync global interactive search state (default to true if not set)
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalInteractiveSearchData?.interactiveSearchAccess !== undefined) {
|
||||||
|
setGlobalInteractiveSearch(globalInteractiveSearchData.interactiveSearchAccess);
|
||||||
|
} else if (globalInteractiveSearchData !== undefined && globalInteractiveSearchData.interactiveSearchAccess === undefined) {
|
||||||
|
setGlobalInteractiveSearch(true);
|
||||||
|
}
|
||||||
|
}, [globalInteractiveSearchData]);
|
||||||
|
|
||||||
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
|
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setGlobalAutoApprove(newValue);
|
setGlobalAutoApprove(newValue);
|
||||||
@@ -102,6 +121,27 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
|
||||||
|
// Optimistic update
|
||||||
|
setGlobalInteractiveSearch(newValue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchJSON('/api/admin/settings/interactive-search', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ interactiveSearchAccess: newValue }),
|
||||||
|
});
|
||||||
|
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
|
||||||
|
mutateGlobalInteractiveSearch();
|
||||||
|
mutate(); // Refresh users list to show updated state
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on error
|
||||||
|
setGlobalInteractiveSearch(!newValue);
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
|
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
|
||||||
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
|
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
|
||||||
|
|
||||||
@@ -136,6 +176,33 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
|
||||||
|
// Optimistic update
|
||||||
|
const previousUsers = data?.users || [];
|
||||||
|
const optimisticUsers = previousUsers.map((u: User) =>
|
||||||
|
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
|
||||||
|
);
|
||||||
|
mutate({ users: optimisticUsers }, false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: user.role,
|
||||||
|
interactiveSearchAccess: newValue
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
|
||||||
|
mutate(); // Refresh users list
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on error
|
||||||
|
mutate({ users: previousUsers }, false);
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showEditDialog = (user: User) => {
|
const showEditDialog = (user: User) => {
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditDialog({ isOpen: true, user });
|
setEditDialog({ isOpen: true, user });
|
||||||
@@ -273,6 +340,7 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const users: User[] = data?.users || [];
|
const users: User[] = data?.users || [];
|
||||||
|
const permissionsUser = permissionsUserId ? users.find((u) => u.id === permissionsUserId) ?? null : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
@@ -287,40 +355,26 @@ function AdminUsersPageContent() {
|
|||||||
Manage user roles and permissions
|
Manage user roles and permissions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex items-center gap-3">
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Global Auto-Approve Toggle */}
|
|
||||||
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
|
onClick={() => setGlobalSettingsOpen(true)}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||||
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
|
|
||||||
>
|
>
|
||||||
<span
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${globalAutoApprove ? 'translate-x-6' : 'translate-x-1'}`}
|
<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>
|
||||||
|
<span>Global User Permissions</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<Link
|
||||||
<label
|
href="/admin"
|
||||||
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||||
className="block text-base font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
>
|
||||||
>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Auto-Approve All Requests
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</label>
|
</svg>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<span>Back to Dashboard</span>
|
||||||
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings below.
|
</Link>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -403,7 +457,7 @@ function AdminUsersPageContent() {
|
|||||||
Role
|
Role
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Auto-Approve
|
Permissions
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Requests
|
Requests
|
||||||
@@ -471,31 +525,34 @@ function AdminUsersPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<button
|
||||||
|
onClick={() => setPermissionsUserId(user.id)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
{user.role === 'admin' ? (
|
{user.role === 'admin' ? (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
Always On
|
Full Access
|
||||||
</span>
|
</span>
|
||||||
) : globalAutoApprove ? (
|
) : globalAutoApprove ? (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
Global Setting
|
Global Default
|
||||||
|
</span>
|
||||||
|
) : (user.autoApproveRequests ?? false) ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
Auto-Approve
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
onClick={() => handleUserAutoApproveToggle(user, !(user.autoApproveRequests ?? false))}
|
Manual
|
||||||
className="relative inline-flex h-5 w-10 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
</span>
|
||||||
style={{ backgroundColor: (user.autoApproveRequests ?? false) ? '#3b82f6' : '#d1d5db' }}
|
|
||||||
title={`Toggle auto-approve for ${user.plexUsername}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${(user.autoApproveRequests ?? false) ? 'translate-x-6' : 'translate-x-1'}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
{user._count.requests}
|
{user._count.requests}
|
||||||
@@ -587,7 +644,7 @@ function AdminUsersPageContent() {
|
|||||||
<li>• <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
|
<li>• <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
|
||||||
<li>• <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
|
<li>• <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
|
||||||
<li>• <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
|
<li>• <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
|
||||||
<li>• <strong>Auto-Approve:</strong> When the global setting is enabled, all requests are automatically processed. When disabled, you can control auto-approval per user. Admin requests are always auto-approved.</li>
|
<li>• <strong>Permissions:</strong> Click a user's permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li>
|
||||||
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
|
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
|
||||||
<li>• <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
|
<li>• <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
|
||||||
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
|
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
|
||||||
@@ -722,6 +779,31 @@ function AdminUsersPageContent() {
|
|||||||
isLoading={deleting}
|
isLoading={deleting}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Global User Settings Modal */}
|
||||||
|
<GlobalUserSettingsModal
|
||||||
|
isOpen={globalSettingsOpen}
|
||||||
|
onClose={() => setGlobalSettingsOpen(false)}
|
||||||
|
globalAutoApprove={globalAutoApprove}
|
||||||
|
onToggleAutoApprove={handleGlobalAutoApproveToggle}
|
||||||
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
|
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Permissions Modal */}
|
||||||
|
<UserPermissionsModal
|
||||||
|
isOpen={permissionsUser !== null}
|
||||||
|
onClose={() => setPermissionsUserId(null)}
|
||||||
|
user={permissionsUser}
|
||||||
|
globalAutoApprove={globalAutoApprove}
|
||||||
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
|
onToggleAutoApprove={(user, newValue) => {
|
||||||
|
handleUserAutoApproveToggle(user as User, newValue);
|
||||||
|
}}
|
||||||
|
onToggleInteractiveSearch={(user, newValue) => {
|
||||||
|
handleUserInteractiveSearchToggle(user as User, newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
|
||||||
import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
|
||||||
import { getConfigService } from '@/lib/services/config.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
|
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Downloads');
|
const logger = RMABLogger.create('API.Admin.Downloads');
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
|
|||||||
torrentName: true,
|
torrentName: true,
|
||||||
torrentHash: true,
|
torrentHash: true,
|
||||||
nzbId: true,
|
nzbId: true,
|
||||||
|
downloadClientId: true,
|
||||||
downloadClient: true, // qbittorrent, sabnzbd, or direct
|
downloadClient: true, // qbittorrent, sabnzbd, or direct
|
||||||
torrentSizeBytes: true,
|
torrentSizeBytes: true,
|
||||||
startedAt: true,
|
startedAt: true,
|
||||||
@@ -68,9 +69,9 @@ export async function GET(request: NextRequest) {
|
|||||||
take: 20,
|
take: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get configured download client type
|
// Get download client manager
|
||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
|
const manager = getDownloadClientManager(configService);
|
||||||
|
|
||||||
// Format response with speed and ETA from download client
|
// Format response with speed and ETA from download client
|
||||||
const formatted = await Promise.all(
|
const formatted = await Promise.all(
|
||||||
@@ -98,24 +99,19 @@ export async function GET(request: NextRequest) {
|
|||||||
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
|
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
|
} else {
|
||||||
// Get torrent hash from download history
|
// Use unified interface for all download clients (qBittorrent, SABnzbd, etc.)
|
||||||
const torrentHash = downloadHistory?.torrentHash;
|
const clientId = downloadHistory?.downloadClientId || downloadHistory?.torrentHash || downloadHistory?.nzbId;
|
||||||
if (torrentHash) {
|
if (clientId && downloadClient) {
|
||||||
const qbService = await getQBittorrentService();
|
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType] || 'torrent';
|
||||||
const torrentInfo = await qbService.getTorrent(torrentHash);
|
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||||
speed = torrentInfo.dlspeed;
|
|
||||||
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
|
if (client) {
|
||||||
}
|
const info = await client.getDownload(clientId);
|
||||||
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
|
if (info) {
|
||||||
// Get NZB ID from download history
|
speed = info.downloadSpeed;
|
||||||
const nzbId = downloadHistory?.nzbId;
|
eta = info.eta > 0 ? info.eta : null;
|
||||||
if (nzbId) {
|
}
|
||||||
const sabnzbdService = await getSABnzbdService();
|
|
||||||
const nzbInfo = await sabnzbdService.getNZB(nzbId);
|
|
||||||
if (nzbInfo) {
|
|
||||||
speed = nzbInfo.downloadSpeed;
|
|
||||||
eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
import { getNotificationService } from '@/lib/services/notification';
|
||||||
|
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id');
|
|||||||
const UpdateBackendSchema = z.object({
|
const UpdateBackendSchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
config: z.record(z.any()).optional(),
|
config: z.record(z.any()).optional(),
|
||||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
|
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ export async function GET(
|
|||||||
success: true,
|
success: true,
|
||||||
backend: {
|
backend: {
|
||||||
...backend,
|
...backend,
|
||||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
config: notificationService.maskConfig(backend.type, backend.config),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -114,7 +115,7 @@ export async function PUT(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Encrypt new/changed values
|
// Encrypt new/changed values
|
||||||
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
|
finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update backend
|
// Update backend
|
||||||
@@ -139,7 +140,7 @@ export async function PUT(
|
|||||||
success: true,
|
success: true,
|
||||||
backend: {
|
backend: {
|
||||||
...updated,
|
...updated,
|
||||||
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
|
config: notificationService.maskConfig(updated.type, updated.config),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Component: Notification Providers Metadata API
|
||||||
|
* Documentation: documentation/backend/services/notifications.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { getAllProviderMetadata } from '@/lib/services/notification';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Notifications.Providers');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/notifications/providers
|
||||||
|
* Returns metadata for all registered notification providers
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const providers = getAllProviderMetadata();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
providers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch provider metadata', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'FetchError',
|
||||||
|
message: 'Failed to fetch provider metadata',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,17 +6,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
|
||||||
|
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Notifications');
|
const logger = RMABLogger.create('API.Admin.Notifications');
|
||||||
|
|
||||||
const CreateBackendSchema = z.object({
|
const CreateBackendSchema = z.object({
|
||||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
config: z.record(z.any()),
|
config: z.record(z.any()),
|
||||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
|
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Mask sensitive config values
|
// Mask sensitive config values
|
||||||
const maskedBackends = backends.map((backend) => ({
|
const maskedBackends = backends.map((backend) => ({
|
||||||
...backend,
|
...backend,
|
||||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
config: notificationService.maskConfig(backend.type, backend.config),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -5,31 +5,17 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||||
|
|
||||||
const TestNotificationSchema = z.discriminatedUnion('mode', [
|
// Flexible schema: supports both backendId and type+config formats
|
||||||
// Test existing backend by ID (uses stored config)
|
const TestNotificationSchema = z.object({
|
||||||
z.object({
|
|
||||||
mode: z.literal('backend'),
|
|
||||||
backendId: z.string(),
|
|
||||||
}),
|
|
||||||
// Test new config before saving
|
|
||||||
z.object({
|
|
||||||
mode: z.literal('config'),
|
|
||||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
|
||||||
config: z.record(z.any()),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Support legacy format without mode
|
|
||||||
const LegacyTestNotificationSchema = z.object({
|
|
||||||
backendId: z.string().optional(),
|
backendId: z.string().optional(),
|
||||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(),
|
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(),
|
||||||
config: z.record(z.any()).optional(),
|
config: z.record(z.any()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
|
|||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
const parsed = TestNotificationSchema.parse(body);
|
||||||
|
|
||||||
// Support legacy format for backward compatibility
|
let type: string;
|
||||||
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
|
|
||||||
|
|
||||||
let type: NotificationBackendType;
|
|
||||||
let encryptedConfig: any;
|
let encryptedConfig: any;
|
||||||
|
|
||||||
const notificationService = getNotificationService();
|
const notificationService = getNotificationService();
|
||||||
|
|
||||||
if (legacyParsed.success) {
|
if (parsed.backendId) {
|
||||||
// Legacy format
|
// Test existing backend by ID (uses stored config)
|
||||||
if (legacyParsed.data.backendId) {
|
const backend = await prisma.notificationBackend.findUnique({
|
||||||
// Test existing backend
|
where: { id: parsed.backendId },
|
||||||
const backend = await prisma.notificationBackend.findUnique({
|
});
|
||||||
where: { id: legacyParsed.data.backendId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'NotFound', message: 'Backend not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type = backend.type as NotificationBackendType;
|
|
||||||
encryptedConfig = backend.config; // Already encrypted in DB
|
|
||||||
} else if (legacyParsed.data.type && legacyParsed.data.config) {
|
|
||||||
// Test new config
|
|
||||||
type = legacyParsed.data.type as NotificationBackendType;
|
|
||||||
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
{ error: 'NotFound', message: 'Backend not found' },
|
||||||
{ status: 400 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type = backend.type;
|
||||||
|
encryptedConfig = backend.config; // Already encrypted in DB
|
||||||
|
} else if (parsed.type && parsed.config) {
|
||||||
|
// Test new config before saving
|
||||||
|
type = parsed.type;
|
||||||
|
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||||
} else {
|
} else {
|
||||||
// New format with discriminated union
|
return NextResponse.json(
|
||||||
const parsed = TestNotificationSchema.parse(body);
|
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||||
|
{ status: 400 }
|
||||||
if (parsed.mode === 'backend') {
|
);
|
||||||
// Test existing backend
|
|
||||||
const backend = await prisma.notificationBackend.findUnique({
|
|
||||||
where: { id: parsed.backendId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!backend) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'NotFound', message: 'Backend not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type = backend.type as NotificationBackendType;
|
|
||||||
encryptedConfig = backend.config; // Already encrypted in DB
|
|
||||||
} else {
|
|
||||||
// Test new config
|
|
||||||
type = parsed.type;
|
|
||||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create test payload
|
// Create test payload
|
||||||
@@ -111,13 +68,14 @@ export async function POST(request: NextRequest) {
|
|||||||
title: "The Hitchhiker's Guide to the Galaxy",
|
title: "The Hitchhiker's Guide to the Galaxy",
|
||||||
author: 'Douglas Adams',
|
author: 'Douglas Adams',
|
||||||
userName: 'Test User',
|
userName: 'Test User',
|
||||||
|
requestType: 'audiobook',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send test notification synchronously (not via job queue)
|
// Send test notification synchronously (not via job queue)
|
||||||
try {
|
try {
|
||||||
// Call sendToBackend directly
|
// Call sendToBackend directly
|
||||||
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
|
await notificationService.sendToBackend(type, encryptedConfig, testPayload);
|
||||||
|
|
||||||
logger.info(`Test notification sent successfully for ${type}`, {
|
logger.info(`Test notification sent successfully for ${type}`, {
|
||||||
adminId: req.user?.sub,
|
adminId: req.user?.sub,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Replace Audiobook API
|
||||||
|
* Documentation: documentation/backend/services/reported-issues.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
|
||||||
|
|
||||||
|
const ReplaceSchema = z.object({
|
||||||
|
torrent: z.object({
|
||||||
|
guid: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
seeders: z.number().optional(),
|
||||||
|
leechers: z.number().optional(),
|
||||||
|
indexer: z.string(),
|
||||||
|
indexerId: z.number().optional(),
|
||||||
|
downloadUrl: z.string(),
|
||||||
|
infoUrl: z.string().optional(),
|
||||||
|
publishDate: z.string().transform((str) => new Date(str)),
|
||||||
|
infoHash: z.string().optional(),
|
||||||
|
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
|
||||||
|
bitrate: z.string().optional(),
|
||||||
|
hasChapters: z.boolean().optional(),
|
||||||
|
protocol: z.enum(['torrent', 'usenet']).optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/reported-issues/[id]/replace
|
||||||
|
* Atomically replace audiobook content: delete old → create new request → start download → resolve issue
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
const body = await req.json();
|
||||||
|
const { torrent } = ReplaceSchema.parse(body);
|
||||||
|
|
||||||
|
const result = await replaceAudiobook(id, req.user.id, torrent);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
request: result.request,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ReportedIssueError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ReplaceError', message: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to replace audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ServerError', message: 'Failed to replace audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Resolve Reported Issue API
|
||||||
|
* Documentation: documentation/backend/services/reported-issues.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
|
||||||
|
|
||||||
|
const ResolveSchema = z.object({
|
||||||
|
action: z.enum(['dismiss']),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/reported-issues/[id]/resolve
|
||||||
|
* Dismiss a reported issue
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
const body = await req.json();
|
||||||
|
const { action } = ResolveSchema.parse(body);
|
||||||
|
|
||||||
|
if (action === 'dismiss') {
|
||||||
|
const issue = await dismissIssue(id, req.user.id);
|
||||||
|
return NextResponse.json({ success: true, issue });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'InvalidAction', message: 'Unknown action' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ReportedIssueError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ResolveError', message: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to resolve issue', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ServerError', message: 'Failed to resolve issue' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Reported Issues List API
|
||||||
|
* Documentation: documentation/backend/services/reported-issues.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { getOpenIssues } from '@/lib/services/reported-issue.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.ReportedIssues');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/reported-issues
|
||||||
|
* Get all open reported issues with audiobook metadata and reporter info
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const issues = await getOpenIssues();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
issues,
|
||||||
|
count: issues.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch reported issues', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ServerError', message: 'Failed to fetch reported issues' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
author: true,
|
author: true,
|
||||||
|
audibleAsin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
@@ -129,6 +130,7 @@ export async function GET(request: NextRequest) {
|
|||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
title: request.audiobook.title,
|
title: request.audiobook.title,
|
||||||
author: request.audiobook.author,
|
author: request.audiobook.author,
|
||||||
|
asin: request.audiobook.audibleAsin || null,
|
||||||
status: request.status,
|
status: request.status,
|
||||||
type: request.type || 'audiobook', // Include request type for UI display
|
type: request.type || 'audiobook', // Include request type for UI display
|
||||||
userId: request.user.id,
|
userId: request.user.id,
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
|
|||||||
import { getConfigService } from '@/lib/services/config.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { AUDIBLE_REGIONS } from '@/lib/types/audible';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Settings.Audible');
|
const logger = RMABLogger.create('API.Admin.Settings.Audible');
|
||||||
|
|
||||||
const VALID_REGIONS = ['us', 'ca', 'uk', 'au', 'in'];
|
const VALID_REGIONS = Object.keys(AUDIBLE_REGIONS);
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
@@ -24,7 +25,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!region || !VALID_REGIONS.includes(region)) {
|
if (!region || !VALID_REGIONS.includes(region)) {
|
||||||
logger.warn('Invalid region provided', { region });
|
logger.warn('Invalid region provided', { region });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Invalid Audible region. Must be one of: us, ca, uk, au, in' },
|
{ success: false, error: `Invalid Audible region. Must be one of: ${VALID_REGIONS.join(', ')}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { getConfigService } from '@/lib/services/config.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
|
import { SUPPORTED_CLIENT_TYPES, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||||
import { PathMapper } from '@/lib/utils/path-mapper';
|
import { PathMapper } from '@/lib/utils/path-mapper';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -35,9 +36,9 @@ export async function PUT(request: NextRequest) {
|
|||||||
logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.');
|
logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.');
|
||||||
|
|
||||||
// Validate type
|
// Validate type
|
||||||
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
const updatedClient: DownloadClientConfig = {
|
const updatedClient: DownloadClientConfig = {
|
||||||
id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(),
|
id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(),
|
||||||
type,
|
type,
|
||||||
name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
|
name: getClientDisplayName(type),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url,
|
url,
|
||||||
username: username || undefined,
|
username: username || undefined,
|
||||||
@@ -137,6 +138,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
} else if (type === 'sabnzbd') {
|
} else if (type === 'sabnzbd') {
|
||||||
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
|
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
|
||||||
invalidateSABnzbdService();
|
invalidateSABnzbdService();
|
||||||
|
} else if (type === 'nzbget') {
|
||||||
|
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
|
||||||
|
invalidateNZBGetService();
|
||||||
|
} else if (type === 'transmission') {
|
||||||
|
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
|
||||||
|
invalidateTransmissionService();
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export async function PUT(
|
|||||||
remotePath,
|
remotePath,
|
||||||
localPath,
|
localPath,
|
||||||
category,
|
category,
|
||||||
|
customPath,
|
||||||
|
postImportCategory,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const config = await getConfigService();
|
const config = await getConfigService();
|
||||||
@@ -53,6 +55,14 @@ export async function PUT(
|
|||||||
|
|
||||||
const existingClient = clients[clientIndex];
|
const existingClient = clients[clientIndex];
|
||||||
|
|
||||||
|
// Validate customPath: reject paths containing ".."
|
||||||
|
if (customPath && customPath.includes('..')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Custom path cannot contain ".."' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Build updated client (preserve fields not in request)
|
// Build updated client (preserve fields not in request)
|
||||||
const updatedClient: DownloadClientConfig = {
|
const updatedClient: DownloadClientConfig = {
|
||||||
...existingClient,
|
...existingClient,
|
||||||
@@ -66,6 +76,8 @@ export async function PUT(
|
|||||||
remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
|
remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
|
||||||
localPath: localPath !== undefined ? localPath : existingClient.localPath,
|
localPath: localPath !== undefined ? localPath : existingClient.localPath,
|
||||||
category: category !== undefined ? category : existingClient.category,
|
category: category !== undefined ? category : existingClient.category,
|
||||||
|
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
|
||||||
|
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate path mapping if enabled
|
// Validate path mapping if enabled
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Component: Fetch Download Client Categories API
|
||||||
|
* Documentation: documentation/phase3/download-clients.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
|
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
|
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Categories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Fetch categories from a download client
|
||||||
|
* Accepts same connection config as the test endpoint
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
clientId,
|
||||||
|
type,
|
||||||
|
name: clientName,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
disableSSLVerify,
|
||||||
|
remotePathMappingEnabled,
|
||||||
|
remotePath,
|
||||||
|
localPath,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'URL is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfigService();
|
||||||
|
const manager = getDownloadClientManager(config);
|
||||||
|
|
||||||
|
// If editing and password not provided, use stored password
|
||||||
|
let effectivePassword = password;
|
||||||
|
let effectiveUsername = username;
|
||||||
|
|
||||||
|
if (clientId && !password) {
|
||||||
|
const existingClients = await manager.getAllClients();
|
||||||
|
const existingClient = existingClients.find(c => c.id === clientId);
|
||||||
|
|
||||||
|
if (!existingClient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Client not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
effectivePassword = existingClient.password;
|
||||||
|
if (!username && existingClient.username) {
|
||||||
|
effectiveUsername = existingClient.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConfig: DownloadClientConfig = {
|
||||||
|
id: 'categories-fetch',
|
||||||
|
type,
|
||||||
|
name: clientName || type,
|
||||||
|
enabled: true,
|
||||||
|
url,
|
||||||
|
username: effectiveUsername || '',
|
||||||
|
password: effectivePassword || '',
|
||||||
|
disableSSLVerify: disableSSLVerify || false,
|
||||||
|
remotePathMappingEnabled: remotePathMappingEnabled || false,
|
||||||
|
remotePath: remotePath || undefined,
|
||||||
|
localPath: localPath || undefined,
|
||||||
|
category: 'readmeabook',
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = await manager.createClientFromConfig(testConfig);
|
||||||
|
const categories = await service.getCategories();
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, categories });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to fetch categories', { error: message });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { getConfigService } from '@/lib/services/config.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
import { SUPPORTED_CLIENT_TYPES, CLIENT_PROTOCOL_MAP, DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -62,12 +62,14 @@ export async function POST(request: NextRequest) {
|
|||||||
remotePath,
|
remotePath,
|
||||||
localPath,
|
localPath,
|
||||||
category,
|
category,
|
||||||
|
customPath,
|
||||||
|
postImportCategory,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Validate type
|
// Validate type
|
||||||
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,21 +101,30 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate type (only one client per type for now)
|
// Check for duplicate protocol (only one client per protocol)
|
||||||
const config = await getConfigService();
|
const config = await getConfigService();
|
||||||
const manager = getDownloadClientManager(config);
|
const manager = getDownloadClientManager(config);
|
||||||
const existingClients = await manager.getAllClients();
|
const existingClients = await manager.getAllClients();
|
||||||
|
|
||||||
const duplicateType = existingClients.find(c => c.type === type && c.enabled);
|
const protocol = CLIENT_PROTOCOL_MAP[type as DownloadClientType];
|
||||||
if (duplicateType) {
|
const duplicateProtocol = existingClients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
||||||
|
if (duplicateProtocol) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `A ${type} client is already configured. Please disable or remove it first.` },
|
{ error: `A ${protocol} client (${getClientDisplayName(duplicateProtocol.type)}) is already configured. Remove it first to add a different ${protocol} client.` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new client config for testing (with plaintext password)
|
// Create new client config for testing (with plaintext password)
|
||||||
// qBittorrent credentials are optional (supports IP whitelist auth)
|
// qBittorrent credentials are optional (supports IP whitelist auth)
|
||||||
|
// Validate customPath: reject paths containing ".."
|
||||||
|
if (customPath && customPath.includes('..')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Custom path cannot contain ".."' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const newClient: DownloadClientConfig = {
|
const newClient: DownloadClientConfig = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
type,
|
type,
|
||||||
@@ -127,6 +138,8 @@ export async function POST(request: NextRequest) {
|
|||||||
remotePath: remotePath || undefined,
|
remotePath: remotePath || undefined,
|
||||||
localPath: localPath || undefined,
|
localPath: localPath || undefined,
|
||||||
category: category || 'readmeabook',
|
category: category || 'readmeabook',
|
||||||
|
customPath: customPath || undefined,
|
||||||
|
postImportCategory: postImportCategory || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test connection before saving
|
// Test connection before saving
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { getConfigService } from '@/lib/services/config.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test');
|
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test');
|
||||||
@@ -23,6 +23,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const {
|
const {
|
||||||
clientId, // Optional: existing client ID to use stored password
|
clientId, // Optional: existing client ID to use stored password
|
||||||
type,
|
type,
|
||||||
|
name: clientName,
|
||||||
url,
|
url,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -33,9 +34,9 @@ export async function POST(request: NextRequest) {
|
|||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Validate type
|
// Validate type
|
||||||
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const testConfig: DownloadClientConfig = {
|
const testConfig: DownloadClientConfig = {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
type,
|
type,
|
||||||
name: 'Test Client',
|
name: clientName || type,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url,
|
url,
|
||||||
username: effectiveUsername || '',
|
username: effectiveUsername || '',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
try {
|
||||||
const { url } = await request.json();
|
const { url, baseUrl } = await request.json();
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await testFlareSolverrConnection(url);
|
const result = await testFlareSolverrConnection(url, baseUrl);
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Interactive Search 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.InteractiveSearch');
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'interactive_search_access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/settings/interactive-search
|
||||||
|
* Get current global interactive search 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 interactiveSearchAccess = config === null ? true : config.value === 'true';
|
||||||
|
|
||||||
|
return NextResponse.json({ interactiveSearchAccess });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch interactive search setting', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch interactive search setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/settings/interactive-search
|
||||||
|
* Update global interactive search 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 { interactiveSearchAccess } = body;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (typeof interactiveSearchAccess !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input. interactiveSearchAccess must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: CONFIG_KEY },
|
||||||
|
create: {
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
value: interactiveSearchAccess.toString(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: interactiveSearchAccess.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Interactive search access setting updated to: ${interactiveSearchAccess}`, {
|
||||||
|
userId: req.user?.sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ interactiveSearchAccess });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update interactive search setting', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update interactive search setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
try {
|
||||||
const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
|
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
|
||||||
|
|
||||||
if (!downloadDir || !mediaDir) {
|
if (!downloadDir || !mediaDir) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -59,6 +59,20 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update ebook path template
|
||||||
|
if (ebookPathTemplate !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'ebook_path_template' },
|
||||||
|
update: { value: ebookPathTemplate },
|
||||||
|
create: {
|
||||||
|
key: 'ebook_path_template',
|
||||||
|
value: ebookPathTemplate,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Template for organizing ebook files in media directory',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update metadata tagging setting
|
// Update metadata tagging setting
|
||||||
await prisma.configuration.upsert({
|
await prisma.configuration.upsert({
|
||||||
where: { key: 'metadata_tagging_enabled' },
|
where: { key: 'metadata_tagging_enabled' },
|
||||||
@@ -90,12 +104,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
configService.clearCache('download_dir');
|
configService.clearCache('download_dir');
|
||||||
configService.clearCache('media_dir');
|
configService.clearCache('media_dir');
|
||||||
configService.clearCache('audiobook_path_template');
|
configService.clearCache('audiobook_path_template');
|
||||||
|
configService.clearCache('ebook_path_template');
|
||||||
configService.clearCache('metadata_tagging_enabled');
|
configService.clearCache('metadata_tagging_enabled');
|
||||||
configService.clearCache('chapter_merging_enabled');
|
configService.clearCache('chapter_merging_enabled');
|
||||||
|
|
||||||
// Invalidate qBittorrent service singleton to force reload of download_dir
|
// Invalidate all download client singletons to force reload of download_dir
|
||||||
|
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||||
|
invalidateDownloadClientManager();
|
||||||
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
||||||
invalidateQBittorrentService();
|
invalidateQBittorrentService();
|
||||||
|
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
|
||||||
|
invalidateSABnzbdService();
|
||||||
|
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
|
||||||
|
invalidateNZBGetService();
|
||||||
|
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
|
||||||
|
invalidateTransmissionService();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
|
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
||||||
@@ -42,6 +43,9 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate cached singleton so background jobs use new credentials
|
||||||
|
invalidateProwlarrService();
|
||||||
|
|
||||||
logger.info('Prowlarr settings updated');
|
logger.info('Prowlarr settings updated');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export async function GET(request: NextRequest) {
|
|||||||
downloadDir: configMap.get('download_dir') || '/downloads',
|
downloadDir: configMap.get('download_dir') || '/downloads',
|
||||||
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
|
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
|
||||||
audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
||||||
|
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
||||||
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
||||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Admin Settings Test Download Client API
|
* Component: Admin Settings Test Download Client API (DEPRECATED)
|
||||||
* Documentation: documentation/settings-pages.md
|
* Documentation: documentation/settings-pages.md
|
||||||
|
*
|
||||||
|
* DEPRECATED: Use /api/admin/settings/download-clients/test instead.
|
||||||
|
* Maintained for backward compatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { getConfigService } from '@/lib/services/config.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.TestDownloadClient');
|
const logger = RMABLogger.create('API.TestDownloadClient');
|
||||||
@@ -19,6 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
type,
|
type,
|
||||||
|
name: clientName,
|
||||||
url,
|
url,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -37,9 +40,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,53 +67,28 @@ export async function POST(request: NextRequest) {
|
|||||||
actualPassword = matchingClient.password;
|
actualPassword = matchingClient.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields per client type and test connection
|
// Build a temporary config for testing
|
||||||
let version: string | undefined;
|
const testConfig: DownloadClientConfig = {
|
||||||
|
id: 'legacy-test',
|
||||||
|
type,
|
||||||
|
name: clientName || type,
|
||||||
|
enabled: true,
|
||||||
|
url,
|
||||||
|
username: username || '',
|
||||||
|
password: actualPassword || '',
|
||||||
|
disableSSLVerify: disableSSLVerify || false,
|
||||||
|
remotePathMappingEnabled: remotePathMappingEnabled || false,
|
||||||
|
remotePath: remotePath || undefined,
|
||||||
|
localPath: localPath || undefined,
|
||||||
|
category: 'readmeabook',
|
||||||
|
};
|
||||||
|
|
||||||
if (type === 'qbittorrent') {
|
const configService = getConfigService();
|
||||||
logger.debug('Testing qBittorrent connection');
|
const manager = getDownloadClientManager(configService);
|
||||||
if (!username || !actualPassword) {
|
const result = await manager.testConnection(testConfig);
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'Username and password are required for qBittorrent' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test qBittorrent connection
|
|
||||||
version = await QBittorrentService.testConnectionWithCredentials(
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
actualPassword,
|
|
||||||
disableSSLVerify || false
|
|
||||||
);
|
|
||||||
} else if (type === 'sabnzbd') {
|
|
||||||
logger.debug('Testing SABnzbd connection');
|
|
||||||
if (!actualPassword) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'API key (password) is required for SABnzbd' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test SABnzbd connection
|
|
||||||
const sabnzbd = new SABnzbdService(url, actualPassword, 'readmeabook', disableSSLVerify || false);
|
|
||||||
const result = await sabnzbd.testConnection();
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: result.error || 'Failed to connect to SABnzbd',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
version = result.version;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If path mapping enabled, validate local path exists
|
// If path mapping enabled, validate local path exists
|
||||||
if (remotePathMappingEnabled) {
|
if (result.success && remotePathMappingEnabled) {
|
||||||
if (!remotePath || !localPath) {
|
if (!remotePath || !localPath) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -136,10 +114,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
if (result.success) {
|
||||||
success: true,
|
return NextResponse.json({ success: true, message: result.message });
|
||||||
version,
|
}
|
||||||
});
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Download client test failed', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Download client test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function PUT(
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { role, autoApproveRequests } = body;
|
const { role, autoApproveRequests, interactiveSearchAccess } = body;
|
||||||
|
|
||||||
// Validate role
|
// Validate role
|
||||||
if (!role || (role !== 'user' && role !== 'admin')) {
|
if (!role || (role !== 'user' && role !== 'admin')) {
|
||||||
@@ -37,6 +37,14 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate interactiveSearchAccess (optional)
|
||||||
|
if (interactiveSearchAccess !== undefined && interactiveSearchAccess !== null && typeof interactiveSearchAccess !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid interactiveSearchAccess. Must be a boolean or null' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent user from demoting themselves
|
// Prevent user from demoting themselves
|
||||||
if (req.user && id === req.user.sub) {
|
if (req.user && id === req.user.sub) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -91,21 +99,30 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that admins cannot have autoApproveRequests set to false
|
// Validate that admins cannot have permissions set to false
|
||||||
if (role === 'admin' && autoApproveRequests === false) {
|
if (role === 'admin' && autoApproveRequests === false) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' },
|
{ error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (role === 'admin' && interactiveSearchAccess === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Admins always have interactive search access. Cannot set interactiveSearchAccess to false for admin users.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: { role: string; autoApproveRequests?: boolean | null } = { role };
|
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
|
||||||
if (autoApproveRequests !== undefined) {
|
if (autoApproveRequests !== undefined) {
|
||||||
updateData.autoApproveRequests = autoApproveRequests;
|
updateData.autoApproveRequests = autoApproveRequests;
|
||||||
}
|
}
|
||||||
|
if (interactiveSearchAccess !== undefined) {
|
||||||
|
updateData.interactiveSearchAccess = interactiveSearchAccess;
|
||||||
|
}
|
||||||
|
|
||||||
// Update user role and autoApproveRequests
|
// Update user
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
@@ -114,6 +131,7 @@ export async function PUT(
|
|||||||
plexUsername: true,
|
plexUsername: true,
|
||||||
role: true,
|
role: true,
|
||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
|
interactiveSearchAccess: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
|
interactiveSearchAccess: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
requests: true,
|
requests: true,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping';
|
|||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||||
|
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||||
|
import type { AudibleRegion } from '@/lib/types/audible';
|
||||||
import {
|
import {
|
||||||
searchByAsin,
|
searchByAsin,
|
||||||
searchByTitle,
|
searchByTitle,
|
||||||
@@ -83,6 +86,21 @@ export async function POST(
|
|||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
try {
|
try {
|
||||||
const { asin } = await params;
|
const { asin } = await params;
|
||||||
|
|
||||||
|
// Check interactive search access permission
|
||||||
|
if (req.user) {
|
||||||
|
const callingUser = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: { role: true, interactiveSearchAccess: true },
|
||||||
|
});
|
||||||
|
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const customTitle = body.customTitle as string | undefined;
|
const customTitle = body.customTitle as string | undefined;
|
||||||
|
|
||||||
@@ -211,6 +229,11 @@ export async function POST(
|
|||||||
const format = preferredFormat || 'epub';
|
const format = preferredFormat || 'epub';
|
||||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||||
|
|
||||||
|
// Get language code from Audible region config
|
||||||
|
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
const langConfig = getLanguageForRegion(region);
|
||||||
|
const languageCode = langConfig.annasArchiveLang;
|
||||||
|
|
||||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||||
@@ -234,7 +257,8 @@ export async function POST(
|
|||||||
audiobook.author,
|
audiobook.author,
|
||||||
format,
|
format,
|
||||||
annasBaseUrl,
|
annasBaseUrl,
|
||||||
flaresolverrUrl || undefined
|
flaresolverrUrl || undefined,
|
||||||
|
languageCode
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -306,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
|
|||||||
author: string,
|
author: string,
|
||||||
preferredFormat: string,
|
preferredFormat: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
flaresolverrUrl?: string
|
flaresolverrUrl?: string,
|
||||||
|
languageCode: string = 'en'
|
||||||
): Promise<EbookSearchResult[]> {
|
): Promise<EbookSearchResult[]> {
|
||||||
let md5: string | null = null;
|
let md5: string | null = null;
|
||||||
let searchMethod: 'asin' | 'title' = 'title';
|
let searchMethod: 'asin' | 'title' = 'title';
|
||||||
@@ -314,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
|
|||||||
// Try ASIN search first
|
// Try ASIN search first
|
||||||
if (asin) {
|
if (asin) {
|
||||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||||
if (md5) {
|
if (md5) {
|
||||||
searchMethod = 'asin';
|
searchMethod = 'asin';
|
||||||
logger.info(`Found via ASIN: ${md5}`);
|
logger.info(`Found via ASIN: ${md5}`);
|
||||||
@@ -324,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
|
|||||||
// Fallback to title search
|
// Fallback to title search
|
||||||
if (!md5) {
|
if (!md5) {
|
||||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||||
if (md5) {
|
if (md5) {
|
||||||
logger.info(`Found via title: ${md5}`);
|
logger.info(`Found via title: ${md5}`);
|
||||||
}
|
}
|
||||||
@@ -410,9 +435,14 @@ async function searchIndexersForInteractive(
|
|||||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||||
|
|
||||||
// Group indexers by ebook categories
|
// Group indexers by ebook categories
|
||||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||||
|
|
||||||
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
|
if (skippedIndexers.length > 0) {
|
||||||
|
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||||
|
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
|
||||||
|
|
||||||
// Get Prowlarr service
|
// Get Prowlarr service
|
||||||
const prowlarr = await getProwlarrService();
|
const prowlarr = await getProwlarrService();
|
||||||
@@ -440,6 +470,10 @@ async function searchIndexersForInteractive(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get language-specific stop words for ranking
|
||||||
|
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||||
|
|
||||||
// Rank results with ebook scoring
|
// Rank results with ebook scoring
|
||||||
const rankedResults = rankEbookTorrents(allResults, {
|
const rankedResults = rankEbookTorrents(allResults, {
|
||||||
title,
|
title,
|
||||||
@@ -449,6 +483,8 @@ async function searchIndexersForInteractive(
|
|||||||
indexerPriorities,
|
indexerPriorities,
|
||||||
flagConfigs,
|
flagConfigs,
|
||||||
requireAuthor: false,
|
requireAuthor: false,
|
||||||
|
stopWords: rankLangConfig.stopWords,
|
||||||
|
characterReplacements: rankLangConfig.characterReplacements,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to unified result type
|
// Convert to unified result type
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Component: Report Issue API
|
||||||
|
* Documentation: documentation/backend/services/reported-issues.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { reportIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.ReportIssue');
|
||||||
|
|
||||||
|
const ReportIssueSchema = z.object({
|
||||||
|
reason: z.string().min(1, 'Reason is required').max(250, 'Reason must be 250 characters or less'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
coverArtUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/audiobooks/[asin]/report-issue
|
||||||
|
* Report an issue with an available audiobook
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const { reason, title, author, coverArtUrl } = ReportIssueSchema.parse(body);
|
||||||
|
|
||||||
|
const issue = await reportIssue(asin, req.user.id, reason, { title, author, coverArtUrl });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, issue }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ReportedIssueError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ReportIssueError', message: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to report issue', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ServerError', message: 'Failed to report issue' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobook,
|
audiobook,
|
||||||
|
audibleBaseUrl: audibleService.getBaseUrl(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
|||||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||||
|
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||||
|
import type { AudibleRegion } from '@/lib/types/audible';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -70,9 +72,14 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Group indexers by their category configuration
|
// Group indexers by their category configuration
|
||||||
// This minimizes API calls while ensuring each indexer only searches its configured categories
|
// This minimizes API calls while ensuring each indexer only searches its configured categories
|
||||||
const groups = groupIndexersByCategories(indexersConfig);
|
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
|
||||||
|
|
||||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
|
if (skippedIndexers.length > 0) {
|
||||||
|
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||||
|
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
|
||||||
|
|
||||||
// Log each group for transparency
|
// Log each group for transparency
|
||||||
groups.forEach((group, index) => {
|
groups.forEach((group, index) => {
|
||||||
@@ -81,7 +88,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Search Prowlarr for each group and combine results
|
// Search Prowlarr for each group and combine results
|
||||||
const prowlarr = await getProwlarrService();
|
const prowlarr = await getProwlarrService();
|
||||||
const searchQuery = title; // Title only - cast wide net
|
|
||||||
const allResults = [];
|
const allResults = [];
|
||||||
|
|
||||||
for (let i = 0; i < groups.length; i++) {
|
for (let i = 0; i < groups.length; i++) {
|
||||||
@@ -89,7 +95,7 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupResults = await prowlarr.search(searchQuery, {
|
const groupResults = await prowlarr.searchWithVariations(title, author, {
|
||||||
categories: group.categories,
|
categories: group.categories,
|
||||||
indexerIds: group.indexerIds,
|
indexerIds: group.indexerIds,
|
||||||
maxResults: 100, // Limit per group
|
maxResults: 100, // Limit per group
|
||||||
@@ -136,13 +142,19 @@ export async function POST(request: NextRequest) {
|
|||||||
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
|
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get language-specific stop words for ranking
|
||||||
|
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
const langConfig = getLanguageForRegion(region);
|
||||||
|
|
||||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||||
// Note: rankTorrents now filters out results < 20 MB internally
|
// Note: rankTorrents now filters out results < 20 MB internally
|
||||||
// requireAuthor: false - interactive search, show all results for user decision
|
// requireAuthor: false - interactive search, show all results for user decision
|
||||||
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
|
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
|
||||||
indexerPriorities,
|
indexerPriorities,
|
||||||
flagConfigs,
|
flagConfigs,
|
||||||
requireAuthor: false // Interactive mode - let user decide
|
requireAuthor: false, // Interactive mode - let user decide
|
||||||
|
stopWords: langConfig.stopWords,
|
||||||
|
characterReplacements: langConfig.characterReplacements,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log filter results
|
// Log filter results
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate new password length
|
// Validate new password length
|
||||||
if (newPassword.length < 8) {
|
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||||
|
if (!allowWeakPassword && newPassword.length < 8) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { resolvePermission, getGlobalBooleanSetting } from '@/lib/utils/permissions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/me
|
* GET /api/auth/me
|
||||||
@@ -37,6 +38,7 @@ export async function GET(request: NextRequest) {
|
|||||||
authProvider: true,
|
authProvider: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
|
interactiveSearchAccess: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,6 +55,14 @@ export async function GET(request: NextRequest) {
|
|||||||
// Determine if user is local admin (setup admin with local authentication)
|
// Determine if user is local admin (setup admin with local authentication)
|
||||||
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
|
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
|
||||||
|
|
||||||
|
// Resolve effective permissions
|
||||||
|
const globalInteractiveSearch = await getGlobalBooleanSetting('interactive_search_access', true);
|
||||||
|
const effectiveInteractiveSearch = resolvePermission(
|
||||||
|
user.role,
|
||||||
|
user.interactiveSearchAccess,
|
||||||
|
globalInteractiveSearch
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -65,6 +75,9 @@ export async function GET(request: NextRequest) {
|
|||||||
authProvider: user.authProvider,
|
authProvider: user.authProvider,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
lastLoginAt: user.lastLoginAt,
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
permissions: {
|
||||||
|
interactiveSearch: effectiveInteractiveSearch,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export async function GET() {
|
|||||||
// Check if local login is disabled via environment variable
|
// Check if local login is disabled via environment variable
|
||||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||||
|
|
||||||
|
// Check if weak passwords are allowed via environment variable
|
||||||
|
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||||
|
|
||||||
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
|
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
|
||||||
const indexerType = await configService.get('indexer.type');
|
const indexerType = await configService.get('indexer.type');
|
||||||
const prowlarrUrl = await configService.get('indexer.prowlarr_url');
|
const prowlarrUrl = await configService.get('indexer.prowlarr_url');
|
||||||
@@ -47,6 +50,7 @@ export async function GET() {
|
|||||||
hasLocalUsers,
|
hasLocalUsers,
|
||||||
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
||||||
localLoginDisabled,
|
localLoginDisabled,
|
||||||
|
allowWeakPassword,
|
||||||
automationEnabled,
|
automationEnabled,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -65,6 +69,7 @@ export async function GET() {
|
|||||||
hasLocalUsers,
|
hasLocalUsers,
|
||||||
oidcProviderName: null,
|
oidcProviderName: null,
|
||||||
localLoginDisabled,
|
localLoginDisabled,
|
||||||
|
allowWeakPassword,
|
||||||
automationEnabled,
|
automationEnabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -72,6 +77,7 @@ export async function GET() {
|
|||||||
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) });
|
||||||
// Default to Plex mode if config can't be read
|
// Default to Plex mode if config can't be read
|
||||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||||
|
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
backendMode: 'plex',
|
backendMode: 'plex',
|
||||||
providers: ['plex'],
|
providers: ['plex'],
|
||||||
@@ -79,6 +85,7 @@ export async function GET() {
|
|||||||
hasLocalUsers: false,
|
hasLocalUsers: false,
|
||||||
oidcProviderName: null,
|
oidcProviderName: null,
|
||||||
localLoginDisabled,
|
localLoginDisabled,
|
||||||
|
allowWeakPassword,
|
||||||
automationEnabled: false,
|
automationEnabled: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Component: Author Books API Route
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Authors.Books');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/authors/{asin}/books?name=Author+Name
|
||||||
|
* Scrape Audible for all books by this author, filtered by ASIN and English language.
|
||||||
|
* Enriched with library availability and request status.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
const authorName = request.nextUrl.searchParams.get('name');
|
||||||
|
|
||||||
|
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorName || authorName.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Author name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
|
||||||
|
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
|
||||||
|
|
||||||
|
// Enrich with library availability and request status
|
||||||
|
const userId = currentUser.sub || undefined;
|
||||||
|
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
|
||||||
|
|
||||||
|
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
books: enrichedBooks,
|
||||||
|
authorName: authorName.trim(),
|
||||||
|
authorAsin: asin,
|
||||||
|
totalBooks: enrichedBooks.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch author books' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Component: Author Detail API Route
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
|
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import {
|
||||||
|
AudnexusAuthorDetail,
|
||||||
|
fetchAuthorDetail,
|
||||||
|
} from '@/lib/integrations/audnexus-authors';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Authors.Detail');
|
||||||
|
|
||||||
|
const SIMILAR_AUTHORS_LIMIT = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/authors/{asin}
|
||||||
|
* Fetch author detail from Audnexus with enriched similar author images
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
|
||||||
|
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configService = getConfigService();
|
||||||
|
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
|
||||||
|
const regionConfig = AUDIBLE_REGIONS[audibleRegion] || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION];
|
||||||
|
const region = regionConfig.audnexusParam;
|
||||||
|
|
||||||
|
logger.info(`Fetching author detail: ${asin} (region: ${region})`);
|
||||||
|
|
||||||
|
// Fetch the primary author detail
|
||||||
|
const detail = await fetchAuthorDetail(asin, region);
|
||||||
|
if (!detail) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound', message: 'Author not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch images for similar authors in parallel (capped)
|
||||||
|
const similarSlice = (detail.similar || []).slice(0, SIMILAR_AUTHORS_LIMIT);
|
||||||
|
const similarDetails = await Promise.all(
|
||||||
|
similarSlice.map(s => fetchAuthorDetail(s.asin, region))
|
||||||
|
);
|
||||||
|
|
||||||
|
const similarAuthors = similarSlice.map((s, i) => ({
|
||||||
|
asin: s.asin,
|
||||||
|
name: s.name,
|
||||||
|
image: similarDetails[i]?.image || undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const author = {
|
||||||
|
asin: detail.asin,
|
||||||
|
name: detail.name,
|
||||||
|
description: detail.description || undefined,
|
||||||
|
image: detail.image || undefined,
|
||||||
|
genres: detail.genres?.map(g => g.name) || [],
|
||||||
|
similar: similarAuthors,
|
||||||
|
audibleUrl: `${regionConfig.baseUrl}/author/${asin}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Author detail complete: "${detail.name}" (${similarAuthors.length} similar authors)`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, author });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch author detail', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch author details' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Component: Author Search API Route
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
|
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import {
|
||||||
|
AudnexusAuthorDetail,
|
||||||
|
searchAuthors,
|
||||||
|
fetchAuthorDetail,
|
||||||
|
} from '@/lib/integrations/audnexus-authors';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Authors.Search');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/authors/search?name=Brandon Sanderson
|
||||||
|
* Search for authors on Audnexus, deduplicate, and return enriched details
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Require authentication
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = request.nextUrl.searchParams.get('name');
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Author name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get configured Audible region
|
||||||
|
const configService = getConfigService();
|
||||||
|
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
|
||||||
|
const region = AUDIBLE_REGIONS[audibleRegion]?.audnexusParam || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].audnexusParam;
|
||||||
|
|
||||||
|
logger.info(`Searching authors: "${name}" (region: ${region})`);
|
||||||
|
|
||||||
|
// Step 1: Search for authors (returns list with potential duplicates)
|
||||||
|
const searchResults = await searchAuthors(name.trim(), region);
|
||||||
|
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
authors: [],
|
||||||
|
query: name.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch details for all unique authors in parallel
|
||||||
|
const detailPromises = searchResults.map(author => fetchAuthorDetail(author.asin, region));
|
||||||
|
const detailResults = await Promise.all(detailPromises);
|
||||||
|
|
||||||
|
// Step 3: Build enriched results, filtering out any failed fetches
|
||||||
|
const authors = detailResults
|
||||||
|
.filter((detail): detail is AudnexusAuthorDetail => detail !== null)
|
||||||
|
.map(detail => ({
|
||||||
|
asin: detail.asin,
|
||||||
|
name: detail.name,
|
||||||
|
description: detail.description || undefined,
|
||||||
|
image: detail.image || undefined,
|
||||||
|
genres: detail.genres?.map(g => g.name).slice(0, 3) || [],
|
||||||
|
similarCount: detail.similar?.length || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`Author search complete: "${name}" → ${authors.length} results`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
authors,
|
||||||
|
query: name.trim(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to search authors', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SearchError', message: 'Failed to search authors' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger';
|
|||||||
|
|
||||||
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
||||||
|
|
||||||
|
// Fetch available Claude models from the Anthropic API
|
||||||
|
async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
||||||
|
const allModels: { id: string; name: string }[] = [];
|
||||||
|
let afterId: string | undefined;
|
||||||
|
|
||||||
|
// Paginate through all available models
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: '1000' });
|
||||||
|
if (afterId) {
|
||||||
|
params.set('after_id', afterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.anthropic.com/v1/models?${params.toString()}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('Claude API error', { error: errorText });
|
||||||
|
throw new Error('Invalid Claude API key or connection failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
for (const model of data.data) {
|
||||||
|
allModels.push({
|
||||||
|
id: model.id,
|
||||||
|
name: model.display_name || model.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterId = data.has_more ? data.last_id : undefined;
|
||||||
|
} while (afterId);
|
||||||
|
|
||||||
|
return allModels;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions for custom provider
|
// Helper functions for custom provider
|
||||||
function isValidBaseUrl(url: string): boolean {
|
function isValidBaseUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||||
models = [
|
try {
|
||||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
models = await fetchClaudeModels(testApiKey);
|
||||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
} catch {
|
||||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
|
||||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Test connection with a simple API call
|
|
||||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-api-key': testApiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'claude-3-5-haiku-20241022',
|
|
||||||
max_tokens: 10,
|
|
||||||
messages: [{ role: 'user', content: 'Test' }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
logger.error('Claude API error', { error: errorText });
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid Claude API key or connection failed' },
|
{ error: 'Invalid Claude API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||||
models = [
|
try {
|
||||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
models = await fetchClaudeModels(apiKey);
|
||||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
} catch {
|
||||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
|
||||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Test connection with a simple API call
|
|
||||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-api-key': apiKey,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'claude-3-5-haiku-20241022',
|
|
||||||
max_tokens: 10,
|
|
||||||
messages: [{ role: 'user', content: 'Test' }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
logger.error('Claude API error', { error: errorText });
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid Claude API key or connection failed' },
|
{ error: 'Invalid Claude API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
|||||||
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
|
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
|
||||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||||
|
import type { AudibleRegion } from '@/lib/types/audible';
|
||||||
import {
|
import {
|
||||||
searchByAsin,
|
searchByAsin,
|
||||||
searchByTitle,
|
searchByTitle,
|
||||||
@@ -121,6 +123,11 @@ export async function POST(
|
|||||||
const format = preferredFormat || 'epub';
|
const format = preferredFormat || 'epub';
|
||||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||||
|
|
||||||
|
// Get language code from Audible region config
|
||||||
|
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
const langConfig = getLanguageForRegion(region);
|
||||||
|
const languageCode = langConfig.annasArchiveLang;
|
||||||
|
|
||||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||||
@@ -145,7 +152,8 @@ export async function POST(
|
|||||||
audiobook.author,
|
audiobook.author,
|
||||||
format,
|
format,
|
||||||
annasBaseUrl,
|
annasBaseUrl,
|
||||||
flaresolverrUrl || undefined
|
flaresolverrUrl || undefined,
|
||||||
|
languageCode
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -217,7 +225,8 @@ async function searchAnnasArchiveForInteractive(
|
|||||||
author: string,
|
author: string,
|
||||||
preferredFormat: string,
|
preferredFormat: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
flaresolverrUrl?: string
|
flaresolverrUrl?: string,
|
||||||
|
languageCode: string = 'en'
|
||||||
): Promise<EbookSearchResult[]> {
|
): Promise<EbookSearchResult[]> {
|
||||||
let md5: string | null = null;
|
let md5: string | null = null;
|
||||||
let searchMethod: 'asin' | 'title' = 'title';
|
let searchMethod: 'asin' | 'title' = 'title';
|
||||||
@@ -225,7 +234,7 @@ async function searchAnnasArchiveForInteractive(
|
|||||||
// Try ASIN search first
|
// Try ASIN search first
|
||||||
if (asin) {
|
if (asin) {
|
||||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||||
if (md5) {
|
if (md5) {
|
||||||
searchMethod = 'asin';
|
searchMethod = 'asin';
|
||||||
logger.info(`Found via ASIN: ${md5}`);
|
logger.info(`Found via ASIN: ${md5}`);
|
||||||
@@ -235,7 +244,7 @@ async function searchAnnasArchiveForInteractive(
|
|||||||
// Fallback to title search
|
// Fallback to title search
|
||||||
if (!md5) {
|
if (!md5) {
|
||||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||||
if (md5) {
|
if (md5) {
|
||||||
logger.info(`Found via title: ${md5}`);
|
logger.info(`Found via title: ${md5}`);
|
||||||
}
|
}
|
||||||
@@ -321,9 +330,14 @@ async function searchIndexersForInteractive(
|
|||||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||||
|
|
||||||
// Group indexers by ebook categories
|
// Group indexers by ebook categories
|
||||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||||
|
|
||||||
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
|
if (skippedIndexers.length > 0) {
|
||||||
|
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||||
|
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
|
||||||
|
|
||||||
// Get Prowlarr service
|
// Get Prowlarr service
|
||||||
const prowlarr = await getProwlarrService();
|
const prowlarr = await getProwlarrService();
|
||||||
@@ -351,6 +365,10 @@ async function searchIndexersForInteractive(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get language-specific stop words for ranking
|
||||||
|
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||||
|
|
||||||
// Rank results with ebook scoring
|
// Rank results with ebook scoring
|
||||||
// Use requireAuthor=false for interactive mode (let user decide)
|
// Use requireAuthor=false for interactive mode (let user decide)
|
||||||
const rankedResults = rankEbookTorrents(allResults, {
|
const rankedResults = rankEbookTorrents(allResults, {
|
||||||
@@ -361,6 +379,8 @@ async function searchIndexersForInteractive(
|
|||||||
indexerPriorities,
|
indexerPriorities,
|
||||||
flagConfigs,
|
flagConfigs,
|
||||||
requireAuthor: false,
|
requireAuthor: false,
|
||||||
|
stopWords: rankLangConfig.stopWords,
|
||||||
|
characterReplacements: rankLangConfig.characterReplacements,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log ranking debug info (same format as search-ebook.processor.ts)
|
// Log ranking debug info (same format as search-ebook.processor.ts)
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||||
|
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||||
|
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||||
|
import type { AudibleRegion } from '@/lib/types/audible';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.InteractiveSearch');
|
const logger = RMABLogger.create('API.InteractiveSearch');
|
||||||
|
|
||||||
@@ -71,6 +75,18 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check interactive search access permission
|
||||||
|
const callingUser = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: { role: true, interactiveSearchAccess: true },
|
||||||
|
});
|
||||||
|
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get enabled indexers from configuration
|
// Get enabled indexers from configuration
|
||||||
const { getConfigService } = await import('@/lib/services/config.service');
|
const { getConfigService } = await import('@/lib/services/config.service');
|
||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
@@ -84,9 +100,8 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||||
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
|
|
||||||
|
|
||||||
if (enabledIndexerIds.length === 0) {
|
if (indexersConfig.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
|
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -102,22 +117,53 @@ export async function POST(
|
|||||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||||
|
|
||||||
// Search Prowlarr for torrents - ONLY enabled indexers
|
// Group indexers by their category configuration
|
||||||
const prowlarr = await getProwlarrService();
|
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
|
||||||
// Use custom title if provided, otherwise use audiobook's title
|
|
||||||
const searchQuery = customTitle || requestRecord.audiobook.title;
|
|
||||||
|
|
||||||
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery });
|
if (skippedIndexers.length > 0) {
|
||||||
|
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||||
|
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom title if provided, otherwise use audiobook's title
|
||||||
|
const searchTitle = customTitle || requestRecord.audiobook.title;
|
||||||
|
const searchAuthor = requestRecord.audiobook.author;
|
||||||
|
|
||||||
|
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
|
||||||
if (customTitle) {
|
if (customTitle) {
|
||||||
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
|
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await prowlarr.search(searchQuery, {
|
// Log each group for transparency
|
||||||
indexerIds: enabledIndexerIds,
|
groups.forEach((group, index) => {
|
||||||
maxResults: 100, // Increased limit for broader search
|
logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(`Found ${results.length} raw results`, { requestId: id });
|
// Search Prowlarr for each group and combine results
|
||||||
|
const prowlarr = await getProwlarrService();
|
||||||
|
const allResults = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < groups.length; i++) {
|
||||||
|
const group = groups[i];
|
||||||
|
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, {
|
||||||
|
categories: group.categories,
|
||||||
|
indexerIds: group.indexerIds,
|
||||||
|
maxResults: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
|
||||||
|
allResults.push(...groupResults);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
// Continue with other groups even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = allResults;
|
||||||
|
logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`, { requestId: id });
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -127,16 +173,41 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch runtime from Audnexus if ASIN available (for size-based scoring)
|
||||||
|
let durationMinutes: number | undefined;
|
||||||
|
if (requestRecord.audiobook.audibleAsin) {
|
||||||
|
try {
|
||||||
|
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const runtime = await audibleService.getRuntime(requestRecord.audiobook.audibleAsin);
|
||||||
|
if (runtime) {
|
||||||
|
durationMinutes = runtime;
|
||||||
|
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${requestRecord.audiobook.audibleAsin}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`No runtime found for ASIN ${requestRecord.audiobook.audibleAsin}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to fetch runtime for ASIN ${requestRecord.audiobook.audibleAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get language-specific stop words for ranking
|
||||||
|
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||||
|
const langConfig = getLanguageForRegion(region);
|
||||||
|
|
||||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||||
// Always use the audiobook's title/author for ranking (not custom search query)
|
// Always use the audiobook's title/author for ranking (not custom search query)
|
||||||
// requireAuthor: false - interactive mode, show all results for user decision
|
// requireAuthor: false - interactive mode, show all results for user decision
|
||||||
const rankedResults = rankTorrents(results, {
|
const rankedResults = rankTorrents(results, {
|
||||||
title: requestRecord.audiobook.title,
|
title: requestRecord.audiobook.title,
|
||||||
author: requestRecord.audiobook.author,
|
author: requestRecord.audiobook.author,
|
||||||
|
durationMinutes,
|
||||||
}, {
|
}, {
|
||||||
indexerPriorities,
|
indexerPriorities,
|
||||||
flagConfigs,
|
flagConfigs,
|
||||||
requireAuthor: false // Interactive mode - let user decide
|
requireAuthor: false, // Interactive mode - let user decide
|
||||||
|
stopWords: langConfig.stopWords,
|
||||||
|
characterReplacements: langConfig.characterReplacements,
|
||||||
});
|
});
|
||||||
|
|
||||||
// No threshold filtering for interactive search - show all results
|
// No threshold filtering for interactive search - show all results
|
||||||
@@ -147,17 +218,23 @@ export async function POST(
|
|||||||
const top3 = rankedResults.slice(0, 3);
|
const top3 = rankedResults.slice(0, 3);
|
||||||
if (top3.length > 0) {
|
if (top3.length > 0) {
|
||||||
logger.debug('==================== RANKING DEBUG ====================');
|
logger.debug('==================== RANKING DEBUG ====================');
|
||||||
logger.debug('Search parameters', { searchQuery, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||||
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
||||||
logger.debug('--------------------------------------------------------');
|
logger.debug('--------------------------------------------------------');
|
||||||
top3.forEach((result, index) => {
|
top3.forEach((result, index) => {
|
||||||
|
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
|
||||||
|
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
|
||||||
|
|
||||||
logger.debug(`${index + 1}. "${result.title}"`, {
|
logger.debug(`${index + 1}. "${result.title}"`, {
|
||||||
indexer: result.indexer,
|
indexer: result.indexer,
|
||||||
indexerId: result.indexerId,
|
indexerId: result.indexerId,
|
||||||
baseScore: `${result.score.toFixed(1)}/100`,
|
baseScore: `${result.score.toFixed(1)}/100`,
|
||||||
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
|
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
|
||||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`,
|
formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
|
||||||
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`,
|
sizeScore: durationMinutes
|
||||||
|
? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
|
||||||
|
: 'N/A (no runtime)',
|
||||||
|
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
|
||||||
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
|
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
|
||||||
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
|
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
|
||||||
finalScore: result.finalScore.toFixed(1),
|
finalScore: result.finalScore.toFixed(1),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.RequestById');
|
const logger = RMABLogger.create('API.RequestById');
|
||||||
|
|
||||||
@@ -200,28 +201,11 @@ export async function PATCH(
|
|||||||
// Get download path from the appropriate download client
|
// Get download path from the appropriate download client
|
||||||
let downloadPath: string;
|
let downloadPath: string;
|
||||||
|
|
||||||
if (downloadHistory.torrentHash) {
|
// Get download path via unified interface
|
||||||
// qBittorrent - get path from torrent info
|
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
|
||||||
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||||
const qbt = await getQBittorrentService();
|
|
||||||
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
if (!clientId || clientType === 'direct') {
|
||||||
downloadPath = `${torrent.save_path}/${torrent.name}`;
|
|
||||||
} else if (downloadHistory.nzbId) {
|
|
||||||
// SABnzbd - get path from NZB info
|
|
||||||
const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
|
|
||||||
const sabnzbd = await getSABnzbdService();
|
|
||||||
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
|
|
||||||
if (!nzbInfo || !nzbInfo.downloadPath) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'ValidationError',
|
|
||||||
message: 'Download path not available from SABnzbd',
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
downloadPath = nzbInfo.downloadPath;
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'ValidationError',
|
error: 'ValidationError',
|
||||||
@@ -231,6 +215,35 @@ export async function PATCH(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { getConfigService } = await import('@/lib/services/config.service');
|
||||||
|
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||||
|
const configService = getConfigService();
|
||||||
|
const manager = getDownloadClientManager(configService);
|
||||||
|
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
||||||
|
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'ValidationError',
|
||||||
|
message: `No ${clientType} client configured`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await client.getDownload(clientId);
|
||||||
|
if (!info?.downloadPath) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'ValidationError',
|
||||||
|
message: `Download path not available from ${client.clientType}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
downloadPath = info.downloadPath;
|
||||||
|
|
||||||
await jobQueue.addOrganizeJob(
|
await jobQueue.addOrganizeJob(
|
||||||
id,
|
id,
|
||||||
requestWithData.audiobook.id,
|
requestWithData.audiobook.id,
|
||||||
|
|||||||
+17
-259
@@ -6,11 +6,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|
||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Requests');
|
const logger = RMABLogger.create('API.Requests');
|
||||||
|
|
||||||
@@ -45,274 +43,34 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { audiobook } = CreateRequestSchema.parse(body);
|
const { audiobook } = CreateRequestSchema.parse(body);
|
||||||
|
|
||||||
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
|
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
|
||||||
// This catches the gap where files are organized but Plex hasn't scanned yet
|
|
||||||
const existingActiveRequest = await prisma.request.findFirst({
|
|
||||||
where: {
|
|
||||||
audiobook: {
|
|
||||||
audibleAsin: audiobook.asin,
|
|
||||||
},
|
|
||||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
|
||||||
status: { in: ['downloaded', 'available'] },
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: { select: { plexUsername: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingActiveRequest) {
|
const result = await createRequestForUser(req.user.id, {
|
||||||
const status = existingActiveRequest.status;
|
|
||||||
const isOwnRequest = existingActiveRequest.userId === req.user.id;
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
|
|
||||||
message: status === 'available'
|
|
||||||
? 'This audiobook is already available in your Plex library'
|
|
||||||
: 'This audiobook is being processed and will be available soon',
|
|
||||||
requestStatus: status,
|
|
||||||
isOwnRequest,
|
|
||||||
requestedBy: existingActiveRequest.user?.plexUsername,
|
|
||||||
},
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
|
|
||||||
const plexMatch = await findPlexMatch({
|
|
||||||
asin: audiobook.asin,
|
asin: audiobook.asin,
|
||||||
title: audiobook.title,
|
title: audiobook.title,
|
||||||
author: audiobook.author,
|
author: audiobook.author,
|
||||||
narrator: audiobook.narrator,
|
narrator: audiobook.narrator,
|
||||||
});
|
description: audiobook.description,
|
||||||
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
|
}, { skipAutoSearch });
|
||||||
|
|
||||||
if (plexMatch) {
|
if (!result.success) {
|
||||||
|
const statusMap: Record<string, { error: string; status: number }> = {
|
||||||
|
already_available: { error: 'AlreadyAvailable', status: 409 },
|
||||||
|
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||||
|
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||||
|
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||||
|
};
|
||||||
|
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ error: mapped.error, message: result.message },
|
||||||
error: 'AlreadyAvailable',
|
{ status: mapped.status }
|
||||||
message: 'This audiobook is already available in your Plex library',
|
|
||||||
plexGuid: plexMatch.plexGuid,
|
|
||||||
},
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch full details from Audnexus to get releaseDate, year, and series
|
|
||||||
let year: number | undefined;
|
|
||||||
let series: string | undefined;
|
|
||||||
let seriesPart: string | undefined;
|
|
||||||
try {
|
|
||||||
const audibleService = getAudibleService();
|
|
||||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
|
||||||
|
|
||||||
if (audnexusData?.releaseDate) {
|
|
||||||
try {
|
|
||||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
|
||||||
if (!isNaN(releaseYear)) {
|
|
||||||
year = releaseYear;
|
|
||||||
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract series data
|
|
||||||
if (audnexusData?.series) {
|
|
||||||
series = audnexusData.series;
|
|
||||||
logger.debug(`Extracted series: ${series}`);
|
|
||||||
}
|
|
||||||
if (audnexusData?.seriesPart) {
|
|
||||||
seriesPart = audnexusData.seriesPart;
|
|
||||||
logger.debug(`Extracted seriesPart: ${seriesPart}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find existing audiobook record by ASIN
|
|
||||||
let audiobookRecord = await prisma.audiobook.findFirst({
|
|
||||||
where: { audibleAsin: audiobook.asin },
|
|
||||||
});
|
|
||||||
|
|
||||||
// If not found, create new audiobook record
|
|
||||||
if (!audiobookRecord) {
|
|
||||||
audiobookRecord = await prisma.audiobook.create({
|
|
||||||
data: {
|
|
||||||
audibleAsin: audiobook.asin,
|
|
||||||
title: audiobook.title,
|
|
||||||
author: audiobook.author,
|
|
||||||
narrator: audiobook.narrator,
|
|
||||||
description: audiobook.description,
|
|
||||||
coverArtUrl: audiobook.coverArtUrl,
|
|
||||||
year,
|
|
||||||
series,
|
|
||||||
seriesPart,
|
|
||||||
status: 'requested',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
|
|
||||||
} else if (year || series || seriesPart) {
|
|
||||||
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
|
|
||||||
audiobookRecord = await prisma.audiobook.update({
|
|
||||||
where: { id: audiobookRecord.id },
|
|
||||||
data: {
|
|
||||||
...(year && { year }),
|
|
||||||
...(series && { series }),
|
|
||||||
...(seriesPart && { seriesPart }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already has an active (non-deleted) audiobook request for this audiobook
|
|
||||||
const existingRequest = await prisma.request.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: req.user.id,
|
|
||||||
audiobookId: audiobookRecord.id,
|
|
||||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
|
||||||
deletedAt: null, // Only check active requests
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingRequest) {
|
|
||||||
// Allow re-requesting if the status is failed, warn, or cancelled
|
|
||||||
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
|
|
||||||
|
|
||||||
if (!canReRequest) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'DuplicateRequest',
|
|
||||||
message: 'You have already requested this audiobook',
|
|
||||||
request: existingRequest,
|
|
||||||
},
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the existing failed/warn/cancelled request
|
|
||||||
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
|
|
||||||
await prisma.request.delete({
|
|
||||||
where: { id: existingRequest.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should skip auto-search (for interactive search)
|
|
||||||
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
|
|
||||||
|
|
||||||
// Check if request needs approval
|
|
||||||
let needsApproval = false;
|
|
||||||
let shouldTriggerSearch = !skipAutoSearch;
|
|
||||||
|
|
||||||
// Fetch user with autoApproveRequests setting
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: req.user.id },
|
|
||||||
select: {
|
|
||||||
role: true,
|
|
||||||
autoApproveRequests: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'UserNotFound', message: 'User not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if approval is needed
|
|
||||||
if (user.role === 'admin') {
|
|
||||||
// Admins always auto-approve
|
|
||||||
needsApproval = false;
|
|
||||||
} else {
|
|
||||||
// Check user's personal setting first
|
|
||||||
if (user.autoApproveRequests === true) {
|
|
||||||
needsApproval = false;
|
|
||||||
} else if (user.autoApproveRequests === false) {
|
|
||||||
needsApproval = true;
|
|
||||||
} else {
|
|
||||||
// User setting is null, check global setting
|
|
||||||
const globalConfig = await prisma.configuration.findUnique({
|
|
||||||
where: { key: 'auto_approve_requests' },
|
|
||||||
});
|
|
||||||
// Default to true if not configured (backward compatibility)
|
|
||||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
|
||||||
needsApproval = !globalAutoApprove;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine initial status
|
|
||||||
let initialStatus: string;
|
|
||||||
if (needsApproval) {
|
|
||||||
initialStatus = 'awaiting_approval';
|
|
||||||
shouldTriggerSearch = false; // Don't trigger search if awaiting approval
|
|
||||||
} else if (skipAutoSearch) {
|
|
||||||
initialStatus = 'awaiting_search';
|
|
||||||
} else {
|
|
||||||
initialStatus = 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request with appropriate status
|
|
||||||
const newRequest = await prisma.request.create({
|
|
||||||
data: {
|
|
||||||
userId: req.user.id,
|
|
||||||
audiobookId: audiobookRecord.id,
|
|
||||||
status: initialStatus,
|
|
||||||
type: 'audiobook', // Explicit type for user-created requests
|
|
||||||
progress: 0,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
audiobook: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
plexUsername: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobQueue = getJobQueueService();
|
|
||||||
|
|
||||||
// Send notification based on approval status
|
|
||||||
if (initialStatus === 'awaiting_approval') {
|
|
||||||
// Request needs approval - send pending notification
|
|
||||||
await jobQueue.addNotificationJob(
|
|
||||||
'request_pending_approval',
|
|
||||||
newRequest.id,
|
|
||||||
audiobookRecord.title,
|
|
||||||
audiobookRecord.author,
|
|
||||||
newRequest.user.plexUsername || 'Unknown User'
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Request was auto-approved (either automatic or interactive search) - send approved notification
|
|
||||||
await jobQueue.addNotificationJob(
|
|
||||||
'request_approved',
|
|
||||||
newRequest.id,
|
|
||||||
audiobookRecord.title,
|
|
||||||
audiobookRecord.author,
|
|
||||||
newRequest.user.plexUsername || 'Unknown User'
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger search job only if not skipped and not awaiting approval
|
|
||||||
if (shouldTriggerSearch) {
|
|
||||||
await jobQueue.addSearchJob(newRequest.id, {
|
|
||||||
id: audiobookRecord.id,
|
|
||||||
title: audiobookRecord.title,
|
|
||||||
author: audiobookRecord.author,
|
|
||||||
asin: audiobookRecord.audibleAsin || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
request: newRequest,
|
request: result.request,
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Component: Series Detail API Route
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||||
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Series.Detail');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/series/{asin}
|
||||||
|
* Fetch series detail: metadata + books (enriched with availability) + similar series
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
|
||||||
|
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Valid series ASIN is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Fetching series detail: ${asin}`);
|
||||||
|
|
||||||
|
const detail = await scrapeSeriesPage(asin);
|
||||||
|
if (!detail) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound', message: 'Series not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich books with library availability and request status
|
||||||
|
const userId = currentUser.sub || undefined;
|
||||||
|
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
|
||||||
|
|
||||||
|
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
series: {
|
||||||
|
...detail,
|
||||||
|
books: enrichedBooks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch series detail', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch series details' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Component: Series Search API Route
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { searchForSeries } from '@/lib/integrations/audible-series';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Series.Search');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/series/search?q=game+of+thrones
|
||||||
|
* Search for audiobook series on Audible, de-duplicate, and return enriched summaries
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Require authentication
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = request.nextUrl.searchParams.get('q');
|
||||||
|
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Search query is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Searching series: "${query}"`);
|
||||||
|
|
||||||
|
const series = await searchForSeries(query.trim());
|
||||||
|
|
||||||
|
logger.info(`Series search complete: "${query}" -> ${series.length} results`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
series,
|
||||||
|
query: query.trim(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to search series', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SearchError', message: 'Failed to search series' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,14 @@ import bcrypt from 'bcrypt';
|
|||||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||||
|
import { getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||||
|
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Setup.Complete');
|
const logger = RMABLogger.create('API.Setup.Complete');
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncomplete(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
backendMode,
|
backendMode,
|
||||||
@@ -28,7 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
downloadClient,
|
downloadClient,
|
||||||
paths,
|
paths,
|
||||||
bookdate,
|
bookdate,
|
||||||
} = await request.json();
|
} = await req.json();
|
||||||
|
|
||||||
// Validate backend mode
|
// Validate backend mode
|
||||||
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
|
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
|
||||||
@@ -401,7 +404,7 @@ export async function POST(request: NextRequest) {
|
|||||||
downloadClientsArray = [{
|
downloadClientsArray = [{
|
||||||
id: `temp-${Date.now()}`,
|
id: `temp-${Date.now()}`,
|
||||||
type: downloadClient.type,
|
type: downloadClient.type,
|
||||||
name: downloadClient.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
|
name: getClientDisplayName(downloadClient.type),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: downloadClient.url,
|
url: downloadClient.url,
|
||||||
username: downloadClient.username,
|
username: downloadClient.username,
|
||||||
@@ -562,4 +565,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Component: Setup Wizard Download Client Categories API
|
||||||
|
* Documentation: documentation/setup-wizard.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
|
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
|
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||||
|
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Setup.DownloadClientCategories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Fetch categories from a download client during setup wizard
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncomplete(request, async (req) => {
|
||||||
|
try {
|
||||||
|
const { type, name, url, username, password, disableSSLVerify } = await req.json();
|
||||||
|
|
||||||
|
if (!type || !url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Type and URL are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConfig: DownloadClientConfig = {
|
||||||
|
id: 'setup-categories',
|
||||||
|
type,
|
||||||
|
name: name || type,
|
||||||
|
enabled: true,
|
||||||
|
url,
|
||||||
|
username: username || '',
|
||||||
|
password: password || '',
|
||||||
|
disableSSLVerify: disableSSLVerify || false,
|
||||||
|
remotePathMappingEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const configService = getConfigService();
|
||||||
|
const manager = getDownloadClientManager(configService);
|
||||||
|
const service = await manager.createClientFromConfig(testConfig);
|
||||||
|
const categories = await service.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(
|
||||||
|
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch categories' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncompleteOrAdmin(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const { serverUrl, apiToken } = await request.json();
|
const { serverUrl, apiToken } = await req.json();
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -79,4 +81,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||||
|
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||||
|
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Setup.TestDownloadClient');
|
const logger = RMABLogger.create('API.Setup.TestDownloadClient');
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncomplete(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const { type, url, username, password, disableSSLVerify } = await request.json();
|
const { type, name, url, username, password, disableSSLVerify } = await req.json();
|
||||||
|
|
||||||
if (!type || !url) {
|
if (!type || !url) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -21,59 +24,39 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields per client type
|
// Build a temporary config for testing
|
||||||
// qBittorrent credentials are optional (supports IP whitelist auth)
|
const testConfig: DownloadClientConfig = {
|
||||||
if (type === 'qbittorrent') {
|
id: 'setup-test',
|
||||||
// Test qBittorrent connection (empty credentials work with IP whitelist)
|
type,
|
||||||
const version = await QBittorrentService.testConnectionWithCredentials(
|
name: name || type,
|
||||||
url,
|
enabled: true,
|
||||||
username || '',
|
url,
|
||||||
password || '',
|
username: username || '',
|
||||||
disableSSLVerify || false
|
password: password || '',
|
||||||
);
|
disableSSLVerify: disableSSLVerify || false,
|
||||||
|
remotePathMappingEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const configService = getConfigService();
|
||||||
|
const manager = getDownloadClientManager(configService);
|
||||||
|
const result = await manager.testConnection(testConfig);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
version,
|
message: result.message,
|
||||||
});
|
|
||||||
} else if (type === 'sabnzbd') {
|
|
||||||
if (!password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: 'API key (password) is required for SABnzbd' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test SABnzbd connection
|
|
||||||
const sabnzbd = new SABnzbdService(url, password, 'readmeabook', disableSSLVerify || false);
|
|
||||||
const result = await sabnzbd.testConnection();
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: result.error || 'Failed to connect to SABnzbd',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
version: result.version,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should never reach here
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Invalid client type' },
|
{ success: false, error: result.message },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,4 +69,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Issuer } from 'openid-client';
|
import { Issuer } from 'openid-client';
|
||||||
|
import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Setup.TestOIDC');
|
const logger = RMABLogger.create('API.Setup.TestOIDC');
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncompleteOrAdmin(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await req.json();
|
||||||
const { issuerUrl, clientId, clientSecret } = body;
|
const { issuerUrl, clientId, clientSecret } = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -93,4 +95,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
|
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
|
||||||
|
|
||||||
@@ -45,8 +46,9 @@ async function testPath(dirPath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncompleteOrAdmin(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json();
|
const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json();
|
||||||
|
|
||||||
if (!downloadDir || !mediaDir) {
|
if (!downloadDir || !mediaDir) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -126,4 +128,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||||
|
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Setup.TestPlex');
|
const logger = RMABLogger.create('API.Setup.TestPlex');
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncomplete(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const { url, token } = await request.json();
|
const { url, token } = await req.json();
|
||||||
|
|
||||||
if (!url || !token) {
|
if (!url || !token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -61,4 +63,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||||
|
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Setup.TestProwlarr');
|
const logger = RMABLogger.create('API.Setup.TestProwlarr');
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireSetupIncomplete(request, async (req) => {
|
||||||
try {
|
try {
|
||||||
const { url, apiKey } = await request.json();
|
const { url, apiKey } = await req.json();
|
||||||
|
|
||||||
if (!url || !apiKey) {
|
if (!url || !apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -50,4 +52,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Component: Goodreads Shelf Delete Route
|
||||||
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/goodreads-shelves/[id]
|
||||||
|
* Remove a Goodreads shelf subscription (ownership check)
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const shelf = await prisma.goodreadsShelf.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shelf) {
|
||||||
|
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
if (shelf.userId !== req.user.id) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.goodreadsShelf.delete({ where: { id } });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete shelf', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to delete shelf' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Component: Goodreads Shelves API Routes
|
||||||
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||||
|
|
||||||
|
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||||
|
|
||||||
|
const AddShelfSchema = z.object({
|
||||||
|
rssUrl: z.string().url().refine(
|
||||||
|
(url) => GOODREADS_RSS_PATTERN.test(url),
|
||||||
|
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/goodreads-shelves
|
||||||
|
* List the current user's Goodreads shelves with book counts and covers
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shelves = await prisma.goodreadsShelf.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shelvesWithMeta = shelves.map((shelf) => {
|
||||||
|
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
|
||||||
|
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
|
||||||
|
if (shelf.coverUrls) {
|
||||||
|
const parsed = JSON.parse(shelf.coverUrls);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
books = parsed.map((item: unknown) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return { coverUrl: item, asin: null, title: '', author: '' };
|
||||||
|
}
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
coverUrl: (obj.coverUrl as string) || '',
|
||||||
|
asin: (obj.asin as string) || null,
|
||||||
|
title: (obj.title as string) || '',
|
||||||
|
author: (obj.author as string) || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: shelf.id,
|
||||||
|
name: shelf.name,
|
||||||
|
rssUrl: shelf.rssUrl,
|
||||||
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
|
createdAt: shelf.createdAt,
|
||||||
|
bookCount: shelf.bookCount ?? null,
|
||||||
|
books,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/goodreads-shelves
|
||||||
|
* Add a new Goodreads shelf subscription
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { rssUrl } = AddShelfSchema.parse(body);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await prisma.goodreadsShelf.findUnique({
|
||||||
|
where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate by fetching the RSS feed
|
||||||
|
let shelfName: string;
|
||||||
|
let bookCount: number;
|
||||||
|
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
|
||||||
|
try {
|
||||||
|
const rssData = await fetchAndValidateRss(rssUrl);
|
||||||
|
shelfName = rssData.shelfName;
|
||||||
|
bookCount = rssData.books.length;
|
||||||
|
initialBooks = rssData.books
|
||||||
|
.filter(b => b.coverUrl)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'InvalidRSS',
|
||||||
|
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shelf = await prisma.goodreadsShelf.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user.id,
|
||||||
|
name: shelfName,
|
||||||
|
rssUrl,
|
||||||
|
bookCount,
|
||||||
|
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
|
try {
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
|
||||||
|
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
shelf: {
|
||||||
|
id: shelf.id,
|
||||||
|
name: shelf.name,
|
||||||
|
rssUrl: shelf.rssUrl,
|
||||||
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
|
createdAt: shelf.createdAt,
|
||||||
|
bookCount: shelf.bookCount,
|
||||||
|
books: initialBooks,
|
||||||
|
},
|
||||||
|
bookCount,
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Component: Author Detail Page
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use, useCallback } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
|
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
|
||||||
|
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
|
||||||
|
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
|
||||||
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
|
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||||
|
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||||
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
|
export default function AuthorDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ asin: string }>;
|
||||||
|
}) {
|
||||||
|
const { asin } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const fromAuthorName = searchParams.get('from');
|
||||||
|
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
|
||||||
|
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
|
||||||
|
asin,
|
||||||
|
author?.name || null
|
||||||
|
);
|
||||||
|
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
// Use browser back if we came from within the app, otherwise fallback to /authors
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push('/authors');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{fromAuthorName ? `Back to ${fromAuthorName}` : 'Back to Authors'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Author Detail Card */}
|
||||||
|
{authorLoading ? (
|
||||||
|
<AuthorDetailSkeleton />
|
||||||
|
) : author ? (
|
||||||
|
<AuthorDetailCard author={author} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 space-y-4">
|
||||||
|
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">Author not found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Similar Authors */}
|
||||||
|
{authorLoading ? (
|
||||||
|
<SimilarAuthorsSkeleton />
|
||||||
|
) : author && author.similar.length > 0 ? (
|
||||||
|
<SimilarAuthorsRow authors={author.similar} currentAuthorName={author.name} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Books Section */}
|
||||||
|
{author && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Sticky Books Header */}
|
||||||
|
<div className="sticky top-14 sm:top-16 z-30">
|
||||||
|
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
Books
|
||||||
|
</h2>
|
||||||
|
{!booksLoading && totalBooks > 0 && (
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||||
|
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||||
|
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Books Grid */}
|
||||||
|
<AudiobookGrid
|
||||||
|
audiobooks={books}
|
||||||
|
isLoading={booksLoading}
|
||||||
|
emptyMessage={`No books found for ${author.name}`}
|
||||||
|
cardSize={cardSize}
|
||||||
|
squareCovers={squareCovers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Component: Authors Page
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { AuthorGrid } from '@/components/authors/AuthorGrid';
|
||||||
|
import { useAuthorSearch } from '@/lib/hooks/useAuthors';
|
||||||
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
|
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||||
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
|
function AuthorsPageContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const initialQuery = searchParams.get('q') || '';
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||||
|
const { cardSize, setCardSize } = usePreferences();
|
||||||
|
|
||||||
|
// Debounce search query and sync to URL
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query);
|
||||||
|
// Update URL without adding history entries
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
router.replace(`/authors?q=${encodeURIComponent(trimmed)}`, { scroll: false });
|
||||||
|
} else {
|
||||||
|
router.replace('/authors', { scroll: false });
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, router]);
|
||||||
|
|
||||||
|
const { authors, isLoading } = useAuthorSearch(debouncedQuery);
|
||||||
|
|
||||||
|
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Browse Authors
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Search for your favorite audiobook authors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Form */}
|
||||||
|
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by author name..."
|
||||||
|
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{debouncedQuery ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Sticky Results Header */}
|
||||||
|
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||||
|
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
Authors
|
||||||
|
</h2>
|
||||||
|
{!isLoading && authors.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||||
|
({authors.length} result{authors.length !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author Grid */}
|
||||||
|
<AuthorGrid
|
||||||
|
authors={authors}
|
||||||
|
isLoading={!!isLoading}
|
||||||
|
emptyMessage={`No authors found for "${debouncedQuery}"`}
|
||||||
|
cardSize={cardSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="text-center py-16 space-y-4">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-16 w-16 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
Start typing to search for authors
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||||
|
Search by author name to discover their works
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthorsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<AuthorsPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -196,3 +196,12 @@ body {
|
|||||||
.animate-toast-in {
|
.animate-toast-in {
|
||||||
animation: toast-slide-in 0.3s ease-out;
|
animation: toast-slide-in 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar while keeping scroll functional */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function LoginContent() {
|
|||||||
hasLocalUsers: boolean;
|
hasLocalUsers: boolean;
|
||||||
oidcProviderName: string | null;
|
oidcProviderName: string | null;
|
||||||
localLoginDisabled: boolean;
|
localLoginDisabled: boolean;
|
||||||
|
allowWeakPassword: boolean;
|
||||||
automationEnabled: boolean;
|
automationEnabled: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showRegisterForm, setShowRegisterForm] = useState(false);
|
const [showRegisterForm, setShowRegisterForm] = useState(false);
|
||||||
@@ -78,6 +79,7 @@ function LoginContent() {
|
|||||||
hasLocalUsers: false,
|
hasLocalUsers: false,
|
||||||
oidcProviderName: null,
|
oidcProviderName: null,
|
||||||
localLoginDisabled: false,
|
localLoginDisabled: false,
|
||||||
|
allowWeakPassword: false,
|
||||||
automationEnabled: false,
|
automationEnabled: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -345,7 +347,7 @@ function LoginContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (registerPassword.length < 8) {
|
if (!authProviders?.allowWeakPassword && registerPassword.length < 8) {
|
||||||
setError('Password must be at least 8 characters');
|
setError('Password must be at least 8 characters');
|
||||||
setIsLoggingIn(false);
|
setIsLoggingIn(false);
|
||||||
return;
|
return;
|
||||||
@@ -639,10 +641,12 @@ function LoginContent() {
|
|||||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={authProviders?.allowWeakPassword ? 1 : 8}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
{!authProviders?.allowWeakPassword && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
|
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
@@ -656,7 +660,7 @@ function LoginContent() {
|
|||||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={authProviders?.allowWeakPassword ? 1 : 8}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+124
-252
@@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||||
|
|
||||||
|
const statConfig = [
|
||||||
|
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||||
|
{ key: 'active', label: 'Active', color: 'text-blue-500' },
|
||||||
|
{ key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
|
||||||
|
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
|
||||||
|
{ key: 'failed', label: 'Failed', color: 'text-red-500' },
|
||||||
|
{ key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StatKey = (typeof statConfig)[number]['key'];
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
// Always show only the current user's own requests (even for admins)
|
|
||||||
const { requests, isLoading } = useRequests(undefined, 50, true);
|
const { requests, isLoading } = useRequests(undefined, 50, true);
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!requests.length) {
|
if (!requests.length) {
|
||||||
return {
|
return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
|
||||||
total: 0,
|
|
||||||
completed: 0,
|
|
||||||
active: 0,
|
|
||||||
waiting: 0,
|
|
||||||
failed: 0,
|
|
||||||
cancelled: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: requests.length,
|
total: requests.length,
|
||||||
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
|
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
|
||||||
active: requests.filter((r: any) =>
|
active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
|
||||||
['pending', 'searching', 'downloading', 'processing'].includes(r.status)
|
waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
|
||||||
).length,
|
|
||||||
waiting: requests.filter((r: any) =>
|
|
||||||
['awaiting_search', 'awaiting_import'].includes(r.status)
|
|
||||||
).length,
|
|
||||||
failed: requests.filter((r: any) => r.status === 'failed').length,
|
failed: requests.filter((r: any) => r.status === 'failed').length,
|
||||||
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
|
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
|
||||||
};
|
};
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
// Get active downloads (downloading or processing)
|
|
||||||
const activeDownloads = useMemo(() => {
|
const activeDownloads = useMemo(() => {
|
||||||
return requests.filter((r: any) =>
|
return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
|
||||||
['downloading', 'processing'].includes(r.status)
|
|
||||||
);
|
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
// Get recent requests (last 5)
|
|
||||||
const recentRequests = useMemo(() => {
|
const recentRequests = useMemo(() => {
|
||||||
return [...requests]
|
return [...requests]
|
||||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-20 max-w-5xl text-center">
|
||||||
<div className="text-center py-16 space-y-4">
|
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-5">
|
||||||
<svg
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
className="mx-auto h-16 w-16 text-gray-400"
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
Authentication Required
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Please log in to view your profile
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Sign in required
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Please log in to view your profile
|
||||||
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -94,183 +77,83 @@ export default function ProfilePage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
<main className="container mx-auto px-4 py-8 max-w-5xl space-y-10">
|
||||||
{/* User Info Card */}
|
{/* Profile Card — gradient banner + avatar + info + stats */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
<section className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
{/* Gradient Banner */}
|
||||||
|
<div className="h-32 sm:h-40 bg-gradient-to-br from-blue-600 via-indigo-500 to-violet-600" />
|
||||||
|
|
||||||
|
{/* Profile Content — overlapping the banner */}
|
||||||
|
<div className="px-6 sm:px-8 pb-8 -mt-14 sm:-mt-16">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex-shrink-0">
|
{user.avatarUrl ? (
|
||||||
{user.avatarUrl ? (
|
<img
|
||||||
<img
|
src={user.avatarUrl}
|
||||||
src={user.avatarUrl}
|
alt={user.username}
|
||||||
alt={user.username}
|
className="w-28 h-28 rounded-full ring-4 ring-white dark:ring-gray-800 shadow-lg object-cover mb-5"
|
||||||
className="w-24 h-24 rounded-full"
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-4xl font-bold ring-4 ring-white dark:ring-gray-800 shadow-lg mb-5">
|
||||||
<div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center text-white text-3xl font-bold">
|
{user.username.charAt(0).toUpperCase()}
|
||||||
{user.username.charAt(0).toUpperCase()}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Details */}
|
{/* Name + Email + Badge */}
|
||||||
<div className="flex-1 space-y-2 text-center sm:text-left">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
{user.username}
|
||||||
{user.username}
|
</h1>
|
||||||
</h1>
|
{user.email && (
|
||||||
{user.email && (
|
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
{user.email}
|
||||||
{user.email}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
<div className="mt-3">
|
||||||
<div className="flex items-center gap-2">
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
|
||||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
user.role === 'admin'
|
||||||
user.role === 'admin'
|
? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400'
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{user.role === 'admin' ? 'Administrator' : 'User'}
|
||||||
{user.role === 'admin' ? 'Administrator' : 'User'}
|
</span>
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-500">
|
|
||||||
Plex ID: {user.plexId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Statistics Grid */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
|
|
||||||
{/* Total Requests */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Total</p>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
{isLoading ? '...' : stats.total}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Requests */}
|
{/* Stats Strip */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-px bg-gray-100 dark:bg-gray-700/30">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
{statConfig.map((stat) => (
|
||||||
<div className="flex-shrink-0">
|
<div
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
key={stat.key}
|
||||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
>
|
||||||
</svg>
|
<div className={cn('text-2xl sm:text-3xl font-bold tabular-nums', stat.color)}>
|
||||||
|
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
|
||||||
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active</p>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-blue-600 dark:text-blue-400">
|
|
||||||
{isLoading ? '...' : stats.active}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Waiting Requests */}
|
{/* Goodreads Shelves */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
<GoodreadsShelvesSection />
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Waiting</p>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
|
||||||
{isLoading ? '...' : stats.waiting}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Completed Requests */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" 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>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Completed</p>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-green-600 dark:text-green-400">
|
|
||||||
{isLoading ? '...' : stats.completed}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Failed Requests */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Failed</p>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-red-600 dark:text-red-400">
|
|
||||||
{isLoading ? '...' : stats.failed}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cancelled Requests */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
|
||||||
<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="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Cancelled</p>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-gray-600 dark:text-gray-400">
|
|
||||||
{isLoading ? '...' : stats.cancelled}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Downloads */}
|
{/* Active Downloads */}
|
||||||
{activeDownloads.length > 0 && (
|
{activeDownloads.length > 0 && (
|
||||||
<div className="space-y-4">
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
Active Downloads
|
Active Downloads
|
||||||
</h2>
|
</h2>
|
||||||
<a
|
<a
|
||||||
href="/requests"
|
href="/requests"
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
View All Requests →
|
View All
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -278,21 +161,23 @@ export default function ProfilePage() {
|
|||||||
<RequestCard key={request.id} request={request} showActions={false} />
|
<RequestCard key={request.id} request={request} showActions={false} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Requests */}
|
{/* Recent Requests */}
|
||||||
<div className="space-y-4">
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
Recent Requests
|
Recent Requests
|
||||||
</h2>
|
</h2>
|
||||||
<a
|
{requests.length > 0 && (
|
||||||
href="/requests"
|
<a
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
href="/requests"
|
||||||
>
|
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
View All Requests →
|
>
|
||||||
</a>
|
View All
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -300,14 +185,14 @@ export default function ProfilePage() {
|
|||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-5 animate-pulse"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
|
<div className="w-20 h-28 bg-gray-100 dark:bg-gray-700/50 rounded-lg flex-shrink-0" />
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3 py-1">
|
||||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-3/4" />
|
||||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
<div className="h-4 bg-gray-100 dark:bg-gray-700/50 rounded w-1/2" />
|
||||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
|
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,47 +205,34 @@ export default function ProfilePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-md space-y-4">
|
<div className="rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-700/50 py-16 text-center">
|
||||||
<svg
|
<svg
|
||||||
className="mx-auto h-16 w-16 text-gray-400"
|
className="mx-auto w-10 h-10 text-gray-300 dark:text-gray-600 mb-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 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>
|
</svg>
|
||||||
<div className="space-y-2">
|
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
No requests yet
|
||||||
No requests yet
|
</p>
|
||||||
</h3>
|
<p className="text-sm text-gray-400 dark:text-gray-600 mt-1">
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
Search for audiobooks to get started
|
||||||
Start by searching for audiobooks and requesting them
|
</p>
|
||||||
</p>
|
<a
|
||||||
</div>
|
href="/search"
|
||||||
<div className="pt-4">
|
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||||
<a
|
>
|
||||||
href="/search"
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
>
|
</svg>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Search Audiobooks
|
||||||
<path
|
</a>
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Search Audiobooks
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import { Header } from '@/components/layout/Header';
|
|||||||
import { RequestCard } from '@/components/requests/RequestCard';
|
import { RequestCard } from '@/components/requests/RequestCard';
|
||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
export default function RequestsPage() {
|
export default function RequestsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { squareCovers } = usePreferences();
|
||||||
const [filter, setFilter] = useState<FilterStatus>('all');
|
const [filter, setFilter] = useState<FilterStatus>('all');
|
||||||
|
|
||||||
// Always fetch only the current user's requests (even for admins)
|
// Always fetch only the current user's requests (even for admins)
|
||||||
@@ -133,7 +135,10 @@ export default function RequestsPage() {
|
|||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
|
<div className={cn(
|
||||||
|
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
|
||||||
|
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
|
||||||
|
)}></div>
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Component: Series Detail Page
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use, useCallback } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
|
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
|
||||||
|
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
|
||||||
|
import { useSeriesDetail } from '@/lib/hooks/useSeries';
|
||||||
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
|
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||||
|
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||||
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
|
export default function SeriesDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ asin: string }>;
|
||||||
|
}) {
|
||||||
|
const { asin } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const fromSeriesTitle = searchParams.get('from');
|
||||||
|
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
|
||||||
|
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
// Use browser back if we came from within the app, otherwise fallback to /series
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push('/series');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{fromSeriesTitle ? `Back to ${fromSeriesTitle}` : 'Back to Series'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Series Detail Card */}
|
||||||
|
{seriesLoading ? (
|
||||||
|
<SeriesDetailSkeleton squareCovers={squareCovers} />
|
||||||
|
) : series ? (
|
||||||
|
<SeriesDetailCard series={series} squareCovers={squareCovers} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 space-y-4">
|
||||||
|
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">Series not found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Similar Series */}
|
||||||
|
{seriesLoading ? (
|
||||||
|
<SimilarSeriesSkeleton squareCovers={squareCovers} />
|
||||||
|
) : series && series.similarSeries.length > 0 ? (
|
||||||
|
<SimilarSeriesRow series={series.similarSeries} currentSeriesTitle={series.title} squareCovers={squareCovers} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Books Section */}
|
||||||
|
{series && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Sticky Books Header */}
|
||||||
|
<div className="sticky top-14 sm:top-16 z-30">
|
||||||
|
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
Books in Series
|
||||||
|
</h2>
|
||||||
|
{series.books.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||||
|
({series.books.length} title{series.books.length !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||||
|
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Books Grid */}
|
||||||
|
<AudiobookGrid
|
||||||
|
audiobooks={series.books}
|
||||||
|
isLoading={seriesLoading}
|
||||||
|
emptyMessage={`No books found for ${series.title}`}
|
||||||
|
cardSize={cardSize}
|
||||||
|
squareCovers={squareCovers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Component: Series Page
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { SeriesGrid } from '@/components/series/SeriesGrid';
|
||||||
|
import { useSeriesSearch } from '@/lib/hooks/useSeries';
|
||||||
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
|
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||||
|
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||||
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
|
function SeriesPageContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const initialQuery = searchParams.get('q') || '';
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||||
|
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||||
|
|
||||||
|
// Debounce search query and sync to URL
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query);
|
||||||
|
// Update URL without adding history entries
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
router.replace(`/series?q=${encodeURIComponent(trimmed)}`, { scroll: false });
|
||||||
|
} else {
|
||||||
|
router.replace('/series', { scroll: false });
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, router]);
|
||||||
|
|
||||||
|
const { series, isLoading } = useSeriesSearch(debouncedQuery);
|
||||||
|
|
||||||
|
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Browse Series
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Search for your favorite audiobook series
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Form */}
|
||||||
|
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by series name..."
|
||||||
|
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{debouncedQuery ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Sticky Results Header */}
|
||||||
|
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||||
|
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
Series
|
||||||
|
</h2>
|
||||||
|
{!isLoading && series.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||||
|
({series.length} result{series.length !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||||
|
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series Grid */}
|
||||||
|
<SeriesGrid
|
||||||
|
series={series}
|
||||||
|
isLoading={!!isLoading}
|
||||||
|
emptyMessage={`No series found for "${debouncedQuery}"`}
|
||||||
|
cardSize={cardSize}
|
||||||
|
squareCovers={squareCovers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="text-center py-16 space-y-4">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-16 w-16 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
Start typing to search for series
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||||
|
Search by series name to discover audiobook collections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeriesPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<SeriesPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user