diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index e6ddeca..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,357 +0,0 @@
-# AGENTS.md - Project Standards & Workflow
-
-**Critical:** This document defines AI-optimized documentation standards and development workflow.
-
----
-
-## 1. Token-Efficient Documentation System
-
-### Why Token Efficiency Matters
-
-**Problem:** Documentation consumes significant token budget, leaving limited context for implementation.
-
-**Solution:** Documentation optimized for AI consumption, not human reading. Average 68-72% token reduction while preserving all technical details.
-
-### Documentation Structure
-
-```
-documentation/
-├── TABLEOFCONTENTS.md # Navigation index (read THIS FIRST)
-├── README.md # Project overview
-├── backend/
-│ ├── database.md # Schema, Prisma, migrations
-│ └── services/
-│ ├── auth.md # Plex OAuth, JWT, RBAC
-│ ├── config.md # Settings, encryption
-│ ├── jobs.md # Bull queue, processors
-│ └── scheduler.md # Cron jobs, recurring tasks
-├── integrations/
-│ ├── plex.md # Library scanning, OAuth, matching
-│ └── audible.md # Web scraping, metadata
-├── phase3/ # Automation pipeline
-│ ├── README.md # Pipeline overview
-│ ├── qbittorrent.md # Download client
-│ ├── prowlarr.md # Indexer search
-│ ├── ranking-algorithm.md # Torrent selection
-│ └── file-organization.md # File management, seeding
-├── frontend/
-│ ├── components.md # React components
-│ ├── routing-auth.md # Route protection
-│ └── pages/
-│ └── login.md # Login page design
-├── deployment/
-│ └── docker.md # Docker Compose, volumes
-└── [feature-specific docs]
-```
-
----
-
-## 2. Using TABLEOFCONTENTS.md (MANDATORY)
-
-### **RULE: Always Start Here**
-
-**Before reading ANY documentation:**
-1. **Read `documentation/TABLEOFCONTENTS.md` FIRST**
-2. Identify relevant sections for your task
-3. Read ONLY the specific files you need
-4. **Never read all files sequentially** (wastes tokens)
-
-### Example Workflow
-
-**Bad (Token wasteful):**
-```
-Task: Fix Plex authentication
-❌ Read README.md → backend/* → integrations/* → ...
-```
-
-**Good (Token efficient):**
-```
-Task: Fix Plex authentication
-✅ Read TABLEOFCONTENTS.md → Identify: backend/services/auth.md, integrations/plex.md
-✅ Read only those 2 files
-✅ Begin implementation
-```
-
-### TABLEOFCONTENTS.md Format
-
-Maps questions/features to specific documentation files:
-- "How does authentication work?" → backend/services/auth.md
-- "How do downloads work?" → phase3/qbittorrent.md, backend/services/jobs.md
-- Organized by: Authentication, Configuration, Database, Integrations, Automation, etc.
-
----
-
-## 3. Token-Efficient Documentation Format
-
-### Mandatory Format Standards
-
-**All documentation MUST follow this token-optimized format:**
-
-#### Structure
-```markdown
-# [Title]
-
-**Status:** [✅ Implemented / ⏳ In Progress / ❌ Not Started] [Brief description]
-
-## Overview
-[1-2 sentence summary]
-
-## Key Details
-- Compact bullet lists (not prose)
-- API endpoints with request/response
-- Data models with field names/types
-- Configuration keys
-- Critical implementation notes
-
-## API/Interfaces
-[Tables or compact code blocks]
-
-## Critical Issues (if any)
-[Only important items]
-
-## Related: [links to other docs]
-```
-
-#### Forbidden Content (Removed for Token Efficiency)
-- ❌ Verbose prose explanations
-- ❌ "Why?" sections (keep brief rationale only)
-- ❌ Large ASCII diagrams (minimal only)
-- ❌ Excessive examples (max 1-2)
-- ❌ "Future Enhancements" sections
-- ❌ "Testing Strategy" (unless critical)
-- ❌ "Performance Considerations" (unless critical)
-- ❌ Empty sections
-- ❌ Decorative formatting
-
-#### Required Content (Preserve Completely)
-- ✅ API endpoint definitions
-- ✅ Data model field names and types
-- ✅ Configuration keys and values
-- ✅ Status values and enums
-- ✅ File paths and code locations
-- ✅ Critical implementation details
-- ✅ "Fixed Issues" (troubleshooting context)
-- ✅ Essential code examples (1-2 max)
-
-### Format Examples
-
-**Before (Token wasteful - 180 lines):**
-```markdown
-# User Authentication Service
-
-## Current State
-
-**Status:** Implemented ✅
-
-This service handles all authentication and authorization logic for the
-ReadMeABook application, including Plex OAuth integration, JWT session
-management, and role-based access control.
-
-## Design Architecture
-
-### Why Plex OAuth?
-
-Plex OAuth was chosen for several important reasons:
-- No need to manage passwords
-- Users already have Plex accounts
-- Seamless integration with Plex ecosystem
-...
-[continues for 150+ more lines]
-```
-
-**After (Token efficient - 50 lines):**
-```markdown
-# User Authentication Service
-
-**Status:** ✅ Implemented | Plex OAuth + JWT sessions + RBAC
-
-## Overview
-Handles Plex OAuth, JWT session management, role-based access control (user/admin).
-
-## Key Details
-- **Auth:** Plex OAuth flow → JWT tokens (access: 1h, refresh: 7d)
-- **Roles:** user (requests only), admin (full access)
-- **First user:** Auto-promoted to admin
-- **Endpoints:**
- - POST /api/auth/plex/login → {authUrl, pinId}
- - GET /api/auth/plex/callback?pinId → {accessToken, refreshToken, user}
- - POST /api/auth/refresh → {accessToken}
- - GET /api/auth/me → {user}
-- **Middleware:** requireAuth(), requireAdmin()
-- **Storage:** HTTP-only cookies + localStorage
-
-## JWT Payload
-```json
-{
- "sub": "user-uuid",
- "plexId": "plex-id",
- "role": "admin",
- "exp": 1234571490
-}
-```
----
-
-## 4. Implementation Strategy
-
-### Step 1: Navigate with TABLEOFCONTENTS.md
-- Read TABLEOFCONTENTS.md to find relevant docs
-- Identify 1-3 specific files needed (not all docs)
-
-### Step 2: Read Minimal Context
-- Read ONLY the identified files
-- Focus on "Key Details" and "API/Interfaces" sections
-- Skip examples unless implementing similar functionality
-
-### Step 3: Reiterate Understanding
-- Brief paragraph (3-4 sentences max)
-- What user wants, what's affected, expected outcome
-
-### Step 4: Create Implementation Plan (TodoWrite)
-```
-- [ ] Read: [specific doc files]
-- [ ] Update: [specific doc files]
-- [ ] Implement: [specific changes]
-- [ ] Verify: [test steps]
-```
-
-### Step 5: Implement
-- Follow plan
-- Update docs using token-efficient format
-- Add file headers linking to docs
-
----
-
-## 5. Documentation Maintenance
-
-### **RULE: Update TABLEOFCONTENTS.md**
-
-**When adding new documentation:**
-1. Create doc file using token-efficient format
-2. **Update TABLEOFCONTENTS.md** with new mapping
-3. Update parent README.md if needed
-
-**Example:**
-```markdown
-# Added new feature: Email notifications
-
-Files created:
-- documentation/backend/services/notifications.md
-
-Updates required:
-- ✅ Create notifications.md (token-efficient format)
-- ✅ Add to TABLEOFCONTENTS.md: "Email notifications" → backend/services/notifications.md
-- ✅ Update documentation/README.md → Backend section
-```
-
-### **RULE: Keep Docs Up-to-Date**
-- **Before code changes:** Read relevant docs
-- **After code changes:** Update docs immediately
-- Use token-efficient format for all updates
-
----
-
-## 6. Code Standards
-
-### File Size Limits
-- Max 300-400 lines per file
-- Refactor if exceeding limit
-
-### Mandatory File Headers
-```typescript
-/**
- * Component: User Authentication Service
- * Documentation: documentation/backend/services/auth.md
- */
-```
-
-### Link Accuracy
-- Header path MUST point to existing doc file
-- Create doc BEFORE implementing code
-- Use relative paths from project root
-
----
-
-## 7. Token Budget Management
-
-### Critical Principle
-
-**Preserve tokens for implementation, not context gathering.**
-
-**Token Budget Allocation:**
-- 20-30%: Reading relevant documentation (via TABLEOFCONTENTS.md)
-- 70-80%: Implementation, problem-solving, code generation
-
-**Anti-Patterns (Token wasteful):**
-- ❌ Reading all documentation files
-- ❌ Reading verbose examples when not needed
-- ❌ Re-reading same docs multiple times
-- ❌ Reading "Future Enhancements" sections
-
-**Best Practices (Token efficient):**
-- ✅ Use TABLEOFCONTENTS.md to target specific files
-- ✅ Read only "Key Details" and "API/Interfaces" sections
-- ✅ Skip examples unless implementing similar code
-- ✅ Cache understanding in memory, don't re-read
-
----
-
-## 8. Examples
-
-### Example 1: Bug Fix
-
-**Task:** "Plex authentication fails with 403 error"
-
-**Process:**
-1. Read TABLEOFCONTENTS.md → Find: backend/services/auth.md, integrations/plex.md
-2. Read only those 2 files (focus on API endpoints, error handling)
-3. Identify issue: Token refresh logic
-4. Fix code
-5. Update backend/services/auth.md (token-efficient format)
-
-### Example 2: New Feature
-
-**Task:** "Add email notifications for completed requests"
-
-**Process:**
-1. Read TABLEOFCONTENTS.md → Find: backend/services/scheduler.md, backend/services/jobs.md
-2. Read those files for background job patterns
-3. Create documentation/backend/services/notifications.md (token-efficient format)
-4. Update TABLEOFCONTENTS.md: "Email notifications" → backend/services/notifications.md
-5. Implement notification service
-6. Add file header linking to notifications.md
-
----
-
-## 9. Quality Checklist
-
-Before completing any task:
-
-- [ ] Used TABLEOFCONTENTS.md to find docs (not read all files)
-- [ ] Read only necessary documentation
-- [ ] Updated documentation in token-efficient format
-- [ ] Updated TABLEOFCONTENTS.md if added new docs
-- [ ] Added file headers to new code files
-- [ ] No file exceeds 400 lines
-- [ ] Documentation matches implementation
-
----
-
-## 10. Summary
-
-**Key Points:**
-1. **Always start with TABLEOFCONTENTS.md** (navigation index)
-2. **Read only what you need** (not all docs)
-3. **Use token-efficient format** (bullets, tables, minimal prose)
-4. **Preserve tokens for implementation** (not context gathering)
-5. **Update docs immediately** (before/after code changes)
-6. **Update TABLEOFCONTENTS.md** (when adding new docs)
-
-**Result:**
-- 68-72% token reduction in documentation
-- Faster context gathering
-- More tokens available for implementation
-- Better AI performance on complex tasks
-
----
-
-**Remember:** Documentation is for AI consumption. Token efficiency is critical. Always use TABLEOFCONTENTS.md.
diff --git a/README.md b/README.md
index 2164ad0..fe34745 100644
--- a/README.md
+++ b/README.md
@@ -91,10 +91,24 @@ Feature and fix Contributions are highly welcome. Documentation in `documentatio
## Support
-If you find this project useful, consider supporting development via [GitHub Sponsors]()
+If you find this project useful, consider supporting development via [GitHub Sponsors](https://github.com/sponsors/kikootwo) or [Ko-fi](https://ko-fi.com/kikootwo).
If you'd like to support but cannot sponsor, a simple star on the GitHub repo is also greatly appreciated!
+## Built with AI Assistance
+
+This is a human-engineered application. Architecture, design decisions, code review, and project direction are managed by a principal engineer with nearly 15 years of professional software development experience.
+
+AI tools (Claude, GitHub Copilot) serve as force multipliers. Accelerating implementation, maintaining consistency, and handling boilerplate, while human expertise drives the technical vision. This mirrors how AI assistance is used at leading technology companies today.
+
+**The workflow:**
+- Token-optimized documentation system designed for AI consumption ([CLAUDE.md](CLAUDE.md))
+- Structured navigation enabling AI to find relevant context without reading entire codebases
+- Consistent architectural patterns that AI tools can follow and extend
+- Human review of all AI-generated code before merge
+
+The result: enterprise-grade velocity on a solo project without sacrificing code quality or architectural integrity.
+
---
diff --git a/docker/unified/app-start.sh b/docker/unified/app-start.sh
index c2270fa..5cbfcef 100644
--- a/docker/unified/app-start.sh
+++ b/docker/unified/app-start.sh
@@ -1,9 +1,44 @@
#!/bin/bash
# App startup wrapper for unified container
# Uses gosu to ensure correct PUID:PGID for file operations
+#
+# Supports:
+# - Docker: Uses gosu to switch to PUID:PGID
+# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
+# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
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:
+# - 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)
if [ -f /etc/environment ]; then
set -a
@@ -20,40 +55,62 @@ echo "[App] Process will run as UID:GID = $PUID:$PGID"
cd /app
-# Use gosu to switch to correct UID:GID and start server
-# This bypasses username resolution issues when PUID collides with existing users
-if [ "$(id -u)" = "0" ]; then
- # Running as root - use gosu to switch to PUID:PGID
- echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
+# =============================================================================
+# START SERVER WITH APPROPRIATE UID:GID HANDLING
+# =============================================================================
+# Three scenarios:
+# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
+# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
+# 3. Non-root fallback: Already running as non-root, run directly
- # Start server in background with gosu
- gosu "$PUID:$PGID" node server.js &
- SERVER_PID=$!
+start_server() {
+ if [ "$(id -u)" = "0" ]; then
+ if is_user_namespace_root; then
+ # Rootless container (e.g., rootless Podman)
+ # Skip gosu - the user namespace already maps our "root" to the correct host UID
+ 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 &
+ else
+ # Normal container (Docker or rootful Podman)
+ # Use gosu to switch to the specified PUID:PGID
+ echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
+ gosu "$PUID:$PGID" node server.js &
+ fi
+ else
+ # Not running as root - run directly (fallback for unusual configurations)
+ echo "[App] Warning: Not running as root, cannot use gosu. Running as current user ($(id -u):$(id -g))."
+ node server.js &
+ fi
+}
- echo "[App] Waiting for server to be ready..."
- sleep 5
+# Start the server in background
+start_server
+SERVER_PID=$!
- # Initialize application services (creates default scheduled jobs)
- echo "[App] Initializing application services..."
- curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
+echo "[App] Waiting for server to be ready..."
+sleep 5
- echo "[App] Server ready with PID $SERVER_PID (running as $PUID:$PGID)"
+# Initialize application services (creates default scheduled jobs)
+echo "[App] Initializing application services..."
+curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
- # Verify the process is running with correct UID:GID
- if [ -f "/proc/$SERVER_PID/status" ]; then
- ACTUAL_UID=$(grep '^Uid:' /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] Server ready with PID $SERVER_PID"
+# Verify the process is running with correct UID:GID (for debugging)
+if [ -f "/proc/$SERVER_PID/status" ]; then
+ ACTUAL_UID=$(grep '^Uid:' /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"
+
+ # Only warn about mismatch in non-rootless scenarios
+ if ! is_user_namespace_root; then
if [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; then
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
fi
fi
-
- # Wait for server process
- wait $SERVER_PID
-else
- # Not running as root - just run directly (fallback)
- echo "[App] Warning: Not running as root, cannot use gosu. Running as current user."
- exec node server.js
fi
+
+# Wait for server process (keeps the script running as long as the server is alive)
+wait $SERVER_PID
diff --git a/docker/unified/redis-start.sh b/docker/unified/redis-start.sh
index e6631a9..d78c436 100644
--- a/docker/unified/redis-start.sh
+++ b/docker/unified/redis-start.sh
@@ -1,9 +1,44 @@
#!/bin/bash
# Redis startup wrapper for unified container
# Uses gosu to ensure correct PUID:PGID for file operations
+#
+# Supports:
+# - Docker: Uses gosu to switch to PUID:PGID
+# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
+# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
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:
+# - 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)
if [ -f /etc/environment ]; then
set -a
@@ -18,14 +53,32 @@ PGID=${PGID:-$(id -g redis)}
echo "[Redis] Starting Redis server..."
echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
-# Use gosu to switch to correct UID:GID and start redis
-# This bypasses username resolution issues when PUID collides with existing users
+# =============================================================================
+# START REDIS WITH APPROPRIATE UID:GID HANDLING
+# =============================================================================
+# Three scenarios:
+# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
+# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve 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"
+
if [ "$(id -u)" = "0" ]; then
- # Running as root - use gosu to switch to PUID:PGID
- echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
- exec gosu "$PUID:$PGID" /usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379
+ if is_user_namespace_root; then
+ # Rootless container (e.g., rootless Podman)
+ # Skip gosu - the user namespace already maps our "root" to the correct host UID
+ 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
+ else
+ # Normal container (Docker or rootful Podman)
+ # Use gosu to switch to the specified PUID:PGID
+ echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
+ exec gosu "$PUID:$PGID" $REDIS_CMD
+ fi
else
- # Not running as root - just run directly (fallback)
- echo "[Redis] Warning: Not running as root, cannot use gosu. Running as current user."
- exec /usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379
+ # Not running as root - run directly (fallback for unusual configurations)
+ echo "[Redis] Warning: Not running as root, cannot use gosu. Running as current user ($(id -u):$(id -g))."
+ exec $REDIS_CMD
fi
diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md
index 893287c..194b1e1 100644
--- a/documentation/TABLEOFCONTENTS.md
+++ b/documentation/TABLEOFCONTENTS.md
@@ -25,13 +25,12 @@
- **OAuth, library scanning, fuzzy matching** → [integrations/plex.md](integrations/plex.md)
- **Availability status, plexGuid linking** → [integrations/plex.md](integrations/plex.md)
-## Audiobookshelf Integration (PRD - Not Implemented)
-- **Full PRD, architecture, implementation phases** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
-- **Step-by-step implementation guide** → [features/audiobookshelf-implementation-guide.md](features/audiobookshelf-implementation-guide.md)
-- **OIDC authentication (Authentik, Keycloak)** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
-- **Manual user registration** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
-- **Backend mode selection (Plex vs ABS)** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
-- **Library service abstraction** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
+## Audiobookshelf Integration
+- **ABS API client, library scanning** → `src/lib/services/audiobookshelf/api.ts`
+- **ABS library service** → `src/lib/services/library/AudiobookshelfLibraryService.ts`
+- **Backend mode selection (Plex vs ABS)** → [backend/services/config.md](backend/services/config.md)
+- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
+- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
## Audible Integration
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
@@ -138,10 +137,9 @@
**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
**"How does logging work?"** → [backend/services/logging.md](backend/services/logging.md)
**"How do BookDate card stack animations work?"** → [features/bookdate-animations.md](features/bookdate-animations.md)
-**"How does Audiobookshelf integration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
-**"How do I use OIDC/Authentik/Keycloak?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
-**"How does manual user registration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
-**"How do I switch from Plex to Audiobookshelf?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
+**"How does Audiobookshelf integration work?"** → `src/lib/services/audiobookshelf/api.ts`, `src/lib/services/library/AudiobookshelfLibraryService.ts`
+**"How do I use OIDC/Authentik/Keycloak?"** → [backend/services/auth.md](backend/services/auth.md)
+**"How do I switch from Plex to Audiobookshelf?"** → Setup wizard (re-run setup with different backend mode)
**"How does library thumbnail caching work?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
diff --git a/documentation/backend/services/environment.md b/documentation/backend/services/environment.md
index eadbbd1..1e13c21 100644
--- a/documentation/backend/services/environment.md
+++ b/documentation/backend/services/environment.md
@@ -194,6 +194,6 @@ environment:
## Related
- OAuth Implementation: documentation/backend/services/auth.md
-- OIDC Configuration: documentation/features/audiobookshelf-integration.md
+- OIDC Configuration: documentation/backend/services/auth.md
- Deployment: documentation/deployment/unified.md
- Setup Middleware: documentation/backend/middleware.md
diff --git a/documentation/features/audiobookshelf-implementation-guide.md b/documentation/features/audiobookshelf-implementation-guide.md
deleted file mode 100644
index cbf5fa3..0000000
--- a/documentation/features/audiobookshelf-implementation-guide.md
+++ /dev/null
@@ -1,2115 +0,0 @@
-# Audiobookshelf Integration - Implementation Guide
-
-**Purpose:** Step-by-step implementation instructions for AI agents to build the Audiobookshelf integration feature.
-
-**Prerequisites:**
-- Read the full PRD: `documentation/features/audiobookshelf-integration.md`
-- Understand current architecture via `documentation/TABLEOFCONTENTS.md`
-
-**Critical Rules:**
-1. Complete each phase fully before moving to the next
-2. Run tests after each phase to verify no regressions
-3. Existing Plex functionality must remain unchanged
-4. Follow existing code patterns and file structure conventions
-5. Update documentation as you implement
-6. Keep files under 400 lines - split if needed
-
----
-
-## Phase 1: Foundation (Abstraction Layer)
-
-### 1.1 Create Library Service Interface
-
-**Goal:** Abstract library operations so both Plex and Audiobookshelf can be used interchangeably.
-
-**Create file:** `src/lib/services/library/ILibraryService.ts`
-
-```typescript
-/**
- * Library Service Interface
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-export interface ServerInfo {
- name: string;
- version: string;
- platform?: string;
- identifier: string; // machineIdentifier (Plex) or serverId (ABS)
-}
-
-export interface Library {
- id: string;
- name: string;
- type: string;
- itemCount?: number;
-}
-
-export interface LibraryItem {
- id: string; // ratingKey (Plex) or item id (ABS)
- externalId: string; // plexGuid or abs_item_id
- title: string;
- author: string;
- narrator?: string;
- description?: string;
- coverUrl?: string;
- duration?: number; // seconds
- asin?: string;
- isbn?: string;
- year?: number;
- addedAt: Date;
- updatedAt: Date;
-}
-
-export interface LibraryConnectionResult {
- success: boolean;
- serverInfo?: ServerInfo;
- error?: string;
-}
-
-export interface ILibraryService {
- // Connection
- testConnection(): Promise;
- getServerInfo(): Promise;
-
- // Libraries
- getLibraries(): Promise;
- getLibraryItems(libraryId: string): Promise;
- getRecentlyAdded(libraryId: string, limit: number): Promise;
-
- // Items
- getItem(itemId: string): Promise;
- searchItems(libraryId: string, query: string): Promise;
-
- // Scanning
- triggerLibraryScan(libraryId: string): Promise;
-}
-```
-
-### 1.2 Refactor Existing Plex Code into PlexLibraryService
-
-**Goal:** Move existing Plex library logic into the new interface structure.
-
-**Create file:** `src/lib/services/library/PlexLibraryService.ts`
-
-**Instructions:**
-1. Read existing Plex integration code in `src/lib/services/plex/` or similar
-2. Identify all library-related functions (getLibraries, scanLibrary, getItems, etc.)
-3. Implement `ILibraryService` interface using existing Plex logic
-4. Do NOT delete original code yet - keep for reference
-5. Map Plex data structures to the generic `LibraryItem` interface:
- - `ratingKey` → `id`
- - `guid` → `externalId`
- - `parentTitle` → `author`
- - `grandparentTitle` or metadata → `narrator`
-
-**Key mapping:**
-```typescript
-function mapPlexItemToLibraryItem(plexItem: PlexAudiobook): LibraryItem {
- return {
- id: plexItem.ratingKey,
- externalId: plexItem.guid,
- title: plexItem.title,
- author: plexItem.author, // from parentTitle
- narrator: plexItem.narrator,
- description: plexItem.summary,
- coverUrl: plexItem.thumb,
- duration: plexItem.duration ? Math.floor(plexItem.duration / 1000) : undefined,
- asin: extractAsinFromGuid(plexItem.guid),
- year: plexItem.year,
- addedAt: new Date(plexItem.addedAt * 1000),
- updatedAt: new Date(plexItem.updatedAt * 1000),
- };
-}
-```
-
-### 1.3 Create Library Service Factory
-
-**Create file:** `src/lib/services/library/index.ts`
-
-```typescript
-/**
- * Library Service Factory
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { ILibraryService } from './ILibraryService';
-import { PlexLibraryService } from './PlexLibraryService';
-// import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService'; // Phase 2
-
-export async function getLibraryService(): Promise {
- // TODO: Read from config once backend mode is implemented
- // const mode = await getConfig('system.backend_mode');
- // if (mode === 'audiobookshelf') {
- // return new AudiobookshelfLibraryService();
- // }
- return new PlexLibraryService();
-}
-
-export * from './ILibraryService';
-```
-
-### 1.4 Create Auth Provider Interface
-
-**Create file:** `src/lib/services/auth/IAuthProvider.ts`
-
-```typescript
-/**
- * Auth Provider Interface
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-export interface UserInfo {
- id: string; // External ID (plexId, oidc subject, or local username)
- username: string;
- email?: string;
- avatarUrl?: string;
- isAdmin?: boolean; // From claims or first-user logic
-}
-
-export interface AuthTokens {
- accessToken: string;
- refreshToken: string;
-}
-
-export interface LoginInitiation {
- redirectUrl?: string; // For OAuth/OIDC flows
- pinId?: string; // For Plex PIN flow
- state?: string; // CSRF state token
-}
-
-export interface CallbackParams {
- code?: string; // Authorization code
- state?: string; // CSRF state
- pinId?: string; // Plex PIN
- error?: string;
-}
-
-export interface AuthResult {
- success: boolean;
- user?: UserInfo;
- tokens?: AuthTokens;
- error?: string;
- requiresApproval?: boolean; // For pending approval flow
- requiresProfileSelection?: boolean; // For Plex Home
- profiles?: any[]; // Plex Home profiles
-}
-
-export interface IAuthProvider {
- type: 'plex' | 'oidc' | 'local';
-
- // Auth initiation
- initiateLogin(): Promise;
-
- // Auth completion
- handleCallback(params: CallbackParams): Promise;
-
- // Token refresh
- refreshToken(refreshToken: string): Promise;
-
- // Validation
- validateAccess(userInfo: UserInfo): Promise;
-}
-```
-
-### 1.5 Refactor Plex OAuth into PlexAuthProvider
-
-**Create file:** `src/lib/services/auth/PlexAuthProvider.ts`
-
-**Instructions:**
-1. Read existing auth code in `src/lib/services/auth.ts` or `src/app/api/auth/plex/`
-2. Extract Plex OAuth logic into `PlexAuthProvider` implementing `IAuthProvider`
-3. Keep existing Plex Home profile support
-4. Map Plex user data to generic `UserInfo` interface
-
-### 1.6 Create Auth Provider Factory
-
-**Create file:** `src/lib/services/auth/index.ts`
-
-```typescript
-/**
- * Auth Provider Factory
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { IAuthProvider } from './IAuthProvider';
-import { PlexAuthProvider } from './PlexAuthProvider';
-// import { OIDCAuthProvider } from './OIDCAuthProvider'; // Phase 3
-// import { LocalAuthProvider } from './LocalAuthProvider'; // Phase 4
-
-export type AuthMethod = 'plex' | 'oidc' | 'local';
-
-export async function getAuthProvider(method?: AuthMethod): Promise {
- // TODO: Read from config once backend mode is implemented
- // const mode = await getConfig('system.backend_mode');
- // const authMethod = method || await getConfig('auth.method');
-
- // if (authMethod === 'oidc') return new OIDCAuthProvider();
- // if (authMethod === 'local') return new LocalAuthProvider();
-
- return new PlexAuthProvider();
-}
-
-export * from './IAuthProvider';
-```
-
-### 1.7 Update Database Schema
-
-**Modify:** `prisma/schema.prisma`
-
-Add new fields to User model:
-```prisma
-model User {
- // ... existing fields ...
-
- // New fields for multi-auth support
- authProvider String? @map("auth_provider") // 'plex' | 'oidc' | 'local'
- oidcSubject String? @map("oidc_subject") // OIDC subject ID
- oidcProvider String? @map("oidc_provider") // e.g., 'authentik'
- registrationStatus String? @map("registration_status") // 'pending_approval' | 'approved' | 'rejected'
-}
-```
-
-Add new Configuration keys (will be set during setup):
-```prisma
-// These are stored in Configuration table, not schema changes
-// system.backend_mode = 'plex' | 'audiobookshelf'
-```
-
-**Run migration:**
-```bash
-npx prisma db push
-```
-
-### 1.8 Add Backend Mode Config Helper
-
-**Modify:** `src/lib/services/config.service.ts` (or create if doesn't exist)
-
-Add function to get backend mode:
-```typescript
-export async function getBackendMode(): Promise<'plex' | 'audiobookshelf'> {
- const config = await prisma.configuration.findUnique({
- where: { key: 'system.backend_mode' }
- });
- return (config?.value as 'plex' | 'audiobookshelf') || 'plex';
-}
-
-export async function isAudiobookshelfMode(): Promise {
- return (await getBackendMode()) === 'audiobookshelf';
-}
-```
-
-### 1.9 Phase 1 Verification
-
-**Tests to run:**
-1. Existing Plex authentication still works
-2. Existing library scanning still works
-3. All existing tests pass
-4. New interfaces compile without errors
-
-**Checklist:**
-- [ ] `ILibraryService` interface created
-- [ ] `PlexLibraryService` implements interface with existing logic
-- [ ] `IAuthProvider` interface created
-- [ ] `PlexAuthProvider` implements interface with existing logic
-- [ ] Factory functions created for both services
-- [ ] Database schema updated with new fields
-- [ ] All existing functionality unchanged
-
----
-
-## Phase 2: Audiobookshelf Library Integration
-
-### 2.1 Create Audiobookshelf API Client
-
-**Create file:** `src/lib/services/audiobookshelf/api.ts`
-
-```typescript
-/**
- * Audiobookshelf API Client
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { getConfig } from '../config.service';
-
-interface ABSRequestOptions {
- method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
- body?: any;
-}
-
-export async function absRequest(endpoint: string, options: ABSRequestOptions = {}): Promise {
- const serverUrl = await getConfig('abs.server_url');
- const apiToken = await getConfig('abs.api_token', true); // true = decrypt
-
- if (!serverUrl || !apiToken) {
- throw new Error('Audiobookshelf not configured');
- }
-
- const url = `${serverUrl.replace(/\/$/, '')}/api${endpoint}`;
-
- const response = await fetch(url, {
- method: options.method || 'GET',
- headers: {
- 'Authorization': `Bearer ${apiToken}`,
- 'Content-Type': 'application/json',
- },
- body: options.body ? JSON.stringify(options.body) : undefined,
- });
-
- if (!response.ok) {
- throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
- }
-
- return response.json();
-}
-
-// API endpoint wrappers
-export async function getABSServerInfo() {
- return absRequest<{ version: string; name: string }>('/status');
-}
-
-export async function getABSLibraries() {
- const result = await absRequest<{ libraries: any[] }>('/libraries');
- return result.libraries;
-}
-
-export async function getABSLibraryItems(libraryId: string) {
- const result = await absRequest<{ results: any[] }>(`/libraries/${libraryId}/items`);
- return result.results;
-}
-
-export async function getABSRecentItems(libraryId: string, limit: number) {
- const result = await absRequest<{ results: any[] }>(
- `/libraries/${libraryId}/items?sort=addedAt&desc=1&limit=${limit}`
- );
- return result.results;
-}
-
-export async function getABSItem(itemId: string) {
- return absRequest(`/items/${itemId}`);
-}
-
-export async function searchABSItems(libraryId: string, query: string) {
- const result = await absRequest<{ book: any[] }>(
- `/libraries/${libraryId}/search?q=${encodeURIComponent(query)}`
- );
- return result.book || [];
-}
-
-export async function triggerABSScan(libraryId: string) {
- await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' });
-}
-```
-
-### 2.2 Create Audiobookshelf Type Definitions
-
-**Create file:** `src/lib/services/audiobookshelf/types.ts`
-
-```typescript
-/**
- * Audiobookshelf Type Definitions
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-export interface ABSLibrary {
- id: string;
- name: string;
- mediaType: 'book' | 'podcast';
- folders: { id: string; fullPath: string }[];
-}
-
-export interface ABSBookMetadata {
- title: string;
- subtitle?: string;
- authorName: string;
- authorNameLF?: string;
- narratorName?: string;
- seriesName?: string;
- genres: string[];
- publishedYear?: string;
- description?: string;
- isbn?: string;
- asin?: string;
- language?: string;
- explicit: boolean;
-}
-
-export interface ABSAudioFile {
- index: number;
- ino: string;
- metadata: {
- filename: string;
- ext: string;
- path: string;
- size: number;
- mtimeMs: number;
- };
- duration: number;
-}
-
-export interface ABSLibraryItem {
- id: string;
- ino: string;
- libraryId: string;
- folderId: string;
- path: string;
- relPath: string;
- isFile: boolean;
- mtimeMs: number;
- ctimeMs: number;
- birthtimeMs: number;
- addedAt: number;
- updatedAt: number;
- isMissing: boolean;
- isInvalid: boolean;
- mediaType: 'book';
- media: {
- metadata: ABSBookMetadata;
- coverPath?: string;
- audioFiles: ABSAudioFile[];
- duration: number;
- size: number;
- numTracks: number;
- numAudioFiles: number;
- };
- numFiles: number;
- size: number;
-}
-```
-
-### 2.3 Create AudiobookshelfLibraryService
-
-**Create file:** `src/lib/services/library/AudiobookshelfLibraryService.ts`
-
-```typescript
-/**
- * Audiobookshelf Library Service
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import {
- ILibraryService,
- LibraryConnectionResult,
- ServerInfo,
- Library,
- LibraryItem,
-} from './ILibraryService';
-import {
- absRequest,
- getABSServerInfo,
- getABSLibraries,
- getABSLibraryItems,
- getABSRecentItems,
- getABSItem,
- searchABSItems,
- triggerABSScan,
-} from '../audiobookshelf/api';
-import { ABSLibraryItem } from '../audiobookshelf/types';
-
-export class AudiobookshelfLibraryService implements ILibraryService {
-
- async testConnection(): Promise {
- try {
- const serverInfo = await this.getServerInfo();
- return {
- success: true,
- serverInfo,
- };
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- };
- }
- }
-
- async getServerInfo(): Promise {
- const info = await getABSServerInfo();
- return {
- name: info.name || 'Audiobookshelf',
- version: info.version,
- identifier: info.name, // ABS doesn't have unique identifier like Plex
- };
- }
-
- async getLibraries(): Promise {
- const libraries = await getABSLibraries();
- return libraries
- .filter((lib: any) => lib.mediaType === 'book') // Only audiobook libraries
- .map((lib: any) => ({
- id: lib.id,
- name: lib.name,
- type: lib.mediaType,
- itemCount: lib.stats?.totalItems,
- }));
- }
-
- async getLibraryItems(libraryId: string): Promise {
- const items = await getABSLibraryItems(libraryId);
- return items.map(this.mapABSItemToLibraryItem);
- }
-
- async getRecentlyAdded(libraryId: string, limit: number): Promise {
- const items = await getABSRecentItems(libraryId, limit);
- return items.map(this.mapABSItemToLibraryItem);
- }
-
- async getItem(itemId: string): Promise {
- try {
- const item = await getABSItem(itemId);
- return this.mapABSItemToLibraryItem(item);
- } catch {
- return null;
- }
- }
-
- async searchItems(libraryId: string, query: string): Promise {
- const items = await searchABSItems(libraryId, query);
- return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
- }
-
- async triggerLibraryScan(libraryId: string): Promise {
- await triggerABSScan(libraryId);
- }
-
- private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
- const metadata = item.media.metadata;
- return {
- id: item.id,
- externalId: item.id, // ABS item ID is the external ID
- title: metadata.title,
- author: metadata.authorName,
- narrator: metadata.narratorName,
- description: metadata.description,
- coverUrl: item.media.coverPath ? `/api/items/${item.id}/cover` : undefined,
- duration: item.media.duration,
- asin: metadata.asin,
- isbn: metadata.isbn,
- year: metadata.publishedYear ? parseInt(metadata.publishedYear) : undefined,
- addedAt: new Date(item.addedAt),
- updatedAt: new Date(item.updatedAt),
- };
- }
-}
-```
-
-### 2.4 Update Library Service Factory
-
-**Modify:** `src/lib/services/library/index.ts`
-
-```typescript
-import { ILibraryService } from './ILibraryService';
-import { PlexLibraryService } from './PlexLibraryService';
-import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
-import { getBackendMode } from '../config.service';
-
-export async function getLibraryService(): Promise {
- const mode = await getBackendMode();
- if (mode === 'audiobookshelf') {
- return new AudiobookshelfLibraryService();
- }
- return new PlexLibraryService();
-}
-```
-
-### 2.5 Create ABS Setup Test Endpoint
-
-**Create file:** `src/app/api/setup/test-abs/route.ts`
-
-```typescript
-/**
- * Test Audiobookshelf Connection
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextRequest, NextResponse } from 'next/server';
-
-export async function POST(request: NextRequest) {
- try {
- const { serverUrl, apiToken } = await request.json();
-
- if (!serverUrl || !apiToken) {
- return NextResponse.json(
- { error: 'Server URL and API token are required' },
- { status: 400 }
- );
- }
-
- // Test connection
- const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/status`, {
- headers: {
- 'Authorization': `Bearer ${apiToken}`,
- },
- });
-
- if (!response.ok) {
- return NextResponse.json(
- { error: `Connection failed: ${response.status} ${response.statusText}` },
- { status: 400 }
- );
- }
-
- const serverInfo = await response.json();
-
- // Get libraries
- const libResponse = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
- headers: {
- 'Authorization': `Bearer ${apiToken}`,
- },
- });
-
- const libData = await libResponse.json();
- const libraries = libData.libraries
- .filter((lib: any) => lib.mediaType === 'book')
- .map((lib: any) => ({
- id: lib.id,
- name: lib.name,
- itemCount: lib.stats?.totalItems || 0,
- }));
-
- return NextResponse.json({
- success: true,
- serverInfo: {
- name: serverInfo.name || 'Audiobookshelf',
- version: serverInfo.version,
- },
- libraries,
- });
- } catch (error) {
- return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Connection failed' },
- { status: 500 }
- );
- }
-}
-```
-
-### 2.6 Update Library Scanning Jobs
-
-**Modify existing scan jobs** to use the abstraction layer:
-
-Find files like `src/lib/jobs/processors/plex-scan.ts` or similar.
-
-Replace direct Plex calls with:
-```typescript
-import { getLibraryService } from '@/lib/services/library';
-
-async function scanLibrary() {
- const libraryService = await getLibraryService();
- const items = await libraryService.getLibraryItems(libraryId);
- // ... rest of scanning logic using generic LibraryItem interface
-}
-```
-
-### 2.7 Update Audiobook Matcher for ABS
-
-**Modify:** `src/lib/utils/audiobook-matcher.ts` (or create if doesn't exist)
-
-Enhance matching to use ASIN/ISBN from ABS:
-```typescript
-export function matchAudiobook(
- request: { title: string; author: string; asin?: string; isbn?: string },
- libraryItems: LibraryItem[]
-): LibraryItem | null {
- // 1. Exact ASIN match (highest confidence)
- if (request.asin) {
- const asinMatch = libraryItems.find(item =>
- item.asin?.toLowerCase() === request.asin?.toLowerCase()
- );
- if (asinMatch) return asinMatch;
- }
-
- // 2. Exact ISBN match
- if (request.isbn) {
- const isbnMatch = libraryItems.find(item =>
- item.isbn?.replace(/-/g, '') === request.isbn?.replace(/-/g, '')
- );
- if (isbnMatch) return isbnMatch;
- }
-
- // 3. Fuzzy title/author match (existing logic)
- return fuzzyMatch(request, libraryItems);
-}
-```
-
-### 2.8 Phase 2 Verification
-
-**Tests to run:**
-1. ABS connection test endpoint works
-2. ABS library scanning retrieves items
-3. ABS recently added works
-4. Plex mode still works unchanged
-5. Matching works with ASIN/ISBN
-
-**Checklist:**
-- [ ] ABS API client created
-- [ ] ABS types defined
-- [ ] `AudiobookshelfLibraryService` implements interface
-- [ ] Test endpoint for ABS connection
-- [ ] Scan jobs use abstraction layer
-- [ ] Matcher enhanced for ASIN/ISBN
-
----
-
-## Phase 3: OIDC Authentication
-
-### 3.1 Install OIDC Dependencies
-
-```bash
-npm install openid-client
-```
-
-### 3.2 Create OIDC Provider Service
-
-**Create file:** `src/lib/services/auth/OIDCAuthProvider.ts`
-
-```typescript
-/**
- * OIDC Auth Provider
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { Issuer, Client, generators } from 'openid-client';
-import { getConfig, setConfig } from '../config.service';
-import {
- IAuthProvider,
- LoginInitiation,
- CallbackParams,
- AuthResult,
- UserInfo,
- AuthTokens,
-} from './IAuthProvider';
-
-export class OIDCAuthProvider implements IAuthProvider {
- type: 'oidc' = 'oidc';
- private client: Client | null = null;
-
- private async getClient(): Promise {
- if (this.client) return this.client;
-
- const issuerUrl = await getConfig('oidc.issuer_url');
- const clientId = await getConfig('oidc.client_id');
- const clientSecret = await getConfig('oidc.client_secret', true);
- const redirectUri = await this.getRedirectUri();
-
- const issuer = await Issuer.discover(issuerUrl);
-
- this.client = new issuer.Client({
- client_id: clientId,
- client_secret: clientSecret,
- redirect_uris: [redirectUri],
- response_types: ['code'],
- });
-
- return this.client;
- }
-
- private async getRedirectUri(): Promise {
- const baseUrl = process.env.NEXTAUTH_URL || process.env.BASE_URL || 'http://localhost:3000';
- return `${baseUrl}/api/auth/oidc/callback`;
- }
-
- async initiateLogin(): Promise {
- const client = await this.getClient();
- const state = generators.state();
- const nonce = generators.nonce();
- const codeVerifier = generators.codeVerifier();
- const codeChallenge = generators.codeChallenge(codeVerifier);
-
- // Store state/nonce/verifier in session or encrypted cookie
- // This is a simplified example - use proper session storage
- await setConfig('oidc.pending_state', state);
- await setConfig('oidc.pending_nonce', nonce);
- await setConfig('oidc.pending_verifier', codeVerifier);
-
- const redirectUrl = client.authorizationUrl({
- scope: 'openid profile email groups',
- state,
- nonce,
- code_challenge: codeChallenge,
- code_challenge_method: 'S256',
- });
-
- return { redirectUrl, state };
- }
-
- async handleCallback(params: CallbackParams): Promise {
- try {
- const client = await this.getClient();
- const redirectUri = await this.getRedirectUri();
-
- // Retrieve stored values
- const expectedState = await getConfig('oidc.pending_state');
- const nonce = await getConfig('oidc.pending_nonce');
- const codeVerifier = await getConfig('oidc.pending_verifier');
-
- if (params.state !== expectedState) {
- return { success: false, error: 'Invalid state parameter' };
- }
-
- const tokenSet = await client.callback(redirectUri, { code: params.code, state: params.state }, {
- code_verifier: codeVerifier,
- nonce,
- });
-
- const userinfo = await client.userinfo(tokenSet.access_token!);
-
- // Check access control
- const hasAccess = await this.checkAccessControl(userinfo);
- if (!hasAccess) {
- return {
- success: false,
- error: 'You do not have access to this application'
- };
- }
-
- // Map to UserInfo
- const user: UserInfo = {
- id: userinfo.sub,
- username: userinfo.preferred_username || userinfo.email || userinfo.sub,
- email: userinfo.email as string | undefined,
- avatarUrl: userinfo.picture as string | undefined,
- isAdmin: await this.checkAdminClaim(userinfo),
- };
-
- // Check if admin approval required
- const accessMethod = await getConfig('oidc.access_control_method');
- if (accessMethod === 'admin_approval') {
- const existingUser = await this.findExistingUser(user.id);
- if (!existingUser) {
- // Create pending user
- await this.createPendingUser(user);
- return { success: false, requiresApproval: true };
- }
- if (existingUser.registrationStatus === 'pending_approval') {
- return { success: false, requiresApproval: true };
- }
- }
-
- // Generate session tokens
- const tokens = await this.generateSessionTokens(user);
-
- return { success: true, user, tokens };
- } catch (error) {
- return {
- success: false,
- error: error instanceof Error ? error.message : 'Authentication failed'
- };
- }
- }
-
- private async checkAccessControl(userinfo: any): Promise {
- const method = await getConfig('oidc.access_control_method');
-
- switch (method) {
- case 'open':
- return true;
-
- case 'group_claim': {
- const claimName = await getConfig('oidc.access_group_claim') || 'groups';
- const requiredGroup = await getConfig('oidc.access_group_value');
- const userGroups = userinfo[claimName] || [];
- return Array.isArray(userGroups) && userGroups.includes(requiredGroup);
- }
-
- case 'allowed_list': {
- const allowedEmails = JSON.parse(await getConfig('oidc.allowed_emails') || '[]');
- const allowedUsernames = JSON.parse(await getConfig('oidc.allowed_usernames') || '[]');
- return (
- allowedEmails.includes(userinfo.email) ||
- allowedUsernames.includes(userinfo.preferred_username)
- );
- }
-
- case 'admin_approval':
- return true; // Handled separately
-
- default:
- return false;
- }
- }
-
- private async checkAdminClaim(userinfo: any): Promise {
- const enabled = await getConfig('oidc.admin_claim_enabled');
- if (enabled !== 'true') {
- // First user becomes admin logic handled elsewhere
- return false;
- }
-
- const claimName = await getConfig('oidc.admin_claim_name') || 'groups';
- const claimValue = await getConfig('oidc.admin_claim_value');
- const userClaims = userinfo[claimName] || [];
-
- if (Array.isArray(userClaims)) {
- return userClaims.includes(claimValue);
- }
- return userClaims === claimValue;
- }
-
- async refreshToken(refreshToken: string): Promise {
- // Implement JWT refresh logic (reuse existing JWT refresh code)
- return null;
- }
-
- async validateAccess(userInfo: UserInfo): Promise {
- return true; // Already validated in handleCallback
- }
-
- private async findExistingUser(oidcSubject: string) {
- // Query database for user with this OIDC subject
- return null; // Implement with Prisma
- }
-
- private async createPendingUser(user: UserInfo) {
- // Create user with registrationStatus: 'pending_approval'
- // Implement with Prisma
- }
-
- private async generateSessionTokens(user: UserInfo): Promise {
- // Reuse existing JWT generation logic
- return { accessToken: '', refreshToken: '' };
- }
-}
-```
-
-### 3.3 Create OIDC Login Endpoint
-
-**Create file:** `src/app/api/auth/oidc/login/route.ts`
-
-```typescript
-/**
- * OIDC Login Initiation
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextResponse } from 'next/server';
-import { OIDCAuthProvider } from '@/lib/services/auth/OIDCAuthProvider';
-
-export async function GET() {
- try {
- const provider = new OIDCAuthProvider();
- const { redirectUrl } = await provider.initiateLogin();
-
- return NextResponse.redirect(redirectUrl!);
- } catch (error) {
- return NextResponse.json(
- { error: 'Failed to initiate login' },
- { status: 500 }
- );
- }
-}
-```
-
-### 3.4 Create OIDC Callback Endpoint
-
-**Create file:** `src/app/api/auth/oidc/callback/route.ts`
-
-```typescript
-/**
- * OIDC Callback Handler
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextRequest, NextResponse } from 'next/server';
-import { OIDCAuthProvider } from '@/lib/services/auth/OIDCAuthProvider';
-import { createOrUpdateUser, generateJWT } from '@/lib/services/auth';
-
-export async function GET(request: NextRequest) {
- const searchParams = request.nextUrl.searchParams;
- const code = searchParams.get('code');
- const state = searchParams.get('state');
- const error = searchParams.get('error');
-
- if (error) {
- return NextResponse.redirect(`/login?error=${encodeURIComponent(error)}`);
- }
-
- try {
- const provider = new OIDCAuthProvider();
- const result = await provider.handleCallback({ code: code!, state: state! });
-
- if (!result.success) {
- if (result.requiresApproval) {
- return NextResponse.redirect('/login?pending=approval');
- }
- return NextResponse.redirect(`/login?error=${encodeURIComponent(result.error || 'Authentication failed')}`);
- }
-
- // Create or update user in database
- const dbUser = await createOrUpdateUser({
- authProvider: 'oidc',
- oidcSubject: result.user!.id,
- username: result.user!.username,
- email: result.user!.email,
- avatarUrl: result.user!.avatarUrl,
- isAdmin: result.user!.isAdmin,
- });
-
- // Generate JWT tokens
- const tokens = await generateJWT(dbUser);
-
- // Set cookies and redirect
- const response = NextResponse.redirect('/');
- response.cookies.set('accessToken', tokens.accessToken, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'strict',
- maxAge: 60 * 60, // 1 hour
- });
- response.cookies.set('refreshToken', tokens.refreshToken, {
- httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- sameSite: 'strict',
- maxAge: 60 * 60 * 24 * 7, // 7 days
- });
-
- return response;
- } catch (error) {
- console.error('OIDC callback error:', error);
- return NextResponse.redirect('/login?error=auth_failed');
- }
-}
-```
-
-### 3.5 Create OIDC Test Endpoint
-
-**Create file:** `src/app/api/setup/test-oidc/route.ts`
-
-```typescript
-/**
- * Test OIDC Configuration
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextRequest, NextResponse } from 'next/server';
-import { Issuer } from 'openid-client';
-
-export async function POST(request: NextRequest) {
- try {
- const { issuerUrl, clientId, clientSecret } = await request.json();
-
- if (!issuerUrl || !clientId || !clientSecret) {
- return NextResponse.json(
- { error: 'Issuer URL, Client ID, and Client Secret are required' },
- { status: 400 }
- );
- }
-
- // Discover OIDC endpoints
- const issuer = await Issuer.discover(issuerUrl);
-
- return NextResponse.json({
- success: true,
- issuer: {
- issuer: issuer.issuer,
- authorizationEndpoint: issuer.metadata.authorization_endpoint,
- tokenEndpoint: issuer.metadata.token_endpoint,
- userinfoEndpoint: issuer.metadata.userinfo_endpoint,
- },
- });
- } catch (error) {
- return NextResponse.json(
- { error: error instanceof Error ? error.message : 'OIDC discovery failed' },
- { status: 500 }
- );
- }
-}
-```
-
-### 3.6 Phase 3 Verification
-
-**Tests to run:**
-1. OIDC discovery works with test provider
-2. OIDC login redirects correctly
-3. OIDC callback creates user
-4. Group claim access control works
-5. Admin claim mapping works
-6. Plex auth still works unchanged
-
-**Checklist:**
-- [ ] `openid-client` installed
-- [ ] `OIDCAuthProvider` implements interface
-- [ ] Login endpoint initiates flow
-- [ ] Callback endpoint handles response
-- [ ] Access control (group claim) works
-- [ ] Test endpoint validates OIDC config
-
----
-
-## Phase 4: Manual Registration
-
-### 4.1 Create Local Auth Provider
-
-**Create file:** `src/lib/services/auth/LocalAuthProvider.ts`
-
-```typescript
-/**
- * Local Auth Provider (Username/Password)
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import bcrypt from 'bcrypt';
-import { prisma } from '@/lib/prisma';
-import {
- IAuthProvider,
- LoginInitiation,
- CallbackParams,
- AuthResult,
- UserInfo,
- AuthTokens,
-} from './IAuthProvider';
-import { generateJWT } from './jwt';
-import { getConfig } from '../config.service';
-
-interface LocalLoginParams extends CallbackParams {
- username: string;
- password: string;
-}
-
-interface RegisterParams {
- username: string;
- password: string;
-}
-
-export class LocalAuthProvider implements IAuthProvider {
- type: 'local' = 'local';
-
- async initiateLogin(): Promise {
- // Local auth doesn't need initiation - return empty
- return {};
- }
-
- async handleCallback(params: CallbackParams): Promise {
- // This handles login with username/password
- const { username, password } = params as LocalLoginParams;
-
- if (!username || !password) {
- return { success: false, error: 'Username and password required' };
- }
-
- // Find user
- const user = await prisma.user.findFirst({
- where: {
- plexUsername: username,
- authProvider: 'local',
- },
- });
-
- if (!user) {
- return { success: false, error: 'Invalid username or password' };
- }
-
- // Check registration status
- if (user.registrationStatus === 'pending_approval') {
- return { success: false, requiresApproval: true };
- }
-
- if (user.registrationStatus === 'rejected') {
- return { success: false, error: 'Account has been rejected' };
- }
-
- // Verify password
- const passwordValid = await bcrypt.compare(password, user.authToken || '');
- if (!passwordValid) {
- return { success: false, error: 'Invalid username or password' };
- }
-
- // Generate tokens
- const tokens = await generateJWT({
- id: user.id,
- username: user.plexUsername,
- role: user.role,
- });
-
- return {
- success: true,
- user: {
- id: user.id,
- username: user.plexUsername,
- isAdmin: user.role === 'admin',
- },
- tokens,
- };
- }
-
- async register(params: RegisterParams): Promise {
- const { username, password } = params;
-
- // Validate
- if (!username || username.length < 3) {
- return { success: false, error: 'Username must be at least 3 characters' };
- }
-
- if (!password || password.length < 8) {
- return { success: false, error: 'Password must be at least 8 characters' };
- }
-
- // Check if registration is enabled
- const registrationEnabled = await getConfig('auth.registration_enabled');
- if (registrationEnabled !== 'true') {
- return { success: false, error: 'Registration is disabled' };
- }
-
- // Check username uniqueness
- const existing = await prisma.user.findFirst({
- where: {
- plexUsername: username,
- authProvider: 'local',
- },
- });
-
- if (existing) {
- return { success: false, error: 'Username already taken' };
- }
-
- // Hash password
- const passwordHash = await bcrypt.hash(password, 10);
-
- // Determine registration status
- const requireApproval = (await getConfig('auth.require_admin_approval')) === 'true';
- const registrationStatus = requireApproval ? 'pending_approval' : 'approved';
-
- // Check if first user (make admin)
- const userCount = await prisma.user.count();
- const isFirstUser = userCount === 0;
-
- // Create user
- const user = await prisma.user.create({
- data: {
- plexId: `local-${username}`,
- plexUsername: username,
- authToken: passwordHash,
- authProvider: 'local',
- role: isFirstUser ? 'admin' : 'user',
- isSetupAdmin: isFirstUser,
- registrationStatus: isFirstUser ? 'approved' : registrationStatus,
- },
- });
-
- if (requireApproval && !isFirstUser) {
- return { success: false, requiresApproval: true };
- }
-
- // Generate tokens for immediate login
- const tokens = await generateJWT({
- id: user.id,
- username: user.plexUsername,
- role: user.role,
- });
-
- return {
- success: true,
- user: {
- id: user.id,
- username: user.plexUsername,
- isAdmin: user.role === 'admin',
- },
- tokens,
- };
- }
-
- async refreshToken(refreshToken: string): Promise {
- // Reuse existing JWT refresh logic
- return null;
- }
-
- async validateAccess(userInfo: UserInfo): Promise {
- return true;
- }
-}
-```
-
-### 4.2 Create Registration Endpoint
-
-**Create file:** `src/app/api/auth/register/route.ts`
-
-```typescript
-/**
- * User Registration Endpoint
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextRequest, NextResponse } from 'next/server';
-import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
-
-// Rate limiting map (in production, use Redis)
-const registrationAttempts = new Map();
-const MAX_ATTEMPTS = 5;
-const WINDOW_MS = 60 * 60 * 1000; // 1 hour
-
-function checkRateLimit(ip: string): boolean {
- const now = Date.now();
- const attempts = registrationAttempts.get(ip);
-
- if (!attempts || now > attempts.resetAt) {
- registrationAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
- return true;
- }
-
- if (attempts.count >= MAX_ATTEMPTS) {
- return false;
- }
-
- attempts.count++;
- return true;
-}
-
-export async function POST(request: NextRequest) {
- // Rate limiting
- const ip = request.headers.get('x-forwarded-for') || 'unknown';
- if (!checkRateLimit(ip)) {
- return NextResponse.json(
- { error: 'Too many registration attempts. Please try again later.' },
- { status: 429 }
- );
- }
-
- try {
- const { username, password } = await request.json();
-
- const provider = new LocalAuthProvider();
- const result = await provider.register({ username, password });
-
- if (!result.success) {
- if (result.requiresApproval) {
- return NextResponse.json({
- success: false,
- pendingApproval: true,
- message: 'Account created. Waiting for admin approval.',
- });
- }
- return NextResponse.json(
- { error: result.error },
- { status: 400 }
- );
- }
-
- // Return tokens for auto-login
- return NextResponse.json({
- success: true,
- user: result.user,
- accessToken: result.tokens!.accessToken,
- refreshToken: result.tokens!.refreshToken,
- });
- } catch (error) {
- return NextResponse.json(
- { error: 'Registration failed' },
- { status: 500 }
- );
- }
-}
-```
-
-### 4.3 Create Local Login Endpoint
-
-**Create file:** `src/app/api/auth/local/login/route.ts`
-
-```typescript
-/**
- * Local Login Endpoint
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextRequest, NextResponse } from 'next/server';
-import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
-
-export async function POST(request: NextRequest) {
- try {
- const { username, password } = await request.json();
-
- const provider = new LocalAuthProvider();
- const result = await provider.handleCallback({ username, password });
-
- if (!result.success) {
- if (result.requiresApproval) {
- return NextResponse.json({
- success: false,
- pendingApproval: true,
- message: 'Account pending admin approval.',
- });
- }
- return NextResponse.json(
- { error: result.error },
- { status: 401 }
- );
- }
-
- return NextResponse.json({
- success: true,
- user: result.user,
- accessToken: result.tokens!.accessToken,
- refreshToken: result.tokens!.refreshToken,
- });
- } catch (error) {
- return NextResponse.json(
- { error: 'Login failed' },
- { status: 500 }
- );
- }
-}
-```
-
-### 4.4 Create Auth Providers Endpoint
-
-**Create file:** `src/app/api/auth/providers/route.ts`
-
-```typescript
-/**
- * List Available Auth Providers
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextResponse } from 'next/server';
-import { getConfig, getBackendMode } from '@/lib/services/config.service';
-
-export async function GET() {
- const mode = await getBackendMode();
-
- if (mode === 'plex') {
- return NextResponse.json({
- providers: ['plex'],
- registrationEnabled: false,
- });
- }
-
- // Audiobookshelf mode
- const oidcEnabled = (await getConfig('oidc.enabled')) === 'true';
- const registrationEnabled = (await getConfig('auth.registration_enabled')) === 'true';
- const oidcProviderName = await getConfig('oidc.provider_name') || 'SSO';
-
- const providers: string[] = [];
- if (oidcEnabled) providers.push('oidc');
- if (registrationEnabled) providers.push('local');
-
- return NextResponse.json({
- providers,
- registrationEnabled,
- oidcProviderName: oidcEnabled ? oidcProviderName : null,
- });
-}
-```
-
-### 4.5 Create Admin User Approval Endpoints
-
-**Create file:** `src/app/api/admin/users/pending/route.ts`
-
-```typescript
-/**
- * Pending User Approvals
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextResponse } from 'next/server';
-import { prisma } from '@/lib/prisma';
-import { requireAdmin } from '@/lib/middleware/auth';
-
-export async function GET() {
- const authResult = await requireAdmin();
- if (!authResult.success) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
- }
-
- const pendingUsers = await prisma.user.findMany({
- where: { registrationStatus: 'pending_approval' },
- select: {
- id: true,
- plexUsername: true,
- createdAt: true,
- authProvider: true,
- },
- orderBy: { createdAt: 'desc' },
- });
-
- return NextResponse.json({ users: pendingUsers });
-}
-```
-
-**Create file:** `src/app/api/admin/users/[id]/approve/route.ts`
-
-```typescript
-/**
- * Approve User Registration
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-import { NextRequest, NextResponse } from 'next/server';
-import { prisma } from '@/lib/prisma';
-import { requireAdmin } from '@/lib/middleware/auth';
-
-export async function POST(
- request: NextRequest,
- { params }: { params: { id: string } }
-) {
- const authResult = await requireAdmin();
- if (!authResult.success) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
- }
-
- await prisma.user.update({
- where: { id: params.id },
- data: { registrationStatus: 'approved' },
- });
-
- return NextResponse.json({ success: true });
-}
-```
-
-### 4.6 Phase 4 Verification
-
-**Tests to run:**
-1. Registration creates user with correct status
-2. Login works for approved users
-3. Login blocked for pending users
-4. Admin can see pending users
-5. Admin can approve users
-6. Rate limiting works
-7. Existing auth methods still work
-
-**Checklist:**
-- [ ] `LocalAuthProvider` implements interface
-- [ ] Registration endpoint with rate limiting
-- [ ] Local login endpoint
-- [ ] Auth providers listing endpoint
-- [ ] Admin approval endpoints
-- [ ] First user becomes admin
-
----
-
-## Phase 5: Setup Wizard Modifications
-
-### 5.1 Create Backend Selection Step
-
-**Create file:** `src/app/setup/components/BackendSelectionStep.tsx`
-
-```typescript
-/**
- * Backend Selection Step
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-'use client';
-
-import { useState } from 'react';
-
-interface Props {
- value: 'plex' | 'audiobookshelf';
- onChange: (value: 'plex' | 'audiobookshelf') => void;
- onNext: () => void;
-}
-
-export function BackendSelectionStep({ value, onChange, onNext }: Props) {
- return (
-
-
-
Choose Your Library Backend
-
- Select which media server you'll use to manage your audiobook library.
-
-
-
-
-
-
-
-
-
-
-
- Note: This choice cannot be changed after setup.
- To switch backends, you'll need to reset the application.
-
-
-
-
-
- );
-}
-```
-
-### 5.2 Create Audiobookshelf Setup Step
-
-**Create file:** `src/app/setup/components/AudiobookshelfStep.tsx`
-
-```typescript
-/**
- * Audiobookshelf Configuration Step
- * Documentation: documentation/features/audiobookshelf-integration.md
- */
-
-'use client';
-
-import { useState } from 'react';
-
-interface Props {
- serverUrl: string;
- apiToken: string;
- libraryId: string;
- onServerUrlChange: (value: string) => void;
- onApiTokenChange: (value: string) => void;
- onLibraryIdChange: (value: string) => void;
- onNext: () => void;
- onBack: () => void;
-}
-
-export function AudiobookshelfStep({
- serverUrl,
- apiToken,
- libraryId,
- onServerUrlChange,
- onApiTokenChange,
- onLibraryIdChange,
- onNext,
- onBack,
-}: Props) {
- const [testing, setTesting] = useState(false);
- const [testResult, setTestResult] = useState<{
- success: boolean;
- libraries?: { id: string; name: string; itemCount: number }[];
- error?: string;
- } | null>(null);
-
- const handleTest = async () => {
- setTesting(true);
- setTestResult(null);
-
- try {
- const response = await fetch('/api/setup/test-abs', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ serverUrl, apiToken }),
- });
-
- const data = await response.json();
- setTestResult(data);
- } catch (error) {
- setTestResult({ success: false, error: 'Connection failed' });
- } finally {
- setTesting(false);
- }
- };
-
- const canProceed = testResult?.success && libraryId;
-
- return (
-
-
-
Configure Audiobookshelf
-
- Enter your Audiobookshelf server details and API token.
-
-
-
-
-
-
- onServerUrlChange(e.target.value)}
- placeholder="http://audiobookshelf:13378"
- className="w-full px-3 py-2 border rounded-lg"
- />
-
-
-
-
-
onApiTokenChange(e.target.value)}
- placeholder="Your API token"
- className="w-full px-3 py-2 border rounded-lg"
- />
-
- Generate this in Audiobookshelf → Settings → API Keys → Add API Key
-
-
-
-
-
- {testResult && (
-
- {testResult.success ? (
- <>
-
Connection successful!
-
-
-
-
- >
- ) : (
-
{testResult.error}
- )}
-
- )}
-
-
-
-
-
-
-
- );
-}
-```
-
-### 5.3 Create Auth Method Selection Step
-
-**Create file:** `src/app/setup/components/AuthMethodStep.tsx`
-
-Similar pattern to BackendSelectionStep - allow choosing between:
-- OIDC Provider
-- Manual Registration
-- Both
-
-### 5.4 Create OIDC Configuration Step
-
-**Create file:** `src/app/setup/components/OIDCConfigStep.tsx`
-
-Include fields for:
-- Provider name (display name)
-- Issuer URL
-- Client ID
-- Client Secret
-- Access control method selection
-- Group claim configuration (if group_claim selected)
-
-Include test connection button that validates OIDC discovery.
-
-### 5.5 Create Registration Settings Step
-
-**Create file:** `src/app/setup/components/RegistrationSettingsStep.tsx`
-
-Include:
-- Enable/disable toggle
-- Require admin approval toggle
-
-### 5.6 Update Main Setup Wizard
-
-**Modify:** `src/app/setup/page.tsx`
-
-Update step flow based on backend mode:
-```typescript
-const steps = useMemo(() => {
- const baseSteps = ['welcome', 'backend'];
-
- if (state.backendMode === 'plex') {
- return [...baseSteps, 'plex', 'admin', 'prowlarr', 'download', 'paths', 'bookdate', 'review', 'finalize'];
- } else {
- return [...baseSteps, 'audiobookshelf', 'auth-method',
- ...(state.authMethod === 'oidc' || state.authMethod === 'both' ? ['oidc-config'] : []),
- ...(state.authMethod === 'manual' || state.authMethod === 'both' ? ['registration-settings'] : []),
- ...(state.authMethod === 'manual' ? ['admin-account'] : []),
- 'prowlarr', 'download', 'paths', 'bookdate', 'review', 'finalize'
- ];
- }
-}, [state.backendMode, state.authMethod]);
-```
-
-### 5.7 Update Setup Complete Endpoint
-
-**Modify:** `src/app/api/setup/complete/route.ts`
-
-Handle saving all new configuration:
-```typescript
-// Save backend mode
-await setConfig('system.backend_mode', state.backendMode);
-
-if (state.backendMode === 'audiobookshelf') {
- // Save ABS config
- await setConfig('abs.server_url', state.absUrl);
- await setConfig('abs.api_token', state.absApiToken, true); // encrypted
- await setConfig('abs.library_id', state.absLibraryId);
-
- // Save auth config
- if (state.authMethod === 'oidc' || state.authMethod === 'both') {
- await setConfig('oidc.enabled', 'true');
- await setConfig('oidc.provider_name', state.oidcProviderName);
- await setConfig('oidc.issuer_url', state.oidcIssuerUrl);
- await setConfig('oidc.client_id', state.oidcClientId);
- await setConfig('oidc.client_secret', state.oidcClientSecret, true);
- await setConfig('oidc.access_control_method', state.oidcAccessMethod);
- // ... other OIDC config
- }
-
- if (state.authMethod === 'manual' || state.authMethod === 'both') {
- await setConfig('auth.registration_enabled', 'true');
- await setConfig('auth.require_admin_approval', state.requireAdminApproval ? 'true' : 'false');
- }
-}
-```
-
-### 5.8 Phase 5 Verification
-
-**Tests to run:**
-1. Full setup flow with Plex mode
-2. Full setup flow with ABS + OIDC
-3. Full setup flow with ABS + Manual registration
-4. All config saved correctly
-5. Correct steps shown for each mode
-
-**Checklist:**
-- [ ] Backend selection step
-- [ ] ABS configuration step
-- [ ] Auth method selection step
-- [ ] OIDC configuration step
-- [ ] Registration settings step
-- [ ] Dynamic step flow based on selections
-- [ ] Setup complete saves all config
-
----
-
-## Phase 6: Settings & Login UI
-
-### 6.1 Update Login Page for Multi-Mode
-
-**Modify:** `src/app/login/page.tsx`
-
-```typescript
-'use client';
-
-import { useEffect, useState } from 'react';
-import { PlexLoginButton } from '@/components/auth/PlexLoginButton';
-import { OIDCLoginButton } from '@/components/auth/OIDCLoginButton';
-import { LocalLoginForm } from '@/components/auth/LocalLoginForm';
-import { RegistrationForm } from '@/components/auth/RegistrationForm';
-
-export default function LoginPage() {
- const [providers, setProviders] = useState<{
- providers: string[];
- registrationEnabled: boolean;
- oidcProviderName: string | null;
- } | null>(null);
- const [showRegister, setShowRegister] = useState(false);
-
- useEffect(() => {
- fetch('/api/auth/providers')
- .then(res => res.json())
- .then(setProviders);
- }, []);
-
- if (!providers) return Loading...
;
-
- // Plex mode
- if (providers.providers.includes('plex')) {
- return ;
- }
-
- // Audiobookshelf mode
- return (
-
- {showRegister ? (
- <>
-
setShowRegister(false)} />
-
- >
- ) : (
- <>
- {providers.providers.includes('oidc') && (
-
- )}
-
- {providers.providers.includes('oidc') && providers.providers.includes('local') && (
- OR
- )}
-
- {providers.providers.includes('local') && (
-
- )}
-
- {providers.registrationEnabled && (
-
- )}
- >
- )}
-
- );
-}
-```
-
-### 6.2 Create Auth Components
-
-**Create:** `src/components/auth/OIDCLoginButton.tsx`
-**Create:** `src/components/auth/LocalLoginForm.tsx`
-**Create:** `src/components/auth/RegistrationForm.tsx`
-
-### 6.3 Add Settings Tabs for ABS Mode
-
-**Modify:** `src/app/admin/settings/page.tsx`
-
-Add conditional tabs:
-- Audiobookshelf tab (if mode = audiobookshelf)
-- OIDC tab (if OIDC enabled)
-- Registration tab (if registration enabled)
-
-### 6.4 Create Settings Tab Components
-
-**Create:** `src/app/admin/settings/components/AudiobookshelfTab.tsx`
-**Create:** `src/app/admin/settings/components/OIDCTab.tsx`
-**Create:** `src/app/admin/settings/components/RegistrationTab.tsx`
-
-### 6.5 Phase 6 Verification
-
-**Tests to run:**
-1. Login page shows correct options per mode
-2. OIDC login button redirects correctly
-3. Local login form works
-4. Registration form works
-5. Settings tabs appear correctly
-6. Settings can be modified and saved
-
----
-
-## Phase 7: Integration Testing & Documentation
-
-### 7.1 End-to-End Tests
-
-Create comprehensive tests for:
-1. **Plex Mode (Regression)**
- - Full setup flow
- - Login/logout
- - Library scanning
- - Request flow
-
-2. **ABS + OIDC Mode**
- - Full setup flow
- - OIDC login with group claim access control
- - Library scanning
- - Request flow
-
-3. **ABS + Manual Registration Mode**
- - Full setup flow
- - User registration
- - Admin approval flow
- - Login after approval
- - Library scanning
- - Request flow
-
-### 7.2 Update Documentation
-
-**Update:** `documentation/TABLEOFCONTENTS.md`
-- Add entries for new integration docs
-
-**Update:** `documentation/backend/services/auth.md`
-- Add OIDC and local auth sections
-
-**Create:** `documentation/integrations/audiobookshelf.md`
-- API reference
-- Configuration
-- Troubleshooting
-
-**Update:** `documentation/setup-wizard.md`
-- Document new steps
-- Mode-specific flows
-
-### 7.3 Final Verification Checklist
-
-- [ ] All Phase 1-6 checklists complete
-- [ ] Plex mode unchanged (full regression)
-- [ ] ABS library integration works
-- [ ] OIDC authentication works
-- [ ] Group claim access control works
-- [ ] Manual registration works
-- [ ] Admin approval works
-- [ ] Setup wizard handles all modes
-- [ ] Settings pages handle all modes
-- [ ] Login page adapts to mode
-- [ ] Documentation updated
-- [ ] No console errors
-- [ ] No TypeScript errors
-- [ ] All tests pass
-
----
-
-## Important Notes for AI Agent
-
-1. **Always read existing code first** before making changes
-2. **Run tests frequently** - after each major change
-3. **Keep existing functionality working** - Plex mode must not break
-4. **Follow existing patterns** - match code style and structure
-5. **Update imports** when moving/creating files
-6. **Handle errors gracefully** - never crash on API errors
-7. **Log appropriately** - debug info but never tokens
-8. **Ask if unclear** - don't make assumptions about requirements
diff --git a/documentation/features/audiobookshelf-integration.md b/documentation/features/audiobookshelf-integration.md
deleted file mode 100644
index 7a3c14e..0000000
--- a/documentation/features/audiobookshelf-integration.md
+++ /dev/null
@@ -1,1184 +0,0 @@
-# Audiobookshelf Integration PRD
-
-**Status:** ❌ Not Started | PRD Complete, Awaiting Development
-
-**Version:** 1.0
-**Last Updated:** 2024-12-10
-**Author:** System Architecture Planning
-
----
-
-## Executive Summary
-
-This PRD defines the requirements for adding Audiobookshelf as an alternative library backend to Plex, along with new authentication systems (OIDC and manual registration) to replace Plex OAuth when using Audiobookshelf.
-
-**Scope:**
-- Audiobookshelf library integration (alternative to Plex)
-- OIDC authentication (Authentik, Keycloak, etc.)
-- Manual user registration with admin toggle
-- Backend selection during setup
-- Full feature parity between Plex and Audiobookshelf modes
-
-**Non-Goals:**
-- Hybrid mode (using both Plex AND Audiobookshelf simultaneously)
-- Migration tool between backends (manual reconfiguration required)
-- Audiobookshelf's internal user system (uses external auth only)
-
----
-
-## 1. Background & Motivation
-
-### Current State
-- ReadMeABook uses Plex as the sole library backend
-- Authentication tied to Plex OAuth
-- Users must have Plex accounts to use the system
-- Library scanning, availability detection, and matching all depend on Plex
-
-### Problem Statement
-1. **Plex dependency:** Users without Plex cannot use ReadMeABook
-2. **Self-hosted preference:** Many users prefer fully self-hosted solutions
-3. **Audiobookshelf popularity:** Growing audiobook-specific media server
-4. **Authentication flexibility:** Some users want OIDC integration with existing identity providers
-
-### Solution
-Add Audiobookshelf as an alternative library backend with flexible authentication options:
-- **Plex Mode:** Existing functionality unchanged (Plex OAuth + Plex library)
-- **Audiobookshelf Mode:** New library backend with OIDC or manual registration
-
----
-
-## 2. System Architecture
-
-### 2.1 Backend Selection Model
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ ReadMeABook Instance │
-├─────────────────────────────────────────────────────────────────┤
-│ Mode: PLEX │ Mode: AUDIOBOOKSHELF │
-│ ──────────────── │ ───────────────────── │
-│ Auth: Plex OAuth │ Auth: OIDC OR Manual Reg │
-│ Library: Plex Media Server │ Library: Audiobookshelf API │
-│ Scanner: Plex API │ Scanner: ABS API │
-│ Availability: Plex GUID │ Availability: ABS Library ID │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-### 2.2 Mode Selection (Mutually Exclusive)
-
-**Decision Point:** Setup wizard Step 1 (after Welcome)
-
-**Options:**
-1. **Plex Mode** - Current behavior, no changes
-2. **Audiobookshelf Mode** - New backend + auth system
-
-**Persistence:** `system.backend_mode` config key (`plex` | `audiobookshelf`)
-
-**Cannot Change After Setup:** Mode selection is permanent after initial setup to prevent data inconsistencies. To switch modes, user must reset the instance.
-
----
-
-## 3. Audiobookshelf Library Integration
-
-### 3.1 Audiobookshelf API Overview
-
-**Base URL:** `{server_url}/api`
-**Auth:** API token in `Authorization: Bearer {token}` header
-**Response:** JSON
-
-**Key Endpoints:**
-| Endpoint | Purpose |
-|----------|---------|
-| `GET /api/libraries` | List all libraries |
-| `GET /api/libraries/{id}/items` | Get all items in library |
-| `GET /api/libraries/{id}/items?filter=...` | Filtered/sorted items |
-| `GET /api/items/{id}` | Get single item with full metadata |
-| `GET /api/items/{id}/cover` | Get cover image |
-| `GET /api/search/covers?title=...&author=...` | Search for covers |
-| `POST /api/libraries/{id}/match` | Trigger metadata match |
-| `POST /api/items/{id}/scan` | Scan single item |
-| `GET /api/me` | Current user info (for validation) |
-| `GET /api/server` | Server info (version, name) |
-
-**Item Structure:**
-```json
-{
- "id": "li_abc123",
- "libraryId": "lib_xyz",
- "media": {
- "metadata": {
- "title": "Book Title",
- "authorName": "Author Name",
- "narratorName": "Narrator",
- "description": "...",
- "isbn": "...",
- "asin": "B00ABC123",
- "publishedYear": "2023",
- "duration": 36000
- },
- "coverPath": "/audiobooks/book/cover.jpg",
- "audioFiles": [...]
- },
- "addedAt": 1699999999000,
- "updatedAt": 1699999999000
-}
-```
-
-### 3.2 Library Service Abstraction
-
-**New Interface:** `ILibraryService`
-
-```typescript
-interface ILibraryService {
- // Connection
- testConnection(): Promise;
- getServerInfo(): Promise;
-
- // Libraries
- getLibraries(): Promise;
- getLibraryItems(libraryId: string): Promise;
- getRecentlyAdded(libraryId: string, limit: number): Promise;
-
- // Items
- getItem(itemId: string): Promise;
- searchItems(query: string): Promise;
-
- // Scanning
- triggerLibraryScan(libraryId: string): Promise;
-
- // Matching
- matchItem(item: AudiobookMetadata): Promise;
-}
-```
-
-**Implementations:**
-- `PlexLibraryService` - Existing Plex logic (refactored)
-- `AudiobookshelfLibraryService` - New ABS implementation
-
-**Factory Pattern:**
-```typescript
-function getLibraryService(): ILibraryService {
- const mode = await getConfig('system.backend_mode');
- return mode === 'audiobookshelf'
- ? new AudiobookshelfLibraryService()
- : new PlexLibraryService();
-}
-```
-
-### 3.3 Database Schema Changes
-
-**New/Modified Tables:**
-
-```sql
--- Configuration additions
-INSERT INTO configuration (key, value) VALUES
- ('system.backend_mode', 'plex'), -- 'plex' | 'audiobookshelf'
- ('abs.server_url', ''),
- ('abs.api_token', ''), -- encrypted
- ('abs.library_id', ''),
- ('abs.server_id', ''); -- for reference
-
--- Users table modifications
-ALTER TABLE users ADD COLUMN auth_provider VARCHAR(50);
--- Values: 'plex' | 'oidc' | 'local'
-
-ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255);
--- OIDC subject ID (unique per provider)
-
-ALTER TABLE users ADD COLUMN oidc_provider VARCHAR(100);
--- OIDC provider name (e.g., 'authentik', 'keycloak')
-
--- Audiobooks table modifications (for ABS mode)
-ALTER TABLE audiobooks ADD COLUMN abs_item_id VARCHAR(255);
--- Audiobookshelf item ID (alternative to plex_guid)
-
--- Plex Library table (renamed for abstraction)
--- OPTION A: Rename to generic 'library_cache'
--- OPTION B: Keep plex_library, add abs_library_cache
--- RECOMMENDED: Option A - single abstracted table
-```
-
-**Library Cache Table (Abstracted):**
-```sql
-CREATE TABLE library_cache (
- id UUID PRIMARY KEY,
- external_id VARCHAR(255) NOT NULL, -- plexGuid OR abs_item_id
- backend_type VARCHAR(50) NOT NULL, -- 'plex' | 'audiobookshelf'
- title VARCHAR(500),
- author VARCHAR(500),
- narrator VARCHAR(500),
- duration_seconds INTEGER,
- cover_url VARCHAR(1000),
- asin VARCHAR(50),
- isbn VARCHAR(50),
- year INTEGER,
- summary TEXT,
- added_at TIMESTAMP,
- updated_at TIMESTAMP,
- last_synced_at TIMESTAMP,
- UNIQUE(external_id, backend_type)
-);
-```
-
-### 3.4 Audiobook Matching
-
-**ABS Matching Advantages:**
-- Native ASIN support in metadata
-- ISBN field available
-- Better audiobook-specific metadata
-
-**Matching Strategy:**
-1. **ASIN Match:** If request has ASIN and ABS item has same ASIN → 100% match
-2. **ISBN Match:** If request has ISBN and ABS item has same ISBN → 100% match
-3. **Fuzzy Match:** Title + Author fuzzy matching (existing algorithm)
-
-**Matching Service:**
-```typescript
-interface IAudiobookMatcher {
- match(request: AudiobookRequest, libraryItems: LibraryItem[]): MatchResult;
-}
-
-// Shared implementation - works for both backends
-class AudiobookMatcher implements IAudiobookMatcher {
- match(request, items) {
- // 1. Try ASIN match
- // 2. Try ISBN match
- // 3. Fall back to fuzzy title/author
- }
-}
-```
-
-### 3.5 Availability Checking
-
-**Flow:**
-```
-1. Library Scan Job → Fetch all ABS items → Populate library_cache
-2. Request created → Check library_cache for match
-3. Download complete → Scan ABS library → Match downloaded to cache
-4. UI shows "In Your Library" based on availability_status
-```
-
-**ABS-Specific Considerations:**
-- ABS has webhook support → Can receive notifications instead of polling
-- ABS scan is faster (native audiobook support)
-- ABS metadata includes ASIN/ISBN natively
-
-### 3.6 File Organization
-
-**Current (Plex):**
-```
-/media/{author}/{title}/audiofiles.m4b
-```
-
-**Audiobookshelf:**
-```
-/media/{author}/{title}/audiofiles.m4b
-```
-
-**Same structure works for both backends** - ABS supports same folder structure.
-
-**ABS-Specific Features:**
-- Can trigger item scan after file placement
-- ABS auto-detects new files in library folder
-- Faster metadata matching with ASIN
-
----
-
-## 4. Authentication Systems
-
-### 4.1 Authentication Provider Abstraction
-
-**New Interface:** `IAuthProvider`
-
-```typescript
-interface IAuthProvider {
- type: 'plex' | 'oidc' | 'local';
-
- // Auth flow
- initiateLogin(): Promise;
- handleCallback(params: CallbackParams): Promise;
- refreshToken(refreshToken: string): Promise;
-
- // User info
- getUserInfo(token: string): Promise;
-
- // Validation
- validateToken(token: string): Promise;
-}
-
-interface AuthResult {
- user: User;
- accessToken: string;
- refreshToken: string;
-}
-```
-
-**Implementations:**
-- `PlexAuthProvider` - Existing Plex OAuth (unchanged)
-- `OIDCAuthProvider` - New OIDC implementation
-- `LocalAuthProvider` - Username/password registration
-
-### 4.2 OIDC Authentication
-
-**Supported Providers:**
-- Authentik
-- Keycloak
-- Auth0
-- Okta
-- Any OpenID Connect compliant provider
-
-**Configuration:**
-```typescript
-interface OIDCConfig {
- provider_name: string; // Display name
- issuer_url: string; // OIDC issuer (e.g., https://auth.example.com)
- client_id: string;
- client_secret: string; // encrypted
- redirect_uri: string; // auto-generated
- scopes: string[]; // ['openid', 'profile', 'email']
-
- // Optional
- discovery_endpoint?: string; // default: {issuer}/.well-known/openid-configuration
- userinfo_endpoint?: string; // from discovery or manual
- token_endpoint?: string; // from discovery or manual
- authorization_endpoint?: string;
-}
-```
-
-**OIDC Flow:**
-```
-1. User clicks "Login with {Provider}"
-2. Redirect to provider's authorization endpoint
-3. User authenticates with provider
-4. Provider redirects back with authorization code
-5. Exchange code for tokens
-6. Fetch user info from userinfo endpoint
-7. Create/update user in DB
-8. Issue JWT session tokens
-9. Redirect to app
-```
-
-**User Mapping:**
-```typescript
-interface OIDCUserInfo {
- sub: string; // Unique subject ID → oidc_subject
- preferred_username: string; // → username
- email?: string; // → email
- name?: string; // → display name
- picture?: string; // → avatar_url
-}
-```
-
-**Role Assignment:**
-- First OIDC user → Admin (same as current Plex behavior)
-- Subsequent users → User role
-- Optional: OIDC group/claim mapping for admin role
-
-### 4.2.1 OIDC Access Control (Authorization)
-
-**Problem:** OIDC only handles authentication ("who are you?"), not authorization ("can you use this app?"). Without access control, anyone with an account on your identity provider could access ReadMeABook.
-
-**Comparison with Plex:**
-- Plex OAuth has built-in access control: user must have access to your configured Plex server
-- OIDC has no equivalent - we must implement our own access control
-
-**Access Control Options:**
-
-| Method | Description | Config Complexity | Recommended |
-|--------|-------------|-------------------|-------------|
-| **OIDC Group Claim** | Require membership in specific group | Medium | ✅ Yes |
-| **Allowed Users List** | Admin maintains list of allowed emails | Low | ✅ Yes |
-| **Admin Approval** | Users auto-created but pending until approved | Low | ✅ Yes |
-| **Open Access** | Anyone who authenticates gets access | None | ⚠️ Rarely |
-
-**Recommended: OIDC Group Claim (Primary)**
-
-Requires user to be member of a specific group in the identity provider.
-
-```typescript
-interface OIDCAccessConfig {
- // Access control (who can use the app)
- access_control_enabled: boolean; // default: true
- access_control_method: 'group_claim' | 'allowed_list' | 'admin_approval' | 'open';
-
- // Group claim settings (if method = 'group_claim')
- access_group_claim: string; // e.g., 'groups' (claim name in token)
- access_group_value: string; // e.g., 'readmeabook-users' (required group)
-
- // Allowed list settings (if method = 'allowed_list')
- allowed_emails: string[]; // e.g., ['user@example.com']
- allowed_usernames: string[]; // e.g., ['john', 'jane']
-}
-```
-
-**Group Claim Flow:**
-```
-1. User authenticates with OIDC provider
-2. Provider returns token with claims:
- {
- "sub": "user-123",
- "email": "john@example.com",
- "groups": ["readmeabook-users", "other-group"] ← Group claim
- }
-3. ReadMeABook checks: Does 'groups' contain 'readmeabook-users'?
-4. If YES → Create/update user, issue session
-5. If NO → Show error: "You don't have access to this application"
-```
-
-**Provider Setup Examples:**
-
-**Authentik:**
-1. Create group `readmeabook-users` in Authentik
-2. Add allowed users to the group
-3. In Application → Provider → Advanced Settings:
- - Add scope mapping for `groups` claim
-4. ReadMeABook config: `access_group_claim: 'groups'`, `access_group_value: 'readmeabook-users'`
-
-**Keycloak:**
-1. Create group `readmeabook-users` in your realm
-2. Add allowed users to the group
-3. In Client → Mappers → Add mapper:
- - Type: Group Membership
- - Token Claim Name: `groups`
-4. ReadMeABook config: same as above
-
-**Allowed List Flow (Alternative):**
-```
-1. Admin adds allowed emails/usernames in ReadMeABook settings
-2. User authenticates with OIDC provider
-3. ReadMeABook checks: Is user's email in allowed list?
-4. If YES → Create/update user, issue session
-5. If NO → Show error: "Your account is not authorized"
-```
-
-**Admin Approval Flow (Alternative):**
-```
-1. User authenticates with OIDC provider
-2. User record created with status: 'pending_approval'
-3. User sees: "Your account is pending admin approval"
-4. Admin sees pending users in admin panel
-5. Admin approves → User can now access app
-```
-
-**Setup Wizard Configuration:**
-```
-┌─────────────────────────────────────────────────────────────┐
-│ OIDC Access Control │
-│ │
-│ How should we control who can access ReadMeABook? │
-│ │
-│ ○ Require OIDC group membership (recommended) │
-│ Users must be in a specific group in your provider │
-│ Group claim name: [groups_________] │
-│ Required group: [readmeabook-users] │
-│ │
-│ ○ Maintain allowed users list │
-│ You'll manually add allowed emails in settings │
-│ │
-│ ○ Require admin approval │
-│ Anyone can login, but must be approved first │
-│ │
-│ ○ Open access (not recommended) │
-│ Anyone who can authenticate will have access │
-└─────────────────────────────────────────────────────────────┘
-```
-
-### 4.2.2 OIDC Role Assignment
-
-**Admin Claim Mapping (Optional):**
-```typescript
-interface OIDCAdminConfig {
- enabled: boolean;
- claim_name: string; // e.g., 'groups', 'roles', 'is_admin'
- claim_value: string; // e.g., 'readmeabook-admin', 'admin'
-}
-```
-
-**Note:** This is separate from access control. Access control determines WHO can use the app. Admin claim mapping determines which users get admin ROLE.
-
-### 4.3 Manual Registration
-
-**Toggle:** `auth.allow_registration` (boolean, default: false)
-
-**When Enabled:**
-- Registration form on login page
-- Username + password signup
-- Optional admin approval before access granted
-
-**Registration Config:**
-```typescript
-interface RegistrationConfig {
- enabled: boolean;
- require_admin_approval: boolean; // if true, new users pending until approved
- default_role: 'user'; // always 'user', admin promotes manually
-}
-```
-
-**Registration Flow:**
-```
-1. User fills registration form (username, password)
-2. Validate username uniqueness
-3. Hash password (bcrypt)
-4. If admin approval required:
- - Create user with status: 'pending_approval'
- - User sees: "Account pending admin approval"
- - Admin approves in admin panel
-5. If no approval required:
- - Create user with status: 'approved'
- - User can login immediately
-```
-
-**User Table for Local Auth:**
-```sql
--- Uses existing authToken field (bcrypt hash)
--- New field:
-ALTER TABLE users ADD COLUMN registration_status VARCHAR(50);
--- Values: 'pending_approval' | 'approved' | 'rejected'
-```
-
-### 4.4 Login Page Changes
-
-**Plex Mode:**
-- Current login page (Plex OAuth button)
-- No changes
-
-**Audiobookshelf Mode:**
-
-```
-┌─────────────────────────────────────────┐
-│ ReadMeABook │
-│ │
-│ [Login with {OIDC Provider}] │ ← If OIDC configured
-│ │
-│ ─────────── OR ─────────── │ ← If both enabled
-│ │
-│ Username: [_______________] │ ← If registration enabled
-│ Password: [_______________] │
-│ [Login] │
-│ │
-│ Don't have an account? [Register] │ ← If registration enabled
-│ │
-└─────────────────────────────────────────┘
-```
-
-**Components:**
-- `OIDCLoginButton` - Dynamic provider name
-- `LocalLoginForm` - Username/password
-- `RegistrationForm` - New user signup
-
----
-
-## 5. Setup Wizard Modifications
-
-### 5.1 New Setup Flow
-
-**Step 1: Welcome** (unchanged)
-
-**Step 2: Backend Selection** (NEW)
-```
-Choose your audiobook library backend:
-
-○ Plex Media Server
- Use Plex for library management and authentication
-
-○ Audiobookshelf
- Use Audiobookshelf for library management
- Choose OIDC or manual registration for authentication
-```
-
-**Step 3A (Plex Mode): Plex Setup** (current Step 3)
-- Server URL, OAuth, library selection
-
-**Step 3B (ABS Mode): Audiobookshelf Setup** (NEW)
-- Server URL
-- API token (with instructions to generate)
-- Library selection (audiobook libraries only)
-- Test connection
-
-**Step 4A (Plex Mode): Skip** (Plex OAuth handles auth)
-
-**Step 4B (ABS Mode): Authentication Setup** (NEW)
-```
-Choose authentication method:
-
-○ OIDC Provider
- Use Authentik, Keycloak, or other OIDC provider
-
-○ Manual Registration
- Users create accounts with username/password
-
-○ Both
- Enable OIDC as primary, allow password fallback
-```
-
-**Step 4B-OIDC: OIDC Configuration** (NEW)
-- Provider name
-- Issuer URL
-- Client ID
-- Client Secret
-- Test connection (validates discovery)
-
-**Step 4B-Manual: Registration Settings** (NEW)
-- Enable/disable registration
-- Require email verification toggle
-- Require admin approval toggle
-- Allowed email domains (optional)
-
-**Step 5: Admin Account** (modified)
-- **Plex Mode:** Current behavior (Plex OAuth creates admin)
-- **ABS + OIDC:** First OIDC login becomes admin (skip this step)
-- **ABS + Manual:** Create admin username/password here
-
-**Remaining Steps:** Prowlarr, Download Client, Paths, BookDate, Review, Finalize (unchanged)
-
-### 5.2 Setup State Interface
-
-```typescript
-interface SetupState {
- currentStep: number;
-
- // Backend selection
- backendMode: 'plex' | 'audiobookshelf';
-
- // Plex config (if mode=plex)
- plexUrl: string;
- plexToken: string;
- plexLibraryId: string;
-
- // ABS config (if mode=audiobookshelf)
- absUrl: string;
- absApiToken: string;
- absLibraryId: string;
-
- // Auth config (if mode=audiobookshelf)
- authMethod: 'oidc' | 'manual' | 'both';
-
- // OIDC config
- oidcProviderName: string;
- oidcIssuerUrl: string;
- oidcClientId: string;
- oidcClientSecret: string;
-
- // Manual registration config
- registrationEnabled: boolean;
- requireEmailVerification: boolean;
- requireAdminApproval: boolean;
- allowedEmailDomains: string[];
-
- // Admin account (manual auth only)
- adminUsername: string;
- adminEmail: string;
- adminPassword: string;
-
- // Rest unchanged...
- prowlarrUrl: string;
- prowlarrApiKey: string;
- // ...
-}
-```
-
----
-
-## 6. Settings Pages Modifications
-
-### 6.1 New Settings Sections
-
-**Backend Mode Display (Read-only):**
-```
-Backend Mode: Audiobookshelf
-⚠️ Cannot be changed after setup. Reset instance to switch backends.
-```
-
-**Audiobookshelf Settings (if mode=audiobookshelf):**
-- Server URL
-- API Token (masked)
-- Library selection
-- Test connection
-- Same validation pattern as Plex settings
-
-**OIDC Settings (if auth=oidc):**
-- Provider name
-- Issuer URL
-- Client ID
-- Client Secret (masked)
-- Test connection (validates discovery)
-
-**Registration Settings (if auth includes manual):**
-- Enable/disable registration toggle
-- Email verification toggle
-- Admin approval toggle
-- Allowed email domains
-
-### 6.2 Conditional Tab Display
-
-```typescript
-const settingsTabs = [
- { id: 'library', label: mode === 'plex' ? 'Plex' : 'Audiobookshelf' },
- { id: 'auth', label: 'Authentication', show: mode === 'audiobookshelf' },
- { id: 'prowlarr', label: 'Prowlarr' },
- { id: 'download', label: 'Download Client' },
- { id: 'paths', label: 'Paths' },
- { id: 'bookdate', label: 'BookDate' },
- { id: 'account', label: 'Account', show: isLocalAdmin },
-];
-```
-
----
-
-## 7. Feature Parity Matrix
-
-| Feature | Plex Mode | Audiobookshelf Mode |
-|---------|-----------|---------------------|
-| Library scanning | ✅ Plex API | ✅ ABS API |
-| Availability detection | ✅ plexGuid | ✅ abs_item_id + ASIN |
-| Recently added polling | ✅ Plex API | ✅ ABS API + webhooks |
-| Fuzzy matching | ✅ Title/Author | ✅ Title/Author + ASIN/ISBN |
-| File organization | ✅ Author/Title | ✅ Author/Title (same) |
-| Authentication | ✅ Plex OAuth | ✅ OIDC / Manual |
-| Plex Home profiles | ✅ Supported | ❌ N/A |
-| BookDate (AI recs) | ✅ User ratings | ⚠️ No ratings in ABS |
-| Request management | ✅ Full | ✅ Full |
-| Admin dashboard | ✅ Full | ✅ Full |
-
-**BookDate Limitation (ABS Mode):**
-- Audiobookshelf doesn't have per-user ratings like Plex
-- BookDate recommendations based on library content + custom prompt only
-- "Rated books only" scope not available in ABS mode
-
----
-
-## 8. API Changes
-
-### 8.1 New Endpoints
-
-**Library (Abstracted):**
-```
-GET /api/library/info → Server info (works for both backends)
-GET /api/library/items → Library items
-GET /api/library/recent → Recently added
-POST /api/library/scan → Trigger scan
-```
-
-**Authentication:**
-```
-GET /api/auth/oidc/login → Initiate OIDC flow
-GET /api/auth/oidc/callback → Handle OIDC callback
-POST /api/auth/register → Create local account
-POST /api/auth/local/login → Login with username/password
-GET /api/auth/providers → List enabled auth providers
-```
-
-**Setup:**
-```
-POST /api/setup/test-abs → Test Audiobookshelf connection
-POST /api/setup/test-oidc → Test OIDC configuration
-```
-
-**Settings:**
-```
-GET /api/admin/settings/abs → Get ABS config
-PUT /api/admin/settings/abs → Update ABS config
-GET /api/admin/settings/oidc → Get OIDC config
-PUT /api/admin/settings/oidc → Update OIDC config
-GET /api/admin/settings/registration → Get registration config
-PUT /api/admin/settings/registration → Update registration config
-```
-
-### 8.2 Modified Endpoints
-
-**Existing endpoints use abstraction layer:**
-```
-GET /api/discovery/* → Uses ILibraryService for availability
-GET /api/requests/* → Uses ILibraryService for availability
-POST /api/requests → Uses ILibraryService for duplicate check
-```
-
----
-
-## 9. Migration & Compatibility
-
-### 9.1 No Migration Path
-
-**By Design:** No automated migration between backends.
-
-**Rationale:**
-- User data (requests, history) may not map cleanly
-- Library IDs are incompatible
-- Auth providers change completely
-- Clean slate ensures consistency
-
-**User Experience:**
-- Reset instance to switch backends
-- Re-run setup wizard
-- Existing requests/history lost (or export feature in future)
-
-### 9.2 Backward Compatibility
-
-**Existing Plex Installations:**
-- Unaffected by this feature
-- `system.backend_mode` defaults to `plex`
-- No setup wizard re-run required
-- All existing functionality preserved
-
----
-
-## 10. Security Considerations
-
-### 10.1 OIDC Security
-
-- Use PKCE (Proof Key for Code Exchange) for authorization flow
-- Validate `state` parameter to prevent CSRF
-- Validate `nonce` in ID token
-- Validate token signatures using provider's JWKS
-- Validate issuer and audience claims
-- Store client secret encrypted (AES-256)
-- Use secure, HTTP-only cookies for refresh tokens
-
-### 10.2 Registration Security
-
-- Rate limit registration attempts (5 per hour per IP)
-- Password requirements: min 8 chars
-- Secure password hashing (bcrypt, 10+ rounds)
-- Admin approval adds human review layer (optional)
-
-### 10.3 Audiobookshelf API Security
-
-- API token stored encrypted
-- Validate SSL certificates
-- Sanitize all input from ABS responses
-- Rate limit API calls to prevent DoS
-
----
-
-## 11. Implementation Phases
-
-### Phase 1: Foundation (Abstraction Layer)
-
-**Scope:**
-- Create `ILibraryService` interface
-- Refactor Plex code into `PlexLibraryService`
-- Create `IAuthProvider` interface
-- Refactor Plex OAuth into `PlexAuthProvider`
-- Add `system.backend_mode` config key
-- Database schema additions
-
-**Files to Create/Modify:**
-```
-src/lib/services/library/
-├── ILibraryService.ts # Interface
-├── PlexLibraryService.ts # Refactored Plex
-├── factory.ts # Service factory
-
-src/lib/services/auth/
-├── IAuthProvider.ts # Interface
-├── PlexAuthProvider.ts # Refactored Plex OAuth
-├── factory.ts # Provider factory
-
-prisma/schema.prisma # Schema updates
-```
-
-**Tests:**
-- Existing Plex functionality unchanged
-- Service factory returns correct implementation
-
-### Phase 2: Audiobookshelf Integration
-
-**Scope:**
-- Implement `AudiobookshelfLibraryService`
-- ABS connection testing
-- Library scanning
-- Availability detection
-- Recently added polling
-- Audiobook matching (ASIN/ISBN support)
-
-**Files to Create:**
-```
-src/lib/services/library/
-├── AudiobookshelfLibraryService.ts
-
-src/lib/services/audiobookshelf/
-├── api.ts # ABS API client
-├── types.ts # ABS type definitions
-├── matcher.ts # Enhanced matcher
-
-documentation/integrations/
-├── audiobookshelf.md # ABS integration docs
-```
-
-**Tests:**
-- ABS connection validation
-- Library item fetching
-- Matching algorithm with ASIN/ISBN
-
-### Phase 3: OIDC Authentication
-
-**Scope:**
-- Implement `OIDCAuthProvider`
-- OIDC discovery support
-- Authorization flow with PKCE
-- Token validation
-- User creation/mapping
-- **Access control implementation** (group claim, allowed list, admin approval)
-- Admin role claim mapping (optional)
-
-**Files to Create:**
-```
-src/lib/services/auth/
-├── OIDCAuthProvider.ts
-
-src/app/api/auth/oidc/
-├── login/route.ts
-├── callback/route.ts
-
-src/lib/utils/
-├── oidc.ts # OIDC utilities
-```
-
-**Dependencies:**
-- `openid-client` npm package (or similar)
-
-**Tests:**
-- OIDC flow end-to-end
-- Token validation
-- User mapping
-
-### Phase 4: Manual Registration
-
-**Scope:**
-- Implement `LocalAuthProvider`
-- Registration endpoint
-- Local login endpoint
-- Admin approval workflow (optional)
-
-**Files to Create:**
-```
-src/lib/services/auth/
-├── LocalAuthProvider.ts
-
-src/app/api/auth/
-├── register/route.ts
-├── local/login/route.ts
-
-src/components/auth/
-├── RegistrationForm.tsx
-├── LocalLoginForm.tsx
-```
-
-**Tests:**
-- Registration flow
-- Password validation
-- Admin approval workflow
-
-### Phase 5: Setup Wizard Modifications
-
-**Scope:**
-- Backend selection step
-- ABS configuration step
-- Auth method selection step
-- OIDC configuration step
-- Registration settings step
-- Conditional step flow
-
-**Files to Modify:**
-```
-src/app/setup/
-├── page.tsx # Add new steps
-├── components/
-│ ├── BackendSelectionStep.tsx # NEW
-│ ├── AudiobookshelfStep.tsx # NEW
-│ ├── AuthMethodStep.tsx # NEW
-│ ├── OIDCConfigStep.tsx # NEW
-│ ├── RegistrationStep.tsx # NEW
-│ ├── AdminAccountStep.tsx # MODIFY
-```
-
-**Tests:**
-- Full setup flow for each mode
-- Validation at each step
-- Config persistence
-
-### Phase 6: Settings & UI Updates
-
-**Scope:**
-- ABS settings tab
-- OIDC settings tab
-- Registration settings tab
-- Login page modes
-- Conditional UI elements
-
-**Files to Modify:**
-```
-src/app/admin/settings/
-├── page.tsx # Add new tabs
-├── components/
-│ ├── AudiobookshelfTab.tsx # NEW
-│ ├── OIDCTab.tsx # NEW
-│ ├── RegistrationTab.tsx # NEW
-
-src/app/login/
-├── page.tsx # Multi-mode login
-```
-
-### Phase 7: Testing & Documentation
-
-**Scope:**
-- Integration tests for all modes
-- Documentation updates
-- User guides
-- Troubleshooting guides
-
-**Deliverables:**
-- End-to-end tests for Plex mode (regression)
-- End-to-end tests for ABS + OIDC mode
-- End-to-end tests for ABS + Manual mode
-- Updated documentation
-- User setup guides
-
----
-
-## 12. Configuration Reference
-
-### 12.1 Environment Variables (Optional Overrides)
-
-```env
-# Backend mode (cannot override after setup)
-BACKEND_MODE=audiobookshelf
-
-# Audiobookshelf
-ABS_URL=http://audiobookshelf:13378
-ABS_API_TOKEN=xxxx
-
-# OIDC
-OIDC_ISSUER=https://auth.example.com
-OIDC_CLIENT_ID=readmeabook
-OIDC_CLIENT_SECRET=xxxx
-
-# Registration
-REGISTRATION_ENABLED=true
-REQUIRE_ADMIN_APPROVAL=true
-```
-
-### 12.2 Database Configuration Keys
-
-```
-system.backend_mode = 'plex' | 'audiobookshelf'
-
-# Audiobookshelf
-abs.server_url = 'http://...'
-abs.api_token = (encrypted)
-abs.library_id = 'lib_xxx'
-abs.server_id = 'xxx'
-
-# OIDC
-oidc.enabled = 'true' | 'false'
-oidc.provider_name = 'Authentik'
-oidc.issuer_url = 'https://...'
-oidc.client_id = 'xxx'
-oidc.client_secret = (encrypted)
-
-# OIDC Access Control (Authorization)
-oidc.access_control_method = 'group_claim' | 'allowed_list' | 'admin_approval' | 'open'
-oidc.access_group_claim = 'groups' # claim name containing groups
-oidc.access_group_value = 'readmeabook-users' # required group for access
-oidc.allowed_emails = '[]' # JSON array (if method = 'allowed_list')
-oidc.allowed_usernames = '[]' # JSON array (if method = 'allowed_list')
-
-# OIDC Admin Role Mapping (separate from access control)
-oidc.admin_claim_enabled = 'false'
-oidc.admin_claim_name = 'groups'
-oidc.admin_claim_value = 'readmeabook-admin'
-
-# Registration
-auth.registration_enabled = 'false'
-auth.require_admin_approval = 'false'
-```
-
----
-
-## 13. Success Metrics
-
-### 13.1 Functional Requirements
-
-- [ ] Plex mode unchanged (regression tests pass)
-- [ ] ABS library scanning works
-- [ ] ABS availability detection works
-- [ ] OIDC authentication flow works
-- [ ] Manual registration flow works
-- [ ] Setup wizard handles all modes
-- [ ] Settings pages handle all modes
-- [ ] BookDate works in ABS mode (with limitations)
-
-### 13.2 Non-Functional Requirements
-
-- [ ] ABS API response time < 2s for library fetch
-- [ ] OIDC login completes < 5s (excluding provider time)
-- [ ] No breaking changes to existing Plex installations
-- [ ] Documentation complete for all modes
-
----
-
-## 14. Open Questions
-
-1. **ABS Webhooks:** Should we use ABS webhooks for real-time updates instead of polling?
- - Pro: More efficient, faster updates
- - Con: Requires ABS to reach ReadMeABook (network config)
-
-2. **User Data Export:** Should we provide export functionality before mode switch?
- - Pro: Better user experience
- - Con: Additional complexity
-
-3. **Email Service:** What email provider for verification emails?
- - Options: SMTP config, SendGrid, Mailgun
- - Or: Skip email verification initially
-
-4. **ABS User Ratings:** Should we add rating functionality in ABS mode?
- - Option: Store ratings in ReadMeABook DB
- - Or: Skip ratings, rely on custom prompts for BookDate
-
-5. **Multiple OIDC Providers:** Support multiple providers simultaneously?
- - Initial: Single provider
- - Future: Multiple providers
-
----
-
-## 15. Appendix
-
-### A. Audiobookshelf API Token Generation
-
-**Instructions for users:**
-1. Login to Audiobookshelf web UI as admin
-2. Go to Settings → API Keys
-3. Click "Add API Key"
-4. Enter a descriptive name (e.g., "ReadMeABook")
-5. Copy the generated API key
-6. Use the key in ReadMeABook setup
-
-### B. OIDC Provider Setup Guides
-
-**Authentik:**
-1. Create Application in Authentik
-2. Create OAuth2/OIDC Provider
-3. Set redirect URI: `{readmeabook_url}/api/auth/oidc/callback`
-4. Note Client ID and Client Secret
-5. Issuer URL: `https://{authentik_domain}/application/o/{app_slug}/`
-
-**Keycloak:**
-1. Create Realm or use existing
-2. Create Client (OpenID Connect)
-3. Set redirect URI
-4. Enable Client Authentication
-5. Note Client ID and Secret
-6. Issuer URL: `https://{keycloak}/realms/{realm}`
-
-### C. Related Documentation
-
-- [Current Auth System](../backend/services/auth.md)
-- [Current Plex Integration](../integrations/plex.md)
-- [Database Schema](../backend/database.md)
-- [Setup Wizard](../setup-wizard.md)
-- [Settings Pages](../settings-pages.md)
-
----
-
-**Document Status:** PRD Complete - Ready for Review
-**Next Steps:** Architecture review → Phase 1 implementation approval
diff --git a/documentation/fixes/asin-matching-fix.md b/documentation/fixes/asin-matching-fix.md
index 28d2011..0d2b688 100644
--- a/documentation/fixes/asin-matching-fix.md
+++ b/documentation/fixes/asin-matching-fix.md
@@ -322,7 +322,7 @@ Plex (After Fix):
## Related Documentation
- [Database Schema](../backend/database.md) - Updated with Plex_Library table
-- [Audiobookshelf Integration](../features/audiobookshelf-integration.md) - Full backend integration docs
+- [File Hash Matching](file-hash-matching.md) - ASIN matching via file hash for ABS
- [Plex Integration](../integrations/plex.md) - Plex-specific matching details
## Future Enhancements
diff --git a/documentation/phase3/sabnzbd.md b/documentation/phase3/sabnzbd.md
index 51357de..a5f8318 100644
--- a/documentation/phase3/sabnzbd.md
+++ b/documentation/phase3/sabnzbd.md
@@ -243,13 +243,20 @@ organizePath = PathMapper.transform(sabPath, config)
- When enabled, NZB downloads are automatically cleaned up after files are organized
- Saves disk space by removing completed download files
-**Two-Stage Cleanup Process:**
-1. **Filesystem Cleanup:** Manually deletes download directory/files using `fs.rm()`
+**Three-Stage Cleanup Process:**
+1. **Download Cleanup:** Deletes download directory/files using `fs.rm()`
- Removes extracted files from category download directory
- Handles both single files and directories recursively
- Gracefully handles already-deleted files (ENOENT)
-2. **SABnzbd Archive:** Archives NZB from history (hides from UI)
+2. **Parent Directory Cleanup:** Removes empty parent directories up to `download_dir`
+ - Uses `removeEmptyParentDirectories()` utility from `cleanup-helpers.ts`
+ - Walks up directory tree removing empty folders (e.g., empty category folder)
+ - Never deletes `download_dir` itself (protected boundary)
+ - Stops at first non-empty directory
+ - Gracefully handles permission errors and race conditions
+
+3. **SABnzbd Archive:** Archives NZB from history (hides from UI)
- Uses SABnzbd's archive feature (default: `archive=1`)
- Preserves job in hidden archive for troubleshooting/auditing
- Does NOT permanently delete from history
diff --git a/src/app/api/admin/settings/download-clients/[id]/route.ts b/src/app/api/admin/settings/download-clients/[id]/route.ts
index f3ab796..79504a7 100644
--- a/src/app/api/admin/settings/download-clients/[id]/route.ts
+++ b/src/app/api/admin/settings/download-clients/[id]/route.ts
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
+import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.ID');
@@ -97,10 +98,15 @@ export async function PUT(
}
}
- // Update clients array
+ // Update clients array and encrypt passwords before saving
clients[clientIndex] = updatedClient;
+ const encryptionService = getEncryptionService();
+ const encryptedClients = clients.map(c => ({
+ ...c,
+ password: c.password ? encryptionService.encrypt(c.password) : '',
+ }));
await config.setMany([
- { key: 'download_clients', value: JSON.stringify(clients) },
+ { key: 'download_clients', value: JSON.stringify(encryptedClients) },
]);
// Invalidate cache
@@ -153,10 +159,15 @@ export async function DELETE(
const deletedClient = clients[clientIndex];
- // Remove client from array
+ // Remove client from array and encrypt passwords before saving
const updatedClients = clients.filter(c => c.id !== id);
+ const encryptionService = getEncryptionService();
+ const encryptedClients = updatedClients.map(c => ({
+ ...c,
+ password: c.password ? encryptionService.encrypt(c.password) : '',
+ }));
await config.setMany([
- { key: 'download_clients', value: JSON.stringify(updatedClients) },
+ { key: 'download_clients', value: JSON.stringify(encryptedClients) },
]);
// Invalidate cache
diff --git a/src/app/api/admin/settings/download-clients/route.ts b/src/app/api/admin/settings/download-clients/route.ts
index 62760db..9ba1063 100644
--- a/src/app/api/admin/settings/download-clients/route.ts
+++ b/src/app/api/admin/settings/download-clients/route.ts
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
+import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -111,7 +112,7 @@ export async function POST(request: NextRequest) {
);
}
- // Create new client config
+ // Create new client config for testing (with plaintext password)
// qBittorrent credentials are optional (supports IP whitelist auth)
const newClient: DownloadClientConfig = {
id: randomUUID(),
@@ -120,7 +121,7 @@ export async function POST(request: NextRequest) {
enabled: true,
url,
username: username || '',
- password: password || '',
+ password: password || '', // Plaintext for connection test
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
@@ -137,10 +138,17 @@ export async function POST(request: NextRequest) {
);
}
+ // Encrypt all passwords before saving (existing clients come decrypted from getAllClients)
+ const encryptionService = getEncryptionService();
+ const allClients = [...existingClients, newClient];
+ const encryptedClients = allClients.map(c => ({
+ ...c,
+ password: c.password ? encryptionService.encrypt(c.password) : '',
+ }));
+
// Save updated clients array
- const updatedClients = [...existingClients, newClient];
await config.setMany([
- { key: 'download_clients', value: JSON.stringify(updatedClients) },
+ { key: 'download_clients', value: JSON.stringify(encryptedClients) },
]);
// Invalidate cache
diff --git a/src/app/api/admin/settings/paths/route.ts b/src/app/api/admin/settings/paths/route.ts
index 28e766b..1ede465 100644
--- a/src/app/api/admin/settings/paths/route.ts
+++ b/src/app/api/admin/settings/paths/route.ts
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
+import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Paths');
@@ -84,6 +85,14 @@ export async function PUT(request: NextRequest) {
logger.info('Paths settings updated');
+ // Clear config cache for all updated keys so services get fresh values
+ const configService = getConfigService();
+ configService.clearCache('download_dir');
+ configService.clearCache('media_dir');
+ configService.clearCache('audiobook_path_template');
+ configService.clearCache('metadata_tagging_enabled');
+ configService.clearCache('chapter_merging_enabled');
+
// Invalidate qBittorrent service singleton to force reload of download_dir
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
invalidateQBittorrentService();
diff --git a/src/app/api/admin/settings/plex/route.ts b/src/app/api/admin/settings/plex/route.ts
index 8f58b6b..ac45a1a 100644
--- a/src/app/api/admin/settings/plex/route.ts
+++ b/src/app/api/admin/settings/plex/route.ts
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getPlexService } from '@/lib/integrations/plex.service';
+import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.AdminPlexSettings');
@@ -33,10 +34,12 @@ export async function PUT(request: NextRequest) {
// Only update token if it's not the masked value
if (!token.startsWith('••••')) {
+ const encryptionService = getEncryptionService();
+ const encryptedToken = encryptionService.encrypt(token);
await prisma.configuration.upsert({
where: { key: 'plex_token' },
- update: { value: token },
- create: { key: 'plex_token', value: token },
+ update: { value: encryptedToken, encrypted: true },
+ create: { key: 'plex_token', value: encryptedToken, encrypted: true },
});
}
@@ -59,10 +62,10 @@ export async function PUT(request: NextRequest) {
const plexService = getPlexService();
const actualToken = token.startsWith('••••') ? null : token;
- // Get token from DB if it was masked
- const tokenToUse = actualToken || (await prisma.configuration.findUnique({
- where: { key: 'plex_token' },
- }))?.value;
+ // Get token from DB if it was masked (decrypted via ConfigService)
+ const { getConfigService } = await import('@/lib/services/config.service');
+ const configService = getConfigService();
+ const tokenToUse = actualToken || await configService.get('plex_token');
if (tokenToUse) {
const serverInfo = await plexService.testConnection(url, tokenToUse);
diff --git a/src/app/api/admin/settings/prowlarr/route.ts b/src/app/api/admin/settings/prowlarr/route.ts
index 7d54595..946e0b3 100644
--- a/src/app/api/admin/settings/prowlarr/route.ts
+++ b/src/app/api/admin/settings/prowlarr/route.ts
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
+import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
@@ -32,10 +33,12 @@ export async function PUT(request: NextRequest) {
// Only update API key if it's not the masked value
if (!apiKey.startsWith('••••')) {
+ const encryptionService = getEncryptionService();
+ const encryptedApiKey = encryptionService.encrypt(apiKey);
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
- update: { value: apiKey },
- create: { key: 'prowlarr_api_key', value: apiKey },
+ update: { value: encryptedApiKey, encrypted: true },
+ create: { key: 'prowlarr_api_key', value: encryptedApiKey, encrypted: true },
});
}
diff --git a/src/app/api/admin/settings/test-download-client/route.ts b/src/app/api/admin/settings/test-download-client/route.ts
index fae9c88..fc8161c 100644
--- a/src/app/api/admin/settings/test-download-client/route.ts
+++ b/src/app/api/admin/settings/test-download-client/route.ts
@@ -5,7 +5,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
-import { prisma } from '@/lib/db';
+import { getConfigService } from '@/lib/services/config.service';
+import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { RMABLogger } from '@/lib/utils/logger';
@@ -43,21 +44,24 @@ export async function POST(request: NextRequest) {
);
}
- // If password is masked, fetch the actual value from database
+ // If password is masked, fetch the actual value from download client manager (decrypted)
let actualPassword = password;
- if (password && password.startsWith('••••')) {
- const storedPassword = await prisma.configuration.findUnique({
- where: { key: 'download_client_password' },
- });
+ if (password && (password.startsWith('••••') || password === '********')) {
+ const configService = getConfigService();
+ const manager = getDownloadClientManager(configService);
+ const clients = await manager.getAllClients();
- if (!storedPassword?.value) {
+ // Find the first client of matching type to get its password
+ const matchingClient = clients.find(c => c.type === type);
+
+ if (!matchingClient?.password) {
return NextResponse.json(
{ success: false, error: 'No stored password/API key found. Please re-enter it.' },
{ status: 400 }
);
}
- actualPassword = storedPassword.value;
+ actualPassword = matchingClient.password;
}
// Validate required fields per client type and test connection
diff --git a/src/app/api/admin/settings/test-plex/route.ts b/src/app/api/admin/settings/test-plex/route.ts
index 88ab2ee..599f340 100644
--- a/src/app/api/admin/settings/test-plex/route.ts
+++ b/src/app/api/admin/settings/test-plex/route.ts
@@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
-import { prisma } from '@/lib/db';
+import { getConfigService } from '@/lib/services/config.service';
import { getPlexService } from '@/lib/integrations/plex.service';
import { RMABLogger } from '@/lib/utils/logger';
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
);
}
- // If token is masked, fetch the actual value from database
+ // If token is masked, fetch the actual value from database (decrypted)
let actualToken = token;
if (token.startsWith('••••')) {
- const storedToken = await prisma.configuration.findUnique({
- where: { key: 'plex_token' },
- });
+ const configService = getConfigService();
+ const storedToken = await configService.get('plex_token');
- if (!storedToken?.value) {
+ if (!storedToken) {
return NextResponse.json(
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
{ status: 400 }
);
}
- actualToken = storedToken.value;
+ actualToken = storedToken;
}
const plexService = getPlexService();
diff --git a/src/app/api/admin/settings/test-prowlarr/route.ts b/src/app/api/admin/settings/test-prowlarr/route.ts
index 8dad05e..e950ad4 100644
--- a/src/app/api/admin/settings/test-prowlarr/route.ts
+++ b/src/app/api/admin/settings/test-prowlarr/route.ts
@@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
-import { prisma } from '@/lib/db';
+import { getConfigService } from '@/lib/services/config.service';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
import { RMABLogger } from '@/lib/utils/logger';
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
);
}
- // If API key is masked, fetch the actual value from database
+ // If API key is masked, fetch the actual value from database (decrypted)
let actualApiKey = apiKey;
if (apiKey.startsWith('••••')) {
- const storedApiKey = await prisma.configuration.findUnique({
- where: { key: 'prowlarr_api_key' },
- });
+ const configService = getConfigService();
+ const storedApiKey = await configService.get('prowlarr_api_key');
- if (!storedApiKey?.value) {
+ if (!storedApiKey) {
return NextResponse.json(
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
{ status: 400 }
);
}
- actualApiKey = storedApiKey.value;
+ actualApiKey = storedApiKey;
}
// Create a new ProwlarrService instance with test credentials
diff --git a/src/app/api/auth/plex/callback/route.ts b/src/app/api/auth/plex/callback/route.ts
index c5516b7..b33c1cb 100644
--- a/src/app/api/auth/plex/callback/route.ts
+++ b/src/app/api/auth/plex/callback/route.ts
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getConfigService } from '@/lib/services/config.service';
+import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
@@ -149,12 +150,19 @@ export async function GET(request: NextRequest) {
if (homeUsers.length > 1) {
logger.info('Account has multiple home profiles, redirecting to profile selection');
+ // SECURITY: Store the Plex token server-side instead of exposing it to the client
+ // The token is keyed by pinId and will be retrieved during profile selection/switch
+ const tokenCache = getAuthTokenCache();
+ tokenCache.set(pinId, authToken);
+ logger.debug('Plex token cached for profile selection', { pinId });
+
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
const accept = request.headers.get('accept') || '';
const isBrowserRequest = accept.includes('text/html');
if (isBrowserRequest) {
- // For browser requests (mobile), construct redirect URL with session data
+ // For browser requests (mobile), construct redirect URL
+ // Token is stored server-side, only pinId is passed to client
const host = request.headers.get('host') || 'localhost:3030';
const protocol = request.headers.get('x-forwarded-proto') ||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
@@ -162,7 +170,8 @@ export async function GET(request: NextRequest) {
logger.debug('Redirecting to profile selection', { selectProfileUrl });
- // Return HTML page with JavaScript to store token in sessionStorage and redirect
+ // Return HTML page that redirects to profile selection
+ // Note: Token is NOT included - it's stored server-side for security
const html = `
@@ -172,9 +181,7 @@ export async function GET(request: NextRequest) {
Loading profiles...
@@ -189,12 +196,12 @@ export async function GET(request: NextRequest) {
});
} else {
// For AJAX requests (desktop popup), return JSON with redirect instruction
+ // Note: Token is NOT included - it's stored server-side for security
return NextResponse.json({
success: true,
authorized: true,
requiresProfileSelection: true,
redirectUrl: `/auth/select-profile?pinId=${pinId}`,
- mainAccountToken: authToken, // Client will store this temporarily
homeUsers: homeUsers.length,
});
}
diff --git a/src/app/api/auth/plex/home-users/route.ts b/src/app/api/auth/plex/home-users/route.ts
index 045e8cf..6e0b234 100644
--- a/src/app/api/auth/plex/home-users/route.ts
+++ b/src/app/api/auth/plex/home-users/route.ts
@@ -5,6 +5,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
+import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Auth.Plex.HomeUsers');
@@ -12,16 +13,36 @@ const logger = RMABLogger.create('API.Auth.Plex.HomeUsers');
/**
* GET /api/auth/plex/home-users
* Get list of Plex Home profiles for authenticated user
+ *
+ * Authentication: Provide X-Plex-Pin-Id header with the PIN ID from OAuth flow.
+ * The Plex token is retrieved from server-side cache for security.
*/
export async function GET(request: NextRequest) {
try {
- const authToken = request.headers.get('X-Plex-Token');
+ // Get pinId from header - token is stored server-side for security
+ const pinId = request.headers.get('X-Plex-Pin-Id');
- if (!authToken) {
+ if (!pinId) {
+ logger.warn('Missing X-Plex-Pin-Id header');
return NextResponse.json(
{
error: 'Unauthorized',
- message: 'Missing authentication token',
+ message: 'Missing PIN ID. Please restart the login process.',
+ },
+ { status: 401 }
+ );
+ }
+
+ // Retrieve the Plex token from server-side cache
+ const tokenCache = getAuthTokenCache();
+ const authToken = tokenCache.get(pinId);
+
+ if (!authToken) {
+ logger.warn('Token not found or expired for pinId', { pinId });
+ return NextResponse.json(
+ {
+ error: 'SessionExpired',
+ message: 'Your session has expired. Please restart the login process.',
},
{ status: 401 }
);
@@ -30,6 +51,8 @@ export async function GET(request: NextRequest) {
const plexService = getPlexService();
const users = await plexService.getHomeUsers(authToken);
+ logger.debug('Home users retrieved', { pinId, userCount: users.length });
+
return NextResponse.json({
success: true,
users,
diff --git a/src/app/api/auth/plex/switch-profile/route.ts b/src/app/api/auth/plex/switch-profile/route.ts
index e08abb1..cb3891f 100644
--- a/src/app/api/auth/plex/switch-profile/route.ts
+++ b/src/app/api/auth/plex/switch-profile/route.ts
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
+import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
@@ -15,23 +16,41 @@ const logger = RMABLogger.create('API.PlexSwitchProfile');
/**
* POST /api/auth/plex/switch-profile
* Switch to a Plex Home profile and complete authentication
+ *
+ * Authentication: Provide pinId in request body. The Plex token is
+ * retrieved from server-side cache for security.
*/
export async function POST(request: NextRequest) {
try {
- const mainAccountToken = request.headers.get('X-Plex-Token');
+ const body = await request.json();
+ const { userId, pin, pinId, profileInfo } = body;
- if (!mainAccountToken) {
+ // Validate pinId is provided
+ if (!pinId) {
+ logger.warn('Missing pinId in request body');
return NextResponse.json(
{
error: 'Unauthorized',
- message: 'Missing authentication token',
+ message: 'Missing PIN ID. Please restart the login process.',
},
{ status: 401 }
);
}
- const body = await request.json();
- const { userId, pin, pinId, profileInfo } = body;
+ // Retrieve the Plex token from server-side cache
+ const tokenCache = getAuthTokenCache();
+ const mainAccountToken = tokenCache.get(pinId);
+
+ if (!mainAccountToken) {
+ logger.warn('Token not found or expired for pinId', { pinId });
+ return NextResponse.json(
+ {
+ error: 'SessionExpired',
+ message: 'Your session has expired. Please restart the login process.',
+ },
+ { status: 401 }
+ );
+ }
if (!userId) {
return NextResponse.json(
@@ -155,6 +174,10 @@ export async function POST(request: NextRequest) {
const refreshToken = generateRefreshToken(user.id);
+ // Clean up the cached Plex token - authentication is complete
+ tokenCache.delete(pinId);
+ logger.debug('Cached Plex token cleaned up after successful auth', { pinId });
+
// Return tokens and user info
return NextResponse.json({
success: true,
diff --git a/src/app/api/init/route.ts b/src/app/api/init/route.ts
index 1d64943..366bfb8 100644
--- a/src/app/api/init/route.ts
+++ b/src/app/api/init/route.ts
@@ -8,6 +8,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSchedulerService } from '@/lib/services/scheduler.service';
+import { runCredentialMigration } from '@/lib/services/credential-migration.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Init');
@@ -18,6 +19,9 @@ export async function GET(request: NextRequest) {
try {
logger.info('Initializing application services...');
+ // Run credential migration (encrypts any plaintext credentials)
+ await runCredentialMigration();
+
// Initialize scheduler service
const schedulerService = getSchedulerService();
await schedulerService.start();
diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts
index ae26e2d..74bb3b4 100644
--- a/src/app/api/setup/complete/route.ts
+++ b/src/app/api/setup/complete/route.ts
@@ -193,10 +193,11 @@ export async function POST(request: NextRequest) {
create: { key: 'plex_url', value: plex.url },
});
+ const encryptedPlexToken = encryptionService.encrypt(plex.token);
await prisma.configuration.upsert({
where: { key: 'plex_token' },
- update: { value: plex.token },
- create: { key: 'plex_token', value: plex.token },
+ update: { value: encryptedPlexToken, encrypted: true },
+ create: { key: 'plex_token', value: encryptedPlexToken, encrypted: true },
});
await prisma.configuration.upsert({
@@ -375,10 +376,11 @@ export async function POST(request: NextRequest) {
create: { key: 'prowlarr_url', value: prowlarr.url },
});
+ const encryptedProwlarrApiKey = encryptionService.encrypt(prowlarr.api_key);
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
- update: { value: prowlarr.api_key },
- create: { key: 'prowlarr_api_key', value: prowlarr.api_key },
+ update: { value: encryptedProwlarrApiKey, encrypted: true },
+ create: { key: 'prowlarr_api_key', value: encryptedProwlarrApiKey, encrypted: true },
});
await prisma.configuration.upsert({
@@ -414,10 +416,16 @@ export async function POST(request: NextRequest) {
throw new Error('Invalid download client configuration');
}
+ // Encrypt passwords in download clients
+ const encryptedClients = downloadClientsArray.map(client => ({
+ ...client,
+ password: client.password ? encryptionService.encrypt(client.password) : '',
+ }));
+
await prisma.configuration.upsert({
where: { key: 'download_clients' },
- update: { value: JSON.stringify(downloadClientsArray) },
- create: { key: 'download_clients', value: JSON.stringify(downloadClientsArray) },
+ update: { value: JSON.stringify(encryptedClients) },
+ create: { key: 'download_clients', value: JSON.stringify(encryptedClients) },
});
// Path configuration
diff --git a/src/app/auth/select-profile/page.tsx b/src/app/auth/select-profile/page.tsx
index 6cb82d2..3615666 100644
--- a/src/app/auth/select-profile/page.tsx
+++ b/src/app/auth/select-profile/page.tsx
@@ -38,42 +38,45 @@ function SelectProfileContent() {
const [pinError, setPinError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
- // Get token from session storage (set by OAuth callback)
- const mainAccountToken = typeof window !== 'undefined' ? sessionStorage.getItem('plex_main_token') : null;
+ // Get pinId from URL - the Plex token is stored server-side for security
const pinId = searchParams.get('pinId');
useEffect(() => {
- if (!mainAccountToken || !pinId) {
+ if (!pinId) {
setError('Invalid session. Please try logging in again.');
setIsLoading(false);
return;
}
- // Fetch home users
+ // Fetch home users using pinId (token is looked up server-side)
const fetchProfiles = async () => {
try {
const response = await fetch('/api/auth/plex/home-users', {
headers: {
- 'X-Plex-Token': mainAccountToken,
+ 'X-Plex-Pin-Id': pinId,
},
});
if (!response.ok) {
- throw new Error('Failed to fetch profiles');
+ const errorData = await response.json().catch(() => ({}));
+ if (response.status === 401) {
+ throw new Error(errorData.message || 'Session expired. Please try logging in again.');
+ }
+ throw new Error(errorData.message || 'Failed to fetch profiles');
}
const data = await response.json();
setProfiles(data.users || []);
setIsLoading(false);
- } catch (err) {
+ } catch (err: any) {
console.error('Failed to fetch profiles:', err);
- setError('Failed to load profiles. Please try again.');
+ setError(err.message || 'Failed to load profiles. Please try again.');
setIsLoading(false);
}
};
fetchProfiles();
- }, [mainAccountToken, pinId]);
+ }, [pinId]);
const handleProfileSelect = async (profile: PlexHomeUser) => {
setSelectedProfile(profile.id);
@@ -97,7 +100,10 @@ function SelectProfileContent() {
};
const completeProfileSelection = async (profileId: string, profilePin?: string) => {
- if (!mainAccountToken) return;
+ if (!pinId) {
+ setError('Session expired. Please try logging in again.');
+ return;
+ }
setIsSubmitting(true);
setPinError(null);
@@ -111,11 +117,11 @@ function SelectProfileContent() {
}
try {
+ // Switch profile using pinId - token is looked up server-side for security
const response = await fetch('/api/auth/plex/switch-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Plex-Token': mainAccountToken,
},
body: JSON.stringify({
userId: profileId,
@@ -134,8 +140,15 @@ function SelectProfileContent() {
if (!response.ok) {
if (response.status === 401) {
- setPinError('Invalid PIN. Please try again.');
- setPin('');
+ // Check if it's a PIN error or session expiry
+ if (data.error === 'InvalidPIN') {
+ setPinError('Invalid PIN. Please try again.');
+ setPin('');
+ setIsSubmitting(false);
+ return;
+ }
+ // Session expired
+ setError(data.message || 'Session expired. Please try logging in again.');
setIsSubmitting(false);
return;
}
@@ -150,9 +163,6 @@ function SelectProfileContent() {
// Update auth context
setAuthData(data.user, data.accessToken);
- // Clear session storage
- sessionStorage.removeItem('plex_main_token');
-
// Redirect to home
router.push('/');
} catch (err: any) {
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
index 98ffe39..960851b 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -173,10 +173,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.success && data.authorized) {
// Check if profile selection is required (Plex Home accounts)
if (data.requiresProfileSelection) {
- // Store main account token temporarily for profile selection
- sessionStorage.setItem('plex_main_token', data.mainAccountToken);
-
// Redirect to profile selection page
+ // Note: Plex token is stored server-side for security, not in sessionStorage
window.location.href = data.redirectUrl;
return;
}
diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts
index 3e67776..63dcb46 100644
--- a/src/lib/integrations/qbittorrent.service.ts
+++ b/src/lib/integrations/qbittorrent.service.ts
@@ -1027,8 +1027,8 @@ export async function getQBittorrentService(): Promise {
pathMappingEnabled: clientConfig.remotePathMappingEnabled,
});
- // Validate required fields
- if (!clientConfig.url || !clientConfig.username || !clientConfig.password) {
+ // Validate required fields (only URL is required - username/password optional for whitelist users)
+ if (!clientConfig.url) {
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
@@ -1045,8 +1045,8 @@ export async function getQBittorrentService(): Promise {
logger.info('[QBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
clientConfig.url,
- clientConfig.username,
- clientConfig.password,
+ clientConfig.username || '',
+ clientConfig.password || '',
downloadDir,
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify,
diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts
index af0e3db..3a0d5a8 100644
--- a/src/lib/processors/organize-files.processor.ts
+++ b/src/lib/processors/organize-files.processor.ts
@@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
+import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
/**
* Process organize files job
@@ -296,6 +297,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
+
+ // Clean up empty parent directories (e.g., empty category folders)
+ // Get download_dir as the boundary - never delete above this
+ const downloadDir = await configService.get('download_dir') || '/downloads';
+ const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
+ boundaryPath: downloadDir,
+ logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
+ });
+
+ if (cleanupResult.removedDirectories.length > 0) {
+ logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
+ }
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -776,6 +789,18 @@ async function processEbookOrganization(
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
+
+ // Clean up empty parent directories (e.g., empty category folders)
+ // Get download_dir as the boundary - never delete above this
+ const downloadDir = await configService.get('download_dir') || '/downloads';
+ const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
+ boundaryPath: downloadDir,
+ logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
+ });
+
+ if (cleanupResult.removedDirectories.length > 0) {
+ logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
+ }
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
diff --git a/src/lib/services/audiobookshelf/api.ts b/src/lib/services/audiobookshelf/api.ts
index eacd939..911a4b9 100644
--- a/src/lib/services/audiobookshelf/api.ts
+++ b/src/lib/services/audiobookshelf/api.ts
@@ -1,6 +1,10 @@
/**
* Component: Audiobookshelf API Client
- * Documentation: documentation/features/audiobookshelf-integration.md
+ *
+ * Provides API methods for interacting with Audiobookshelf:
+ * - Library scanning and item fetching
+ * - Metadata matching (with ASIN for accurate Audible lookup)
+ * - Item management
*/
import { getConfigService } from '../config.service';
diff --git a/src/lib/services/auth-token-cache.service.ts b/src/lib/services/auth-token-cache.service.ts
new file mode 100644
index 0000000..a5587b2
--- /dev/null
+++ b/src/lib/services/auth-token-cache.service.ts
@@ -0,0 +1,233 @@
+/**
+ * Component: Auth Token Cache Service
+ * Documentation: documentation/backend/services/auth.md
+ *
+ * Provides secure server-side storage for Plex OAuth tokens during the
+ * profile selection flow. Tokens are stored in memory with automatic
+ * expiration to prevent sensitive data from being exposed in client responses.
+ *
+ * Security: This service exists to prevent Plex tokens from being embedded
+ * in HTML responses or JSON payloads where they could be captured by
+ * viewing page source or intercepting network traffic.
+ */
+
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('AuthTokenCache');
+
+interface CachedToken {
+ token: string;
+ createdAt: number;
+ expiresAt: number;
+}
+
+/**
+ * Default TTL for cached tokens (5 minutes)
+ * This is sufficient time for profile selection while minimizing exposure window
+ */
+const DEFAULT_TTL_MS = 5 * 60 * 1000;
+
+/**
+ * Cleanup interval - run every minute to remove expired tokens
+ */
+const CLEANUP_INTERVAL_MS = 60 * 1000;
+
+/**
+ * AuthTokenCacheService - Singleton service for secure token storage
+ *
+ * Uses an in-memory Map for storage. Tokens are automatically expired
+ * and cleaned up. This is intentionally ephemeral - if the server restarts,
+ * users in the middle of profile selection will need to re-authenticate,
+ * which is acceptable for security.
+ */
+class AuthTokenCacheService {
+ private cache: Map = new Map();
+ private cleanupInterval: NodeJS.Timeout | null = null;
+ private ttlMs: number;
+
+ constructor(ttlMs: number = DEFAULT_TTL_MS) {
+ this.ttlMs = ttlMs;
+ this.startCleanupInterval();
+ }
+
+ /**
+ * Store a Plex token for later retrieval
+ *
+ * @param pinId - The Plex PIN ID (used as the lookup key)
+ * @param token - The Plex OAuth token to store
+ * @param ttlMs - Optional custom TTL for this token
+ */
+ set(pinId: string, token: string, ttlMs?: number): void {
+ const effectiveTtl = ttlMs ?? this.ttlMs;
+ const now = Date.now();
+
+ this.cache.set(pinId, {
+ token,
+ createdAt: now,
+ expiresAt: now + effectiveTtl,
+ });
+
+ logger.debug('Token cached', {
+ pinId,
+ ttlSeconds: Math.round(effectiveTtl / 1000),
+ cacheSize: this.cache.size,
+ });
+ }
+
+ /**
+ * Retrieve a stored token by PIN ID
+ *
+ * @param pinId - The Plex PIN ID
+ * @returns The stored token, or null if not found/expired
+ */
+ get(pinId: string): string | null {
+ const cached = this.cache.get(pinId);
+
+ if (!cached) {
+ logger.debug('Token not found in cache', { pinId });
+ return null;
+ }
+
+ // Check if expired
+ if (Date.now() > cached.expiresAt) {
+ logger.debug('Token expired', { pinId });
+ this.cache.delete(pinId);
+ return null;
+ }
+
+ logger.debug('Token retrieved from cache', { pinId });
+ return cached.token;
+ }
+
+ /**
+ * Remove a token from the cache
+ * Called after successful authentication to clean up
+ *
+ * @param pinId - The Plex PIN ID
+ * @returns true if a token was removed, false if not found
+ */
+ delete(pinId: string): boolean {
+ const existed = this.cache.has(pinId);
+ this.cache.delete(pinId);
+
+ if (existed) {
+ logger.debug('Token removed from cache', { pinId, cacheSize: this.cache.size });
+ }
+
+ return existed;
+ }
+
+ /**
+ * Check if a token exists and is not expired
+ *
+ * @param pinId - The Plex PIN ID
+ * @returns true if token exists and is valid
+ */
+ has(pinId: string): boolean {
+ const cached = this.cache.get(pinId);
+ if (!cached) return false;
+
+ if (Date.now() > cached.expiresAt) {
+ this.cache.delete(pinId);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the current cache size (for monitoring)
+ */
+ get size(): number {
+ return this.cache.size;
+ }
+
+ /**
+ * Manually trigger cleanup of expired tokens
+ * Called automatically on interval, but can be called manually if needed
+ */
+ cleanup(): number {
+ const now = Date.now();
+ let removed = 0;
+
+ for (const [pinId, cached] of this.cache.entries()) {
+ if (now > cached.expiresAt) {
+ this.cache.delete(pinId);
+ removed++;
+ }
+ }
+
+ if (removed > 0) {
+ logger.debug('Expired tokens cleaned up', { removed, remaining: this.cache.size });
+ }
+
+ return removed;
+ }
+
+ /**
+ * Clear all cached tokens
+ * Use with caution - will force all users in profile selection to re-authenticate
+ */
+ clear(): void {
+ const count = this.cache.size;
+ this.cache.clear();
+ logger.info('Token cache cleared', { tokensRemoved: count });
+ }
+
+ /**
+ * Start the automatic cleanup interval
+ */
+ private startCleanupInterval(): void {
+ // Don't start multiple intervals
+ if (this.cleanupInterval) return;
+
+ this.cleanupInterval = setInterval(() => {
+ this.cleanup();
+ }, CLEANUP_INTERVAL_MS);
+
+ // Don't prevent Node.js from exiting
+ if (this.cleanupInterval.unref) {
+ this.cleanupInterval.unref();
+ }
+
+ logger.debug('Cleanup interval started', { intervalMs: CLEANUP_INTERVAL_MS });
+ }
+
+ /**
+ * Stop the cleanup interval (for testing or shutdown)
+ */
+ stopCleanupInterval(): void {
+ if (this.cleanupInterval) {
+ clearInterval(this.cleanupInterval);
+ this.cleanupInterval = null;
+ logger.debug('Cleanup interval stopped');
+ }
+ }
+}
+
+// Singleton instance
+let instance: AuthTokenCacheService | null = null;
+
+/**
+ * Get the singleton AuthTokenCacheService instance
+ */
+export function getAuthTokenCache(): AuthTokenCacheService {
+ if (!instance) {
+ instance = new AuthTokenCacheService();
+ logger.info('Auth token cache initialized');
+ }
+ return instance;
+}
+
+/**
+ * Reset the singleton instance (for testing only)
+ */
+export function resetAuthTokenCache(): void {
+ if (instance) {
+ instance.stopCleanupInterval();
+ instance.clear();
+ instance = null;
+ }
+}
+
+export { AuthTokenCacheService };
diff --git a/src/lib/services/credential-migration.service.ts b/src/lib/services/credential-migration.service.ts
new file mode 100644
index 0000000..2817a55
--- /dev/null
+++ b/src/lib/services/credential-migration.service.ts
@@ -0,0 +1,169 @@
+/**
+ * Component: Credential Migration Service
+ * Documentation: documentation/backend/services/config.md
+ *
+ * One-time migration to encrypt plaintext credentials stored in the database.
+ * Runs on startup and auto-detects plaintext vs encrypted values.
+ */
+
+import { prisma } from '@/lib/db';
+import { getEncryptionService } from './encryption.service';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('CredentialMigration');
+
+/**
+ * Check if a value looks like it's already encrypted.
+ * Encrypted values have format: base64:base64:base64 (iv:authTag:ciphertext)
+ */
+export function isEncryptedFormat(value: string): boolean {
+ if (!value || typeof value !== 'string') {
+ return false;
+ }
+
+ const parts = value.split(':');
+ if (parts.length !== 3) {
+ return false;
+ }
+
+ // Check if all parts look like base64
+ const base64Regex = /^[A-Za-z0-9+/]+=*$/;
+ return parts.every(part => part.length > 0 && base64Regex.test(part));
+}
+
+/**
+ * Migrate a single configuration key from plaintext to encrypted.
+ * Returns true if migration was performed, false if already encrypted or not found.
+ */
+async function migrateConfigKey(key: string): Promise {
+ const config = await prisma.configuration.findUnique({
+ where: { key },
+ });
+
+ if (!config || !config.value) {
+ return false;
+ }
+
+ // Skip if already marked as encrypted
+ if (config.encrypted) {
+ logger.debug(`Key "${key}" already marked as encrypted, skipping`);
+ return false;
+ }
+
+ // Skip if value looks like it's already encrypted (format check)
+ if (isEncryptedFormat(config.value)) {
+ logger.debug(`Key "${key}" appears to be in encrypted format, updating flag only`);
+ await prisma.configuration.update({
+ where: { key },
+ data: { encrypted: true },
+ });
+ return false;
+ }
+
+ // Encrypt the plaintext value
+ const encryptionService = getEncryptionService();
+ const encryptedValue = encryptionService.encrypt(config.value);
+
+ await prisma.configuration.update({
+ where: { key },
+ data: {
+ value: encryptedValue,
+ encrypted: true,
+ },
+ });
+
+ logger.info(`Migrated credential: ${key}`);
+ return true;
+}
+
+/**
+ * Migrate download_clients JSON to encrypt passwords within.
+ * Returns true if any passwords were encrypted.
+ */
+async function migrateDownloadClients(): Promise {
+ const config = await prisma.configuration.findUnique({
+ where: { key: 'download_clients' },
+ });
+
+ if (!config || !config.value) {
+ return false;
+ }
+
+ let clients: any[];
+ try {
+ clients = JSON.parse(config.value);
+ } catch (error) {
+ logger.error('Failed to parse download_clients JSON', { error });
+ return false;
+ }
+
+ if (!Array.isArray(clients) || clients.length === 0) {
+ return false;
+ }
+
+ const encryptionService = getEncryptionService();
+ let migratedCount = 0;
+
+ for (const client of clients) {
+ // Encrypt password if present and not already encrypted
+ if (client.password && typeof client.password === 'string' && !isEncryptedFormat(client.password)) {
+ client.password = encryptionService.encrypt(client.password);
+ migratedCount++;
+ }
+ }
+
+ if (migratedCount > 0) {
+ await prisma.configuration.update({
+ where: { key: 'download_clients' },
+ data: { value: JSON.stringify(clients) },
+ });
+
+ logger.info(`Migrated ${migratedCount} download client password(s)`);
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Run the credential migration.
+ * Safe to call multiple times - detects and skips already-encrypted values.
+ */
+export async function runCredentialMigration(): Promise {
+ logger.info('Starting credential migration check...');
+
+ let totalMigrated = 0;
+
+ // Migrate simple config keys
+ const keysToMigrate = [
+ 'plex_token',
+ 'prowlarr_api_key',
+ ];
+
+ for (const key of keysToMigrate) {
+ try {
+ const migrated = await migrateConfigKey(key);
+ if (migrated) {
+ totalMigrated++;
+ }
+ } catch (error) {
+ logger.error(`Failed to migrate ${key}`, { error: error instanceof Error ? error.message : String(error) });
+ }
+ }
+
+ // Migrate download client passwords
+ try {
+ const migratedClients = await migrateDownloadClients();
+ if (migratedClients) {
+ totalMigrated++;
+ }
+ } catch (error) {
+ logger.error('Failed to migrate download client passwords', { error: error instanceof Error ? error.message : String(error) });
+ }
+
+ if (totalMigrated > 0) {
+ logger.info(`Credential migration complete: ${totalMigrated} item(s) encrypted`);
+ } else {
+ logger.info('Credential migration complete: no changes needed');
+ }
+}
diff --git a/src/lib/services/download-client-manager.service.ts b/src/lib/services/download-client-manager.service.ts
index 361dd64..15a59af 100644
--- a/src/lib/services/download-client-manager.service.ts
+++ b/src/lib/services/download-client-manager.service.ts
@@ -8,6 +8,8 @@
import { randomUUID } from 'crypto';
import { ConfigurationService } from './config.service';
+import { getEncryptionService } from './encryption.service';
+import { isEncryptedFormat } from './credential-migration.service';
import { RMABLogger } from '@/lib/utils/logger';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
@@ -86,8 +88,26 @@ export class DownloadClientManager {
if (configValue) {
try {
const clients = JSON.parse(configValue) as DownloadClientConfig[];
- this.clientsCache = clients;
- return clients;
+
+ // Decrypt passwords if they're in encrypted format
+ const encryptionService = getEncryptionService();
+ const decryptedClients = clients.map(client => {
+ if (client.password && isEncryptedFormat(client.password)) {
+ try {
+ return {
+ ...client,
+ password: encryptionService.decrypt(client.password),
+ };
+ } catch (error) {
+ logger.error(`Failed to decrypt password for client ${client.name}`, { error });
+ return client;
+ }
+ }
+ return client;
+ });
+
+ this.clientsCache = decryptedClients;
+ return decryptedClients;
} catch (error) {
logger.error('Failed to parse download_clients config', { error });
return [];
diff --git a/src/lib/utils/chapter-merger.ts b/src/lib/utils/chapter-merger.ts
index 68febfe..78c8456 100644
--- a/src/lib/utils/chapter-merger.ts
+++ b/src/lib/utils/chapter-merger.ts
@@ -676,9 +676,14 @@ export async function mergeChapters(
args.push('-avoid_negative_ts', 'make_zero'); // Handle negative timestamps
args.push('-max_muxing_queue_size', '9999'); // Prevent buffer overflow on long files
- // Add book metadata
+ // Add book metadata (escape for double-quoted shell context)
+ // Single quotes do NOT need escaping inside double quotes - they are literal
const escapeMetadata = (val: string): string =>
- val.replace(/"/g, '\\"').replace(/'/g, "\\'");
+ val
+ .replace(/\\/g, '\\\\') // Backslashes first
+ .replace(/"/g, '\\"') // Double quotes
+ .replace(/`/g, '\\`') // Backticks
+ .replace(/\$/g, '\\$'); // Dollar signs
args.push('-metadata', `title="${escapeMetadata(options.title)}"`);
args.push('-metadata', `album="${escapeMetadata(options.title)}"`);
diff --git a/src/lib/utils/cleanup-helpers.ts b/src/lib/utils/cleanup-helpers.ts
new file mode 100644
index 0000000..e5f378f
--- /dev/null
+++ b/src/lib/utils/cleanup-helpers.ts
@@ -0,0 +1,275 @@
+/**
+ * Cleanup Helpers Utility
+ * Documentation: documentation/phase3/sabnzbd.md
+ *
+ * Provides utilities for cleaning up after file organization,
+ * including removal of empty parent directories.
+ */
+
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import { RMABLogger } from './logger';
+
+const logger = RMABLogger.create('CleanupHelpers');
+
+/**
+ * Options for removeEmptyParentDirectories
+ */
+export interface RemoveEmptyParentOptions {
+ /** The boundary path - will never delete this directory or its parents */
+ boundaryPath: string;
+ /** Optional logger context for job-aware logging */
+ logContext?: { jobId: string; context: string };
+}
+
+/**
+ * Removes empty parent directories after a file/directory has been deleted.
+ *
+ * This function walks up the directory tree from the deleted path, removing
+ * any empty directories until it encounters a non-empty directory or reaches
+ * the configured boundary path.
+ *
+ * Use case: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
+ * After deleting the download folder, the category folder (readmeabook) may
+ * be left empty. This function cleans up those empty parent folders.
+ *
+ * Safety features:
+ * - Will NEVER delete the boundary path itself (e.g., download_dir)
+ * - Will NEVER delete above the boundary path
+ * - Gracefully handles ENOENT (already deleted)
+ * - Gracefully handles permission errors (logs warning, continues)
+ * - Stops immediately when a non-empty directory is encountered
+ *
+ * @param deletedPath - The path that was just deleted (file or directory)
+ * @param options - Configuration options including boundary path
+ * @returns Object with details about what was cleaned up
+ *
+ * @example
+ * // After deleting /downloads/readmeabook/My.Audiobook.Name
+ * await removeEmptyParentDirectories(
+ * '/downloads/readmeabook/My.Audiobook.Name',
+ * { boundaryPath: '/downloads' }
+ * );
+ * // This will remove /downloads/readmeabook if it's empty
+ * // but will never touch /downloads
+ */
+export async function removeEmptyParentDirectories(
+ deletedPath: string,
+ options: RemoveEmptyParentOptions
+): Promise<{
+ success: boolean;
+ removedDirectories: string[];
+ stoppedAt?: string;
+ stoppedReason?: 'non_empty' | 'boundary_reached' | 'root_reached' | 'error';
+ error?: string;
+}> {
+ const log = options.logContext
+ ? RMABLogger.forJob(options.logContext.jobId, options.logContext.context)
+ : logger;
+
+ const removedDirectories: string[] = [];
+
+ try {
+ // Normalize paths for consistent comparison
+ const normalizedBoundary = normalizePath(options.boundaryPath);
+ let currentPath = normalizePath(path.dirname(deletedPath));
+
+ log.debug('Starting empty parent directory cleanup', {
+ deletedPath,
+ boundaryPath: options.boundaryPath,
+ normalizedBoundary,
+ startingFrom: currentPath,
+ });
+
+ // Walk up the directory tree
+ while (true) {
+ // Safety check: Have we reached the filesystem root?
+ const parentPath = normalizePath(path.dirname(currentPath));
+ if (parentPath === currentPath) {
+ log.debug('Reached filesystem root, stopping cleanup');
+ return {
+ success: true,
+ removedDirectories,
+ stoppedAt: currentPath,
+ stoppedReason: 'root_reached',
+ };
+ }
+
+ // Safety check: Have we reached or passed the boundary?
+ if (!isPathBelowBoundary(currentPath, normalizedBoundary)) {
+ log.debug('Reached boundary path, stopping cleanup', {
+ currentPath,
+ boundaryPath: normalizedBoundary,
+ });
+ return {
+ success: true,
+ removedDirectories,
+ stoppedAt: currentPath,
+ stoppedReason: 'boundary_reached',
+ };
+ }
+
+ // Check if the directory is empty
+ const isEmpty = await isDirectoryEmpty(currentPath);
+
+ if (isEmpty === null) {
+ // Directory doesn't exist (ENOENT) - move to parent
+ log.debug(`Directory does not exist, moving to parent: ${currentPath}`);
+ currentPath = parentPath;
+ continue;
+ }
+
+ if (!isEmpty) {
+ // Directory is not empty - stop here
+ log.debug(`Directory not empty, stopping cleanup: ${currentPath}`);
+ return {
+ success: true,
+ removedDirectories,
+ stoppedAt: currentPath,
+ stoppedReason: 'non_empty',
+ };
+ }
+
+ // Directory is empty - try to remove it
+ try {
+ await fs.rmdir(currentPath);
+ removedDirectories.push(currentPath);
+ log.info(`Removed empty directory: ${currentPath}`);
+ } catch (removeError) {
+ const errorCode = (removeError as NodeJS.ErrnoException).code;
+
+ if (errorCode === 'ENOENT') {
+ // Already deleted (race condition) - continue to parent
+ log.debug(`Directory already deleted: ${currentPath}`);
+ } else if (errorCode === 'ENOTEMPTY') {
+ // Directory became non-empty (race condition) - stop
+ log.debug(`Directory became non-empty: ${currentPath}`);
+ return {
+ success: true,
+ removedDirectories,
+ stoppedAt: currentPath,
+ stoppedReason: 'non_empty',
+ };
+ } else if (errorCode === 'EACCES' || errorCode === 'EPERM') {
+ // Permission error - log warning and stop
+ log.warn(`Permission denied removing directory: ${currentPath}`, {
+ error: removeError instanceof Error ? removeError.message : String(removeError),
+ });
+ return {
+ success: true, // Partial success - we cleaned what we could
+ removedDirectories,
+ stoppedAt: currentPath,
+ stoppedReason: 'error',
+ error: `Permission denied: ${currentPath}`,
+ };
+ } else {
+ // Unexpected error - log and stop
+ log.error(`Failed to remove directory: ${currentPath}`, {
+ error: removeError instanceof Error ? removeError.message : String(removeError),
+ errorCode,
+ });
+ return {
+ success: false,
+ removedDirectories,
+ stoppedAt: currentPath,
+ stoppedReason: 'error',
+ error: removeError instanceof Error ? removeError.message : String(removeError),
+ };
+ }
+ }
+
+ // Move to parent directory
+ currentPath = parentPath;
+ }
+ } catch (error) {
+ log.error('Unexpected error during empty parent cleanup', {
+ error: error instanceof Error ? error.message : String(error),
+ deletedPath,
+ boundaryPath: options.boundaryPath,
+ });
+ return {
+ success: false,
+ removedDirectories,
+ stoppedReason: 'error',
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
+
+/**
+ * Checks if a directory is empty
+ *
+ * @param dirPath - Path to the directory
+ * @returns true if empty, false if not empty, null if directory doesn't exist
+ */
+async function isDirectoryEmpty(dirPath: string): Promise {
+ try {
+ const entries = await fs.readdir(dirPath);
+ return entries.length === 0;
+ } catch (error) {
+ const errorCode = (error as NodeJS.ErrnoException).code;
+
+ if (errorCode === 'ENOENT') {
+ // Directory doesn't exist
+ return null;
+ }
+
+ if (errorCode === 'ENOTDIR') {
+ // Path is a file, not a directory
+ return null;
+ }
+
+ // Re-throw other errors
+ throw error;
+ }
+}
+
+/**
+ * Checks if a path is strictly below (inside) the boundary path
+ *
+ * A path is below the boundary if:
+ * - It's longer than the boundary path
+ * - It starts with the boundary path followed by a path separator
+ *
+ * @param testPath - The path to test (must be normalized)
+ * @param boundaryPath - The boundary path (must be normalized)
+ * @returns true if testPath is strictly below boundaryPath
+ */
+function isPathBelowBoundary(testPath: string, boundaryPath: string): boolean {
+ // Ensure both paths don't have trailing slashes for comparison
+ const normalizedTest = testPath.replace(/\/+$/, '');
+ const normalizedBoundary = boundaryPath.replace(/\/+$/, '');
+
+ // Path must be strictly below boundary, not equal to it
+ if (normalizedTest === normalizedBoundary) {
+ return false;
+ }
+
+ // Check if test path is under boundary path
+ // Must start with boundary + separator to avoid matching /downloads2 when boundary is /downloads
+ return normalizedTest.startsWith(normalizedBoundary + '/');
+}
+
+/**
+ * Normalizes a file path for consistent comparison
+ *
+ * @param filePath - Path to normalize
+ * @returns Normalized path with forward slashes and no trailing slash
+ */
+function normalizePath(filePath: string): string {
+ // Convert backslashes to forward slashes
+ let normalized = filePath.replace(/\\/g, '/');
+
+ // Use path.normalize to handle redundant separators and ..
+ normalized = path.normalize(normalized);
+
+ // Convert backslashes again (path.normalize might add them on Windows)
+ normalized = normalized.replace(/\\/g, '/');
+
+ // Remove trailing slash (except for root '/')
+ if (normalized.length > 1 && normalized.endsWith('/')) {
+ normalized = normalized.slice(0, -1);
+ }
+
+ return normalized;
+}
diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts
index 685d6a5..6ec73e6 100644
--- a/src/lib/utils/metadata-tagger.ts
+++ b/src/lib/utils/metadata-tagger.ts
@@ -165,16 +165,22 @@ export async function tagMultipleFiles(
}
/**
- * Escape metadata values for shell command
- * Removes quotes and special characters that could break the command
+ * Escape metadata values for shell command (double-quoted context)
+ *
+ * In double-quoted shell strings, only these characters need escaping:
+ * - Backslashes (must be first to avoid double-escaping)
+ * - Double quotes
+ * - Backticks (command substitution)
+ * - Dollar signs (variable expansion)
+ *
+ * Single quotes do NOT need escaping inside double quotes - they are literal.
*/
function escapeMetadata(value: string): string {
return value
+ .replace(/\\/g, '\\\\') // Escape backslashes FIRST (before other escapes add backslashes)
.replace(/"/g, '\\"') // Escape double quotes
- .replace(/'/g, "\\'") // Escape single quotes
- .replace(/`/g, '\\`') // Escape backticks
- .replace(/\$/g, '\\$') // Escape dollar signs
- .replace(/\\/g, '\\\\'); // Escape backslashes
+ .replace(/`/g, '\\`') // Escape backticks (prevents command substitution)
+ .replace(/\$/g, '\\$'); // Escape dollar signs (prevents variable expansion)
}
/**
diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts
index df74c54..7b7df9f 100644
--- a/tests/api/admin-settings-core.routes.test.ts
+++ b/tests/api/admin-settings-core.routes.test.ts
@@ -77,6 +77,13 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
invalidateDownloadClientManager: invalidateDownloadClientManagerMock,
}));
+vi.mock('@/lib/services/encryption.service', () => ({
+ getEncryptionService: () => ({
+ encrypt: (value: string) => `enc-${value}`,
+ decrypt: (value: string) => value.replace('enc-', ''),
+ }),
+}));
+
describe('Admin settings core routes', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/tests/api/admin-settings-tests.routes.test.ts b/tests/api/admin-settings-tests.routes.test.ts
index 20f4547..08c58f2 100644
--- a/tests/api/admin-settings-tests.routes.test.ts
+++ b/tests/api/admin-settings-tests.routes.test.ts
@@ -30,6 +30,12 @@ const fsMock = vi.hoisted(() => ({
access: vi.fn(),
constants: { R_OK: 4 },
}));
+const configServiceMock = vi.hoisted(() => ({
+ get: vi.fn(),
+}));
+const downloadClientManagerMock = vi.hoisted(() => ({
+ getAllClients: vi.fn(),
+}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
@@ -73,6 +79,14 @@ vi.mock('fs/promises', () => ({
...fsMock,
}));
+vi.mock('@/lib/services/config.service', () => ({
+ getConfigService: () => configServiceMock,
+}));
+
+vi.mock('@/lib/services/download-client-manager.service', () => ({
+ getDownloadClientManager: () => downloadClientManagerMock,
+}));
+
describe('Admin settings test routes', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -211,7 +225,10 @@ describe('Admin settings test routes', () => {
});
it('uses stored password when masked password is provided', async () => {
- prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'stored-pass' });
+ // Mock download client manager to return the stored password
+ downloadClientManagerMock.getAllClients.mockResolvedValueOnce([
+ { type: 'qbittorrent', password: 'stored-pass' },
+ ]);
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0');
const request = {
json: vi.fn().mockResolvedValue({
@@ -236,7 +253,8 @@ describe('Admin settings test routes', () => {
});
it('returns error when masked password is missing in storage', async () => {
- prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
+ // Mock download client manager to return no matching client
+ downloadClientManagerMock.getAllClients.mockResolvedValueOnce([]);
const request = {
json: vi.fn().mockResolvedValue({
type: 'qbittorrent',
diff --git a/tests/api/auth-plex.routes.test.ts b/tests/api/auth-plex.routes.test.ts
index d440d39..7e47087 100644
--- a/tests/api/auth-plex.routes.test.ts
+++ b/tests/api/auth-plex.routes.test.ts
@@ -22,6 +22,11 @@ const encryptionServiceMock = vi.hoisted(() => ({
const configServiceMock = vi.hoisted(() => ({
getPlexConfig: vi.fn(),
}));
+const authTokenCacheMock = vi.hoisted(() => ({
+ set: vi.fn(),
+ get: vi.fn(),
+ delete: vi.fn(),
+}));
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
@@ -41,6 +46,10 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
+vi.mock('@/lib/services/auth-token-cache.service', () => ({
+ getAuthTokenCache: () => authTokenCacheMock,
+}));
+
vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: generateAccessTokenMock,
generateRefreshToken: generateRefreshTokenMock,
@@ -194,8 +203,10 @@ describe('Plex auth routes', () => {
const html = await response.text();
expect(response.headers.get('content-type')).toContain('text/html');
- expect(html).toContain('sessionStorage.setItem');
+ // Token is now stored server-side, not in sessionStorage
+ expect(html).toContain('token is stored server-side');
expect(html).toContain('https://example.com/auth/select-profile?pinId=3');
+ expect(authTokenCacheMock.set).toHaveBeenCalledWith('3', 'token');
});
it('returns tokens for successful Plex auth', async () => {
@@ -267,18 +278,20 @@ describe('Plex auth routes', () => {
expect(html).toContain('#authData=');
});
- it('returns Plex home users when token is provided', async () => {
+ it('returns Plex home users when pinId is provided', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]);
const { GET } = await import('@/app/api/auth/plex/home-users/route');
- const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
+ const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.users).toHaveLength(1);
+ expect(authTokenCacheMock.get).toHaveBeenCalledWith('123');
});
- it('rejects Plex home users when token is missing', async () => {
+ it('rejects Plex home users when pinId is missing', async () => {
const { GET } = await import('@/app/api/auth/plex/home-users/route');
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users') as any);
const payload = await response.json();
@@ -287,18 +300,30 @@ describe('Plex auth routes', () => {
expect(payload.error).toBe('Unauthorized');
});
+ it('returns 401 when token not found in cache', async () => {
+ authTokenCacheMock.get.mockReturnValue(null);
+
+ const { GET } = await import('@/app/api/auth/plex/home-users/route');
+ const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
+ const payload = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(payload.error).toBe('SessionExpired');
+ });
+
it('returns 500 when Plex home users fetch fails', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
plexServiceMock.getHomeUsers.mockRejectedValue(new Error('boom'));
const { GET } = await import('@/app/api/auth/plex/home-users/route');
- const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
+ const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('ServerError');
});
- it('rejects profile switch without main account token', async () => {
+ it('rejects profile switch without pinId', async () => {
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
request.json.mockResolvedValue({ userId: 'home-1' });
@@ -310,10 +335,26 @@ describe('Plex auth routes', () => {
expect(payload.error).toBe('Unauthorized');
});
- it('rejects profile switch when userId is missing', async () => {
+ it('rejects profile switch when token not found in cache', async () => {
+ authTokenCacheMock.get.mockReturnValue(null);
+
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
- const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
- request.json.mockResolvedValue({});
+ const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
+ request.json.mockResolvedValue({ userId: 'home-1', pinId: '123' });
+
+ const response = await POST(request as any);
+ const payload = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(payload.error).toBe('SessionExpired');
+ });
+
+ it('rejects profile switch when userId is missing', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
+
+ const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
+ const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
+ request.json.mockResolvedValue({ pinId: '123' });
const response = await POST(request as any);
const payload = await response.json();
@@ -323,11 +364,12 @@ describe('Plex auth routes', () => {
});
it('returns 401 for invalid profile PIN', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
plexServiceMock.switchHomeUser.mockRejectedValue(new Error('Invalid PIN'));
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
- const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
- request.json.mockResolvedValue({ userId: 'home-1', pin: '0000' });
+ const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
+ request.json.mockResolvedValue({ userId: 'home-1', pin: '0000', pinId: '123' });
const response = await POST(request as any);
const payload = await response.json();
@@ -337,6 +379,7 @@ describe('Plex auth routes', () => {
});
it('switches Plex profile using provided profile info', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
prismaMock.user.count.mockResolvedValue(1);
prismaMock.user.upsert.mockResolvedValue({
@@ -349,10 +392,11 @@ describe('Plex auth routes', () => {
});
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
- const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
+ const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
request.json.mockResolvedValue({
userId: 'home-1',
pin: '1234',
+ pinId: '123',
profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' },
});
@@ -361,9 +405,11 @@ describe('Plex auth routes', () => {
expect(payload.success).toBe(true);
expect(payload.accessToken).toBe('access-token');
+ expect(authTokenCacheMock.delete).toHaveBeenCalledWith('123');
});
it('switches Plex profile using getUserInfo fallback', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
plexServiceMock.getUserInfo.mockResolvedValue({
id: 'plex-3',
@@ -382,8 +428,8 @@ describe('Plex auth routes', () => {
});
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
- const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
- request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' });
+ const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
+ request.json.mockResolvedValue({ userId: 'home-2', pin: '1234', pinId: '123' });
const response = await POST(request as any);
const payload = await response.json();
@@ -394,12 +440,13 @@ describe('Plex auth routes', () => {
});
it('returns 500 when profile info lookup fails', async () => {
+ authTokenCacheMock.get.mockReturnValue('cached-token');
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
plexServiceMock.getUserInfo.mockResolvedValue({ id: null });
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
- const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
- request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' });
+ const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
+ request.json.mockResolvedValue({ userId: 'home-2', pin: '1234', pinId: '123' });
const response = await POST(request as any);
const payload = await response.json();
diff --git a/tests/app/select-profile.page.test.tsx b/tests/app/select-profile.page.test.tsx
index 85c242c..c0031f1 100644
--- a/tests/app/select-profile.page.test.tsx
+++ b/tests/app/select-profile.page.test.tsx
@@ -45,7 +45,7 @@ describe('SelectProfilePage', () => {
});
it('selects an unprotected profile and stores auth data', async () => {
- sessionStorage.setItem('plex_main_token', 'main-token');
+ // Token is now stored server-side, only pinId needed in URL
setMockSearchParams('pinId=123');
const setAuthDataMock = vi.fn();
@@ -71,6 +71,9 @@ describe('SelectProfilePage', () => {
const fetchMock = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.url;
if (url === '/api/auth/plex/home-users') {
+ // Verify pinId header is sent instead of token
+ const headers = (init as RequestInit)?.headers as Record;
+ expect(headers?.['X-Plex-Pin-Id']).toBe('123');
return makeJsonResponse({ users: profiles });
}
if (url === '/api/auth/plex/switch-profile') {
@@ -107,7 +110,7 @@ describe('SelectProfilePage', () => {
});
it('prompts for a PIN and handles invalid submissions', async () => {
- sessionStorage.setItem('plex_main_token', 'main-token');
+ // Token is now stored server-side, only pinId needed in URL
setMockSearchParams('pinId=555');
const setAuthDataMock = vi.fn();
@@ -136,7 +139,8 @@ describe('SelectProfilePage', () => {
return makeJsonResponse({ users: profiles });
}
if (url === '/api/auth/plex/switch-profile') {
- return makeJsonResponse({ message: 'Invalid PIN' }, false, 401);
+ // Return InvalidPIN error type to trigger PIN error message
+ return makeJsonResponse({ error: 'InvalidPIN', message: 'Invalid PIN' }, false, 401);
}
throw new Error(`Unexpected fetch: ${url}`);
});
diff --git a/tests/services/download-client-manager.service.test.ts b/tests/services/download-client-manager.service.test.ts
index 85aaed0..cab342f 100644
--- a/tests/services/download-client-manager.service.test.ts
+++ b/tests/services/download-client-manager.service.test.ts
@@ -21,6 +21,19 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
+// Mock credential migration service - passwords in tests are plaintext
+vi.mock('@/lib/services/credential-migration.service', () => ({
+ isEncryptedFormat: () => false, // Test passwords are plaintext
+}));
+
+// Mock encryption service
+vi.mock('@/lib/services/encryption.service', () => ({
+ getEncryptionService: () => ({
+ encrypt: (value: string) => `enc-${value}`,
+ decrypt: (value: string) => value.replace('enc-', ''),
+ }),
+}));
+
// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time
const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({
qbtServiceMock: {
diff --git a/tests/utils/chapter-merger.test.ts b/tests/utils/chapter-merger.test.ts
index ae51b85..2d841f8 100644
--- a/tests/utils/chapter-merger.test.ts
+++ b/tests/utils/chapter-merger.test.ts
@@ -620,4 +620,120 @@ describe('chapter merger', () => {
expect(result.success).toBe(false);
expect(result.error).toMatch(/Merged file not created/i);
});
+
+ describe('metadata escaping', () => {
+ it('does NOT escape single quotes in metadata (they are literal in double-quoted shell strings)', async () => {
+ const outputPath = '/tmp/output.m4b';
+ const chapters = [
+ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
+ ];
+
+ fsMock.access.mockResolvedValue(undefined);
+ fsMock.stat.mockImplementation(async (filePath: string) => {
+ if (filePath === outputPath) {
+ return { size: 2 * 1024 * 1024 };
+ }
+ return { size: 500 * 1024 };
+ });
+ fsMock.mkdir.mockResolvedValue(undefined);
+ fsMock.writeFile.mockResolvedValue(undefined);
+ fsMock.unlink.mockResolvedValue(undefined);
+
+ mockExecImplementation((command) => {
+ if (command.startsWith('ffprobe')) {
+ return {
+ stdout: JSON.stringify({
+ format: { duration: '60', bit_rate: '128000', tags: {} },
+ }),
+ };
+ }
+ if (command.startsWith('ffmpeg -v error')) {
+ return { stdout: '' };
+ }
+ return { stdout: '' };
+ });
+
+ spawnMock.mockReturnValue(createSpawnProcess(0));
+
+ await mergeChapters(chapters, {
+ title: "It's Not Her",
+ author: "O'Brien",
+ narrator: "Jane's Voice",
+ outputPath,
+ });
+
+ // Get the args passed to spawn
+ const spawnCall = spawnMock.mock.calls[0];
+ const args = spawnCall[1] as string[];
+
+ // Find the title metadata arg (format after parsing: title="It's Not Her)
+ const titleArg = args.find((arg: string) => arg.startsWith('title='));
+ const albumArtistArg = args.find((arg: string) => arg.startsWith('album_artist='));
+ const composerArg = args.find((arg: string) => arg.startsWith('composer='));
+
+ // Single quotes should appear as-is ('s), NOT escaped with backslash (\'s)
+ // The args contain the value with opening quote: title="It's Not Her
+ expect(titleArg).toContain("It's Not Her");
+ expect(titleArg).not.toContain("\\'"); // No escaped single quotes
+ expect(albumArtistArg).toContain("O'Brien");
+ expect(albumArtistArg).not.toContain("\\'");
+ expect(composerArg).toContain("Jane's Voice");
+ expect(composerArg).not.toContain("\\'");
+
+ // Verify no backslash-escaped single quotes anywhere in args
+ const allArgsJoined = args.join(' ');
+ expect(allArgsJoined).not.toContain("\\'");
+ });
+
+ it('properly escapes double quotes and special shell characters', async () => {
+ const outputPath = '/tmp/output.m4b';
+ const chapters = [
+ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
+ ];
+
+ fsMock.access.mockResolvedValue(undefined);
+ fsMock.stat.mockImplementation(async (filePath: string) => {
+ if (filePath === outputPath) {
+ return { size: 2 * 1024 * 1024 };
+ }
+ return { size: 500 * 1024 };
+ });
+ fsMock.mkdir.mockResolvedValue(undefined);
+ fsMock.writeFile.mockResolvedValue(undefined);
+ fsMock.unlink.mockResolvedValue(undefined);
+
+ mockExecImplementation((command) => {
+ if (command.startsWith('ffprobe')) {
+ return {
+ stdout: JSON.stringify({
+ format: { duration: '60', bit_rate: '128000', tags: {} },
+ }),
+ };
+ }
+ if (command.startsWith('ffmpeg -v error')) {
+ return { stdout: '' };
+ }
+ return { stdout: '' };
+ });
+
+ spawnMock.mockReturnValue(createSpawnProcess(0));
+
+ await mergeChapters(chapters, {
+ title: 'Book "Quoted" $100',
+ author: 'Author',
+ outputPath,
+ });
+
+ // Get the args passed to spawn
+ const spawnCall = spawnMock.mock.calls[0];
+ const args = spawnCall[1] as string[];
+
+ // Find the title arg - double quotes and $ should be escaped
+ const titleArg = args.find((arg: string) => arg.startsWith('title='));
+
+ // Verify escaping is present for double quotes and dollar signs
+ expect(titleArg).toContain('\\"Quoted\\"');
+ expect(titleArg).toContain('\\$100');
+ });
+ });
});
diff --git a/tests/utils/cleanup-helpers.test.ts b/tests/utils/cleanup-helpers.test.ts
new file mode 100644
index 0000000..3f27f94
--- /dev/null
+++ b/tests/utils/cleanup-helpers.test.ts
@@ -0,0 +1,414 @@
+/**
+ * Component: Cleanup Helpers Tests
+ * Documentation: documentation/phase3/sabnzbd.md
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { removeEmptyParentDirectories } from '@/lib/utils/cleanup-helpers';
+
+// Mock fs/promises
+const fsMock = vi.hoisted(() => ({
+ readdir: vi.fn(),
+ rmdir: vi.fn(),
+}));
+
+// Mock logger
+const loggerMock = vi.hoisted(() => ({
+ RMABLogger: {
+ create: vi.fn(() => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ })),
+ forJob: vi.fn(() => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock('fs/promises', () => ({
+ default: fsMock,
+ ...fsMock,
+}));
+
+vi.mock('@/lib/utils/logger', () => loggerMock);
+
+describe('removeEmptyParentDirectories', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('basic functionality', () => {
+ it('removes a single empty parent directory', async () => {
+ // Setup: /downloads/category/audiobook was deleted
+ // /downloads/category is empty
+ fsMock.readdir.mockResolvedValueOnce([]); // /downloads/category is empty
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toEqual(['/downloads/category']);
+ expect(result.stoppedReason).toBe('boundary_reached');
+ expect(fsMock.rmdir).toHaveBeenCalledWith('/downloads/category');
+ });
+
+ it('removes multiple nested empty directories', async () => {
+ // Setup: /downloads/cat/subcat/audiobook was deleted
+ // Both /downloads/cat/subcat and /downloads/cat are empty
+ fsMock.readdir
+ .mockResolvedValueOnce([]) // /downloads/cat/subcat is empty
+ .mockResolvedValueOnce([]); // /downloads/cat is empty
+ fsMock.rmdir.mockResolvedValue(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/cat/subcat/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toHaveLength(2);
+ expect(result.removedDirectories).toContain('/downloads/cat/subcat');
+ expect(result.removedDirectories).toContain('/downloads/cat');
+ expect(result.stoppedReason).toBe('boundary_reached');
+ });
+
+ it('stops when encountering a non-empty directory', async () => {
+ // Setup: /downloads/category/audiobook was deleted
+ // /downloads/category has other files
+ fsMock.readdir.mockResolvedValueOnce(['other-file.txt']);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toHaveLength(0);
+ expect(result.stoppedReason).toBe('non_empty');
+ expect(result.stoppedAt).toBe('/downloads/category');
+ expect(fsMock.rmdir).not.toHaveBeenCalled();
+ });
+
+ it('removes first empty dir but stops at non-empty parent', async () => {
+ // Setup: /downloads/cat/subcat/audiobook was deleted
+ // /downloads/cat/subcat is empty, /downloads/cat has other stuff
+ fsMock.readdir
+ .mockResolvedValueOnce([]) // /downloads/cat/subcat is empty
+ .mockResolvedValueOnce(['other-subcat']); // /downloads/cat has other subcat
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/cat/subcat/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toEqual(['/downloads/cat/subcat']);
+ expect(result.stoppedReason).toBe('non_empty');
+ expect(result.stoppedAt).toBe('/downloads/cat');
+ });
+ });
+
+ describe('boundary protection', () => {
+ it('never deletes the boundary directory itself', async () => {
+ // Setup: /downloads/audiobook was deleted (directly under boundary)
+ // /downloads is empty
+ fsMock.readdir.mockResolvedValueOnce([]);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toHaveLength(0);
+ expect(result.stoppedReason).toBe('boundary_reached');
+ // Should NOT try to remove /downloads
+ expect(fsMock.rmdir).not.toHaveBeenCalled();
+ });
+
+ it('never deletes above the boundary directory', async () => {
+ // Setup: Deep nested structure with empty parents all the way up
+ fsMock.readdir.mockResolvedValue([]); // All directories empty
+ fsMock.rmdir.mockResolvedValue(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/a/b/c/d/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ // Should remove a/b/c/d, a/b/c, a/b, a - but NOT /downloads
+ expect(result.removedDirectories).toHaveLength(4);
+ expect(result.removedDirectories).not.toContain('/downloads');
+ expect(result.stoppedReason).toBe('boundary_reached');
+ });
+
+ it('handles boundary with trailing slash', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads/' } // Trailing slash
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toEqual(['/downloads/category']);
+ });
+
+ it('handles path directly at boundary level', async () => {
+ const result = await removeEmptyParentDirectories(
+ '/downloads/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ // Parent of /downloads/audiobook is /downloads which is the boundary
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toHaveLength(0);
+ expect(result.stoppedReason).toBe('boundary_reached');
+ });
+ });
+
+ describe('error handling', () => {
+ it('handles ENOENT gracefully (directory already deleted)', async () => {
+ // First directory check succeeds (empty), rmdir fails with ENOENT
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockRejectedValueOnce(
+ Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ );
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ // Should continue without error
+ });
+
+ it('handles ENOENT when checking if directory exists', async () => {
+ // Directory doesn't exist when we try to read it
+ fsMock.readdir.mockRejectedValueOnce(
+ Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ );
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ // Should handle gracefully, move to parent
+ });
+
+ it('handles ENOTEMPTY race condition gracefully', async () => {
+ // Directory was empty when checked, but became non-empty before removal
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockRejectedValueOnce(
+ Object.assign(new Error('ENOTEMPTY'), { code: 'ENOTEMPTY' })
+ );
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.stoppedReason).toBe('non_empty');
+ });
+
+ it('handles EACCES permission error gracefully', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockRejectedValueOnce(
+ Object.assign(new Error('Permission denied'), { code: 'EACCES' })
+ );
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ // Should still be considered partial success
+ expect(result.success).toBe(true);
+ expect(result.stoppedReason).toBe('error');
+ expect(result.error).toContain('Permission denied');
+ });
+
+ it('handles EPERM permission error gracefully', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockRejectedValueOnce(
+ Object.assign(new Error('Operation not permitted'), { code: 'EPERM' })
+ );
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.stoppedReason).toBe('error');
+ });
+
+ it('handles unexpected errors', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockRejectedValueOnce(
+ Object.assign(new Error('Unknown error'), { code: 'EUNKNOWN' })
+ );
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.stoppedReason).toBe('error');
+ expect(result.error).toBe('Unknown error');
+ });
+ });
+
+ describe('path edge cases', () => {
+ it('handles Windows-style backslash paths', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ 'C:\\downloads\\category\\audiobook',
+ { boundaryPath: 'C:\\downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ // Should normalize paths and work correctly
+ });
+
+ it('handles mixed slash paths', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category\\audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ });
+
+ it('handles paths with redundant slashes', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads//category///audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ });
+
+ it('prevents /downloads2 from matching /downloads boundary', async () => {
+ // If boundary is /downloads, path /downloads2/cat/audio should NOT match
+ fsMock.readdir.mockResolvedValue([]); // All empty
+ fsMock.rmdir.mockResolvedValue(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads2/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ // Should reach root (no boundary match) or handle gracefully
+ // The boundary check should NOT match /downloads2 when boundary is /downloads
+ expect(result.success).toBe(true);
+ // Should have removed /downloads2/category and /downloads2 (or hit root)
+ });
+ });
+
+ describe('with job context logging', () => {
+ it('uses job-aware logger when context provided', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ {
+ boundaryPath: '/downloads',
+ logContext: { jobId: 'job-123', context: 'TestCleanup' },
+ }
+ );
+
+ expect(result.success).toBe(true);
+ expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-123', 'TestCleanup');
+ });
+
+ it('uses default logger when no context provided', async () => {
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/category/audiobook',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ // Default logger is created at module load time, not per-call
+ // Just verify the function works without job context
+ expect(loggerMock.RMABLogger.forJob).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('realistic SABnzbd scenarios', () => {
+ it('cleans up empty readmeabook category folder', async () => {
+ // Real scenario: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
+ // After organizing, My.Audiobook.Name is deleted
+ // readmeabook folder should be cleaned up too
+ fsMock.readdir.mockResolvedValueOnce([]); // /downloads/readmeabook is empty
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/readmeabook/My.Audiobook.Name',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toEqual(['/downloads/readmeabook']);
+ expect(result.stoppedReason).toBe('boundary_reached');
+ });
+
+ it('preserves category folder with other downloads', async () => {
+ // Real scenario: Multiple downloads in readmeabook category
+ // Only one is being cleaned up
+ fsMock.readdir.mockResolvedValueOnce(['Other.Audiobook.Name']); // Other download exists
+
+ const result = await removeEmptyParentDirectories(
+ '/downloads/readmeabook/My.Audiobook.Name',
+ { boundaryPath: '/downloads' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toHaveLength(0);
+ expect(result.stoppedReason).toBe('non_empty');
+ });
+
+ it('handles path mapping scenario (mapped download_dir)', async () => {
+ // Real scenario: download_dir is /media/usenet/complete
+ // after path mapping from SABnzbd's perspective
+ fsMock.readdir.mockResolvedValueOnce([]);
+ fsMock.rmdir.mockResolvedValueOnce(undefined);
+
+ const result = await removeEmptyParentDirectories(
+ '/media/usenet/complete/readmeabook/My.Audiobook.Name',
+ { boundaryPath: '/media/usenet/complete' }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.removedDirectories).toEqual(['/media/usenet/complete/readmeabook']);
+ });
+ });
+});
diff --git a/tests/utils/metadata-tagger.test.ts b/tests/utils/metadata-tagger.test.ts
index de5c5c5..92ebc84 100644
--- a/tests/utils/metadata-tagger.test.ts
+++ b/tests/utils/metadata-tagger.test.ts
@@ -113,4 +113,89 @@ describe('metadata tagger', () => {
mockExecFailure('not installed');
await expect(checkFfmpegAvailable()).resolves.toBe(false);
});
+
+ describe('metadata escaping', () => {
+ it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
+ fsMock.access.mockResolvedValue(undefined);
+ mockExecSuccess('done');
+
+ await tagAudioFileMetadata('/tmp/book.m4b', {
+ title: "It's Not Her",
+ author: "Author's Name",
+ });
+
+ const command = execMock.mock.calls[0][0] as string;
+ // Single quotes should appear as-is, NOT escaped with backslash
+ expect(command).toContain('-metadata title="It\'s Not Her"');
+ expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes
+ expect(command).toContain('-metadata album_artist="Author\'s Name"');
+ });
+
+ it('escapes double quotes in metadata values', async () => {
+ fsMock.access.mockResolvedValue(undefined);
+ mockExecSuccess('done');
+
+ await tagAudioFileMetadata('/tmp/book.m4b', {
+ title: 'Book "Title"',
+ author: 'Author',
+ });
+
+ const command = execMock.mock.calls[0][0] as string;
+ expect(command).toContain('-metadata title="Book \\"Title\\""');
+ });
+
+ it('escapes backticks to prevent command substitution', async () => {
+ fsMock.access.mockResolvedValue(undefined);
+ mockExecSuccess('done');
+
+ await tagAudioFileMetadata('/tmp/book.m4b', {
+ title: 'Book `test`',
+ author: 'Author',
+ });
+
+ const command = execMock.mock.calls[0][0] as string;
+ expect(command).toContain('-metadata title="Book \\`test\\`"');
+ });
+
+ it('escapes dollar signs to prevent variable expansion', async () => {
+ fsMock.access.mockResolvedValue(undefined);
+ mockExecSuccess('done');
+
+ await tagAudioFileMetadata('/tmp/book.m4b', {
+ title: 'Book $100',
+ author: 'Author',
+ });
+
+ const command = execMock.mock.calls[0][0] as string;
+ expect(command).toContain('-metadata title="Book \\$100"');
+ });
+
+ it('escapes backslashes before other characters', async () => {
+ fsMock.access.mockResolvedValue(undefined);
+ mockExecSuccess('done');
+
+ await tagAudioFileMetadata('/tmp/book.m4b', {
+ title: 'Path\\to\\book',
+ author: 'Author',
+ });
+
+ const command = execMock.mock.calls[0][0] as string;
+ expect(command).toContain('-metadata title="Path\\\\to\\\\book"');
+ });
+
+ it('handles complex titles with multiple special characters', async () => {
+ fsMock.access.mockResolvedValue(undefined);
+ mockExecSuccess('done');
+
+ await tagAudioFileMetadata('/tmp/book.m4b', {
+ title: "Don't Say \"Hello\" for $5",
+ author: "O'Brien",
+ });
+
+ const command = execMock.mock.calls[0][0] as string;
+ // Single quotes literal, double quotes escaped, dollar escaped
+ expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"');
+ expect(command).toContain('-metadata album_artist="O\'Brien"');
+ });
+ });
});