diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 6a40c3c..e8996e4 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -13,6 +13,7 @@ - **Settings management, encryption** → [backend/services/config.md](backend/services/config.md) - **Settings UI (modular architecture, all tabs)** → [settings-pages.md](settings-pages.md) - **Settings architecture refactoring (Jan 2026)** → [settings-pages.md](settings-pages.md#architecture-refactored-jan-2026) +- **Audiobook organization templates** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure) - **Setup middleware & status check** → [backend/middleware.md](backend/middleware.md) - **Environment variables, PUBLIC_URL, OAuth configuration** → [backend/services/environment.md](backend/services/environment.md) @@ -75,6 +76,7 @@ - **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md) - **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md) - **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md) +- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md) ## Deployment - **Docker Compose setup (multi-container)** → [deployment/docker.md](deployment/docker.md) @@ -98,6 +100,9 @@ **"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md) **"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header) **"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md) +**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) +**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) +**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) **"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md) diff --git a/documentation/admin-dashboard.md b/documentation/admin-dashboard.md index c9f04e9..034613b 100644 --- a/documentation/admin-dashboard.md +++ b/documentation/admin-dashboard.md @@ -7,6 +7,7 @@ Comprehensive overview of system metrics, active requests, download monitoring, ## Sections - **Metrics:** Total requests, active downloads, completed/failed requests, total users, system health +- **Requests Awaiting Approval:** Grid of requests pending admin approval (approve/deny buttons, auto-refresh) - **Active Downloads:** Real-time table with title, progress, speed, ETA - **Recent Requests:** Last 50 with status and timestamps - **Quick Actions:** Links to settings, users, scheduled jobs, system logs @@ -27,13 +28,27 @@ Comprehensive overview of system metrics, active requests, download monitoring, **GET /api/admin/requests/recent** - Request ID, title, user, status, created/completed dates +**GET /api/admin/requests/pending-approval** +- Requests with status 'awaiting_approval', includes audiobook + user details +- Returns: requests array, count + +**POST /api/admin/requests/[id]/approve** +- Action: 'approve' (set status to 'pending', trigger search) or 'deny' (set status to 'denied') +- Validates request is in 'awaiting_approval' status + **GET /api/admin/users** -- User ID, Plex ID, username, email, role, avatar, created/updated dates, last login, request count +- User ID, Plex ID, username, email, role, avatar, created/updated dates, last login, request count, autoApproveRequests **PUT /api/admin/users/[id]** -- Update user role (user/admin) +- Update user role (user/admin), autoApproveRequests (true/false/null) - Prevents self-demotion +**GET /api/admin/settings/auto-approve** +- Get global auto-approve setting (boolean) + +**PATCH /api/admin/settings/auto-approve** +- Update global auto-approve setting (boolean) + **GET /api/admin/logs** - Query params: page, limit, status, type - Returns: Job logs with request/audiobook/user details, pagination info @@ -45,6 +60,14 @@ Comprehensive overview of system metrics, active requests, download monitoring, - Back to Home button in header - Admin role required - Real-time progress updates +- **Requests Awaiting Approval Section:** + - Only visible when pending approval requests exist + - Grid layout (3 columns on desktop) + - Book cards with cover, title, author, user info, timestamp + - Approve (green) and Deny (red) buttons + - Loading states during approval/denial actions + - Toast notifications for success/errors + - Mutates pending-approval, recent requests, metrics caches on action ## Navigation @@ -55,11 +78,18 @@ Comprehensive overview of system metrics, active requests, download monitoring, ## User Management Features -- List all users with avatar, email, role, request count, last login +- List all users with avatar, email, role, request count, last login, autoApproveRequests - Edit user roles (user/admin) - Cannot change own role (security) - Shows request count per user - Role badges (purple for admin, gray for user) +- **Global Auto-Approve Toggle:** + - Checkbox at top: "Auto-approve all requests by default" + - Updates Configuration.auto_approve_requests +- **Per-User Auto-Approve Control:** + - Dropdown: Use Global (null), Always Auto-Approve (true), Always Require Approval (false) + - Updates User.autoApproveRequests + - Shows effective setting (considers global + per-user) ## System Logs Features diff --git a/documentation/admin-features/request-approval.md b/documentation/admin-features/request-approval.md new file mode 100644 index 0000000..5ca5c90 --- /dev/null +++ b/documentation/admin-features/request-approval.md @@ -0,0 +1,215 @@ +# Request Approval System + +**Status:** ✅ Implemented | Admin approval workflow for user requests with global & per-user auto-approve controls + +## Overview +Allows admins to review and approve/deny user requests before they are processed. Supports global auto-approve toggle and per-user auto-approve overrides. + +## Key Details + +### Request Statuses +- **awaiting_approval** - New status for requests pending admin approval +- **denied** - New status for requests rejected by admin +- **pending** - Status after approval (triggers search job) +- Applies to all existing statuses: pending, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn + +### Configuration Keys +- `auto_approve_requests` (Configuration table) - Global setting (true/false string) +- `User.autoApproveRequests` (User table) - Per-user override (boolean, nullable) + - `null` = Use global setting + - `true` = Always auto-approve for this user + - `false` = Always require approval for this user + +### Approval Logic +**When user creates request:** +1. Check `User.autoApproveRequests`: + - If `true` → Set status to 'pending', trigger search job + - If `false` → Set status to 'awaiting_approval', wait for admin + - If `null` → Check global `auto_approve_requests` setting + - If 'true' → Auto-approve (status: 'pending') + - Otherwise → Require approval (status: 'awaiting_approval') + +**Admin approval actions:** +- **Approve** → Change status to 'pending', trigger search job +- **Deny** → Change status to 'denied', no further processing + +## API Endpoints + +### GET /api/admin/requests/pending-approval +Fetch all requests with status 'awaiting_approval' + +**Auth:** Admin only + +**Response:** +```json +{ + "success": true, + "requests": [ + { + "id": "uuid", + "createdAt": "2026-01-15T12:00:00Z", + "audiobook": { + "title": "Book Title", + "author": "Author Name", + "coverArtUrl": "https://..." + }, + "user": { + "id": "uuid", + "plexUsername": "username", + "avatarUrl": "https://..." + } + } + ], + "count": 5 +} +``` + +### POST /api/admin/requests/[id]/approve +Approve or deny a specific request + +**Auth:** Admin only + +**Request:** +```json +{ + "action": "approve" | "deny" +} +``` + +**Response (approve):** +```json +{ + "success": true, + "message": "Request approved and search job triggered", + "request": { /* full request object */ } +} +``` + +**Response (deny):** +```json +{ + "success": true, + "message": "Request denied", + "request": { /* full request object */ } +} +``` + +**Errors:** +- `404` - Request not found +- `400` - Request not in 'awaiting_approval' status +- `400` - Invalid action (must be 'approve' or 'deny') + +### GET /api/admin/settings/auto-approve +Get global auto-approve setting + +**Auth:** Admin only + +**Response:** +```json +{ + "autoApproveRequests": true +} +``` + +### PATCH /api/admin/settings/auto-approve +Update global auto-approve setting + +**Auth:** Admin only + +**Request:** +```json +{ + "autoApproveRequests": true +} +``` + +**Response:** +```json +{ + "autoApproveRequests": true +} +``` + +### PUT /api/admin/users/[id] +Update user (includes autoApproveRequests field) + +**Auth:** Admin only + +**Request:** +```json +{ + "autoApproveRequests": true | false | null +} +``` + +## UI Features + +### Admin Dashboard (/admin) +**Requests Awaiting Approval Section:** +- Shows only when pending approval requests exist +- Grid layout with book cards (3 columns on desktop) +- Each card displays: + - Book cover image + - Title and author + - User avatar and username + - Request timestamp (relative: "2 hours ago") + - Approve button (green, checkmark icon) + - Deny button (red, X icon) +- Auto-refreshes every 10 seconds (SWR) +- Loading states on buttons during approval/denial +- Success/error toast notifications +- Mutates multiple caches on action: pending-approval, recent requests, metrics + +### Admin Users Page (/admin/users) +**Global Auto-Approve Toggle:** +- Checkbox at top of page +- Label: "Auto-approve all requests by default" +- Updates `auto_approve_requests` configuration +- Optimistic UI update with revert on error +- Toast notification on success/error + +**Per-User Auto-Approve Control:** +- Each user row has toggle dropdown: + - "Use Global Setting" (null, default) + - "Always Auto-Approve" (true) + - "Always Require Approval" (false) +- Updates `User.autoApproveRequests` field +- Shows current effective setting (considers global + per-user) +- Optimistic UI update + +### User Request Flow +**When creating request (POST /api/requests):** +- System checks approval logic (see above) +- If awaiting approval → User sees status "Awaiting Approval" on request card +- If auto-approved → User sees status "Pending" and processing begins + +### Request Status Badges +- **awaiting_approval** → Amber badge with warning icon +- **denied** → Red badge with X icon +- All other statuses → Existing badge colors + +## Database Schema + +### User Table +``` +autoApproveRequests: Boolean (nullable, default null) +- null: Use global setting +- true: Always auto-approve +- false: Always require approval +``` + +### Request Table +``` +status: Enum (includes 'awaiting_approval', 'denied') +``` + +### Configuration Table +``` +key: 'auto_approve_requests' +value: 'true' | 'false' (string) +``` + +## Related +- [Admin Dashboard](../admin-dashboard.md) - Dashboard UI features +- [Database Schema](../backend/database.md) - User and Request tables +- [Settings Pages](../settings-pages.md) - Global settings management diff --git a/documentation/admin-features/request-deletion.md b/documentation/admin-features/request-deletion.md index fab1e2b..e6a5f44 100644 --- a/documentation/admin-features/request-deletion.md +++ b/documentation/admin-features/request-deletion.md @@ -92,7 +92,13 @@ model Request { - **ONLY deletes title folder** (not author folder) - Handles missing folders gracefully -4. **Soft Delete Request** +4. **Delete from Library Backend** + - **Audiobookshelf Mode:** Delete library item via API if `absItemId` exists + - Prevents "ghost" entries in Audiobookshelf library + - Only removes from ABS database, not files (already deleted in step 3) + - **Plex Mode:** Clear plex_library cache records + +5. **Soft Delete Request** - UPDATE: `deletedAt = NOW(), deletedBy = adminUserId` - Preserves for audit trail and orphaned download tracking @@ -186,6 +192,8 @@ where: { 6. ✅ **Media folder not found** - Log and continue (already deleted) 7. ✅ **Multiple delete clicks** - Button disabled during deletion 8. ✅ **Network error** - Alert shown, request remains +9. ✅ **ABS library item deletion fails** - Log error, continue with soft delete +10. ✅ **No absItemId present** - Skip ABS deletion (not yet in library) ## File Structure diff --git a/documentation/backend/database.md b/documentation/backend/database.md index 084b321..0c8bd38 100644 --- a/documentation/backend/database.md +++ b/documentation/backend/database.md @@ -14,6 +14,11 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio - `avatar_url`, `auth_token` (encrypted), `created_at`, `updated_at`, `last_login_at` - **Plex Home profile tracking:** - `plex_home_user_id` (string, nullable) - Profile ID from Plex Home (null = main account, set = home profile) +- **Request approval control:** + - `auto_approve_requests` (bool, nullable, default null) - Per-user override for request approval + - `null` = Use global setting (Configuration.auto_approve_requests) + - `true` = Always auto-approve this user's requests + - `false` = Always require admin approval for this user's requests - **BookDate per-user preferences:** - `bookdate_library_scope` ('full'|'rated', default 'full') - Library scope for recommendations - `bookdate_custom_prompt` (text, optional, max 1000 chars) - Custom preferences for AI @@ -52,8 +57,12 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio ### Requests - `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK) -- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'warn') - - Flow: pending → searching → downloading → processing → downloaded → available (when matched in Plex) +- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'warn'|'awaiting_approval'|'denied') + - **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available + - **Denial flow:** awaiting_approval → (deny) → denied + - **awaiting_approval** - Request pending admin approval (only if auto-approve disabled) + - **denied** - Request rejected by admin (terminal state) + - **pending** - Request approved and queued for processing - `progress` (0-100), `priority`, `error_message` - `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5) - `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at` @@ -72,7 +81,11 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio - `id` (UUID PK), `key` (unique), `value`, `encrypted` (bool), `category`, `description` - `created_at`, `updated_at` - Indexes: `key`, `category` -- Example keys: `plex.server_url`, `plex.auth_token`, `indexer.prowlarr_url`, `download_client.qbittorrent_password`, `paths.downloads`, `setup.completed` +- Example keys: `plex.server_url`, `plex.auth_token`, `indexer.prowlarr_url`, `download_client.qbittorrent_password`, `paths.downloads`, `setup.completed`, `auto_approve_requests` +- **Request approval:** + - `auto_approve_requests` (value: 'true'|'false') - Global setting for auto-approving requests + - If 'true' and User.autoApproveRequests is null, requests auto-approved + - If not 'true' and User.autoApproveRequests is null, requests require admin approval ### Jobs - `id` (UUID PK), `bull_job_id`, `request_id` (FK nullable) diff --git a/documentation/backend/services/config.md b/documentation/backend/services/config.md index 2bc6141..653382a 100644 --- a/documentation/backend/services/config.md +++ b/documentation/backend/services/config.md @@ -42,6 +42,7 @@ download_client.qbittorrent.url download_client.qbittorrent.password (encrypted) paths.downloads paths.media_library +paths.audiobook_path_template automation.check_interval_seconds system.setup_completed ``` @@ -99,7 +100,8 @@ const CONFIG_DEFAULTS = { 'system.setup_completed': 'false', 'system.log_level': 'info', 'paths.downloads': '/downloads', - 'paths.media_library': '/media' + 'paths.media_library': '/media', + 'audiobook_path_template': '{author}/{title} {asin}' }; ``` diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index 906e730..f31c712 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -8,32 +8,38 @@ Copies completed downloads to standardized directory structure for Plex. Automat Target directory read from database config `media_dir` (configurable in setup wizard and settings). +**Template-based organization:** +- Config key: `audiobook_path_template` +- Default: `{author}/{title} {asin}` +- Variables: `{author}`, `{title}`, `{narrator}`, `{asin}`, `{year}` +- Optional variables (narrator, asin, year) are removed if not available + +**Examples:** ``` -[media_dir]/ -└── Author Name/ - └── Book Title (Year) ASIN/ - ├── Book Title.m4b - └── cover.jpg +Template: {author}/{title} {asin} +Result: Douglas Adams/The Hitchhiker's Guide to the Galaxy B0009JKV9W/ + +Template: {author}/{title} ({year}) +Result: Douglas Adams/The Hitchhiker's Guide to the Galaxy (2005)/ + +Template: {author}/{narrator}/{title} +Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/ ``` -**Folder naming format:** +**Legacy behavior (hardcoded):** - With year and ASIN: `Book Title (Year) ASIN` - With ASIN only: `Book Title ASIN` - With year only: `Book Title (Year)` - Fallback: `Book Title` -**Example:** `Douglas Adams/The Hitchhiker's Guide to the Galaxy (2005) B0009JKV9W/` - -**Rationale:** Including ASIN in folder name improves Plex/Audnexus agent matching accuracy. - -Default: `/media/audiobooks/` (if not configured) +**Rationale:** Template system allows customization for different metadata agent configurations and user preferences while maintaining backward compatibility. ## Process 1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file) 2. Identify audiobook files (.m4b, .m4a, .mp3) - supports both directories and single files -3. Read media directory from database config `media_dir` -4. Create `[media_dir]/[Author]/[Title (Year) ASIN]/` +3. Read media directory and path template from database config (`media_dir`, `audiobook_path_template`) +4. Apply template to create target path: `[media_dir]/[template result]/` 5. **Copy** files (not move - originals stay for seeding) 6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files 7. Copy cover art if found, else download from Audible @@ -191,7 +197,10 @@ async function organize( ## Configuration - **Media directory:** Read from database config key `media_dir` (set in setup wizard or settings) -- **Fallback:** `/media/audiobooks` if not configured +- **Path template:** Read from database config key `audiobook_path_template` (default: `{author}/{title} {asin}`) +- **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true) +- **Chapter merging:** `chapter_merging_enabled` (boolean, default: false) +- **Fallback:** `/media/audiobooks` if media_dir not configured - **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var) ## Fixed Issues ✅ diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 722943a..d8e5735 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -68,7 +68,7 @@ src/app/admin/settings/ 2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle 3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle 4. **Download Client** - Type, URL, credentials (masked) -5. **Paths** - Download + media directories +5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle 6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history ## Audible Region @@ -98,6 +98,34 @@ src/app/admin/settings/ - **Smart re-initialization**: Service automatically detects region changes and re-initializes before each request - See: `documentation/integrations/audible.md` for technical details +## Audiobook Organization Template + +**Purpose:** Customize how audiobooks are organized within the media directory using variable-based templates. + +**Configuration:** +- Key: `audiobook_path_template` (string, default: `{author}/{title} {asin}`) +- Variables: `{author}`, `{title}`, `{narrator}`, `{asin}`, `{year}` +- Optional variables (narrator, asin, year) removed if not available +- Template validated on test, shows preview examples + +**UI (PathsTab):** +- Text input with monospace font +- Placeholder: `{author}/{title} {asin}` +- Variable reference panel showing all available variables +- Template validation on "Test Paths" with success/error feedback +- Preview examples showing 2-3 sample paths with actual data + +**Validation:** +- Must contain at least `{author}` or `{title}` (required variables) +- Cannot be empty or only contain optional variables +- Invalid templates show error message +- Valid templates show preview paths + +**Examples:** +- `{author}/{title} {asin}` → `Douglas Adams/The Hitchhiker's Guide to the Galaxy B0009JKV9W/` +- `{author}/{title} ({year})` → `Douglas Adams/The Hitchhiker's Guide to the Galaxy (2005)/` +- `{author}/{narrator}/{title}` → `Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/` + ## Filesystem Scan Trigger **Purpose:** Trigger Plex/Audiobookshelf to scan filesystem after organizing files for users with disabled filesystem watchers. @@ -160,7 +188,7 @@ src/app/admin/settings/ - Plex: URL or token modified - Prowlarr: URL or API key modified (NOT indexer config) - Download Client: URL, username, or password modified -- Paths: Directory paths modified +- Paths: Directory paths or template modified ## API Endpoints @@ -197,14 +225,15 @@ src/app/admin/settings/ - Requires prior successful test if credentials changed **PUT /api/admin/settings/paths** -- Updates paths -- Requires prior successful test if paths changed +- Updates paths and audiobook organization template +- Requires prior successful test if paths or template changed +- Body: `{ downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled }` **Test Endpoints (authenticated, handle masked values):** - POST /api/admin/settings/test-plex - Tests Plex connection, uses stored token if masked, returns libraries - POST /api/admin/settings/test-prowlarr - Tests connection, uses stored API key if masked, returns indexers - POST /api/admin/settings/test-download-client - Tests qBittorrent/Transmission, uses stored password if masked -- POST /api/setup/test-paths - Validates paths writable (no sensitive data, reuses wizard endpoint) +- POST /api/setup/test-paths - Validates paths writable and template format, returns `{success, message, templateValidation: {isValid, error?, previewPaths?}}` **BookDate Endpoints:** - GET /api/bookdate/config - Get global BookDate configuration (API key excluded, admin only) @@ -237,7 +266,7 @@ src/app/admin/settings/ **Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected **Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean **Download Client:** Valid URL, credentials required, type must be 'qbittorrent' or 'transmission' -**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory +**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}` ## Tech Stack diff --git a/prisma/migrations/20260115000000_add_audiobook_path_template/migration.sql b/prisma/migrations/20260115000000_add_audiobook_path_template/migration.sql new file mode 100644 index 0000000..3a0c2fc --- /dev/null +++ b/prisma/migrations/20260115000000_add_audiobook_path_template/migration.sql @@ -0,0 +1,17 @@ +-- Add audiobook path template configuration +-- This allows admin to customize the folder/file path template for organized audiobooks +-- Template supports placeholders: {author}, {title}, {asin} + +-- Insert default configuration for audiobook path template +INSERT INTO configuration (id, key, value, encrypted, category, description, created_at, updated_at) +VALUES ( + gen_random_uuid(), + 'audiobook_path_template', + '{author}/{title} {asin}', + false, + 'automation', + 'Template for organizing audiobook file paths. Supports placeholders: {author}, {title}, {asin}. Example: "{author}/{title} {asin}" creates "Author Name/Book Title ASIN/audiobook.m4b"', + NOW(), + NOW() +) +ON CONFLICT (key) DO NOTHING; diff --git a/prisma/migrations/20260115120000_add_year_to_audiobook/migration.sql b/prisma/migrations/20260115120000_add_year_to_audiobook/migration.sql new file mode 100644 index 0000000..ddbece9 --- /dev/null +++ b/prisma/migrations/20260115120000_add_year_to_audiobook/migration.sql @@ -0,0 +1,2 @@ +-- AddYearToAudiobook +ALTER TABLE "audiobooks" ADD COLUMN "year" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4c16e3..f3f6e3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,9 @@ model User { bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete") + // Request approval preferences + autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user @@ -162,6 +165,7 @@ model Audiobook { narrator String? description String? @db.Text coverArtUrl String? @map("cover_art_url") @db.Text + year Int? // Release year extracted from releaseDate // Request tracking status String @default("requested") // requested, downloading, processing, completed, failed @@ -199,7 +203,7 @@ model Request { userId String @map("user_id") audiobookId String @map("audiobook_id") status String @default("pending") - // Status values: pending, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn + // Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn // Flow: pending → searching → downloading → processing → downloaded → available (when matched in Plex) progress Int @default(0) // 0-100 priority Int @default(0) diff --git a/src/app/admin/jobs/page.tsx b/src/app/admin/jobs/page.tsx index 3562e22..bfb767a 100644 --- a/src/app/admin/jobs/page.tsx +++ b/src/app/admin/jobs/page.tsx @@ -175,7 +175,7 @@ function AdminJobsPageContent() {
{/* Header */} -
+

Scheduled Jobs diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index abffaad..a7e6bdc 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -142,7 +142,7 @@ export default function AdminLogsPage() {
{/* Header */} -
+

System Logs diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 6199543..96bdbd8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,13 +5,285 @@ 'use client'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; import Link from 'next/link'; -import { authenticatedFetcher } from '@/lib/utils/api'; +import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api'; import { MetricCard } from './components/MetricCard'; import { ActiveDownloadsTable } from './components/ActiveDownloadsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable'; -import { ToastProvider } from '@/components/ui/Toast'; +import { ToastProvider, useToast } from '@/components/ui/Toast'; +import { formatDistanceToNow } from 'date-fns'; +import { useState } from 'react'; + +interface PendingApprovalRequest { + id: string; + createdAt: string; + audiobook: { + title: string; + author: string; + coverArtUrl: string | null; + }; + user: { + id: string; + plexUsername: string; + avatarUrl: string | null; + }; +} + +function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) { + const toast = useToast(); + const [loadingStates, setLoadingStates] = useState>({}); + + const handleApproveRequest = async (requestId: string) => { + setLoadingStates((prev) => ({ ...prev, [requestId]: true })); + + try { + await fetchJSON(`/api/admin/requests/${requestId}/approve`, { + method: 'POST', + body: JSON.stringify({ action: 'approve' }), + }); + + toast.success('Request approved'); + + // Mutate both pending requests and recent requests caches + await mutate('/api/admin/requests/pending-approval'); + await mutate('/api/admin/requests/recent'); + await mutate('/api/admin/metrics'); + } catch (error) { + console.error('[Admin] Failed to approve request:', error); + toast.error( + `Failed to approve request: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setLoadingStates((prev) => ({ ...prev, [requestId]: false })); + } + }; + + const handleDenyRequest = async (requestId: string) => { + setLoadingStates((prev) => ({ ...prev, [requestId]: true })); + + try { + await fetchJSON(`/api/admin/requests/${requestId}/approve`, { + method: 'POST', + body: JSON.stringify({ action: 'deny' }), + }); + + toast.success('Request denied'); + + // Mutate pending requests cache + await mutate('/api/admin/requests/pending-approval'); + await mutate('/api/admin/metrics'); + } catch (error) { + console.error('[Admin] Failed to deny request:', error); + toast.error( + `Failed to deny request: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setLoadingStates((prev) => ({ ...prev, [requestId]: false })); + } + }; + + return ( +
+ {/* Section Header */} +
+
+ + + +

+ Requests Awaiting Approval +

+
+ + {requests.length} + +
+ + {/* Requests Grid */} +
+ {requests.map((request) => { + const isLoading = loadingStates[request.id] || false; + + return ( +
+ {/* Card Content */} +
+
+ {/* Cover Image */} +
+ {request.audiobook.coverArtUrl ? ( + {request.audiobook.title} + ) : ( +
+ + + +
+ )} +
+ + {/* Book Info */} +
+

+ {request.audiobook.title} +

+

+ {request.audiobook.author} +

+ + {/* User Info */} +
+ {request.user.avatarUrl ? ( + {request.user.plexUsername} + ) : ( +
+ + + +
+ )} + + {request.user.plexUsername} + +
+ + {/* Timestamp */} +

+ {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} +

+
+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ ); + })} +
+
+ ); +} function AdminDashboardContent() { // Fetch data with auto-refresh every 10 seconds @@ -39,6 +311,14 @@ function AdminDashboardContent() { } ); + const { data: pendingApprovalData } = useSWR( + '/api/admin/requests/pending-approval', + authenticatedFetcher, + { + refreshInterval: 10000, + } + ); + const { data: settingsData } = useSWR( '/api/admin/settings', authenticatedFetcher, @@ -74,7 +354,7 @@ function AdminDashboardContent() {
{/* Header */} -
+

Admin Dashboard @@ -197,6 +477,11 @@ function AdminDashboardContent() { />

+ {/* Requests Awaiting Approval */} + {pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && ( + + )} + {/* Active Downloads */}

diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 56b3e08..0f6ffc3 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -95,6 +95,7 @@ export interface DownloadClientSettings { export interface PathsSettings { downloadDir: string; mediaDir: string; + audiobookPathTemplate?: string; metadataTaggingEnabled: boolean; chapterMergingEnabled: boolean; } @@ -187,6 +188,11 @@ export interface TestResult { success: boolean; message: string; responseTime?: number; + templateValidation?: { + isValid: boolean; + error?: string; + previewPaths?: string[]; + }; } /** diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 0990d9f..a520afd 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -168,22 +168,24 @@ export default function AdminSettings() {
{/* Header */} -
-
- - - - - -

Settings

+
+
+

+ Settings +

+

+ Configure system integrations and preferences +

+ + + + + Back to Dashboard +
{/* Tab Navigation */} diff --git a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx index a43b419..e34624d 100644 --- a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx +++ b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx @@ -5,11 +5,12 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { usePathsSettings } from './usePathsSettings'; import type { PathsSettings } from '../../lib/types'; +import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; interface PathsTabProps { paths: PathsSettings; @@ -24,6 +25,31 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) onValidationChange, }); + // Live preview state (client-side validation) + const [livePreview, setLivePreview] = useState<{ + isValid: boolean; + error?: string; + previewPaths?: string[]; + } | null>(null); + + // Update live preview whenever template changes + useEffect(() => { + const template = paths.audiobookPathTemplate || '{author}/{title} {asin}'; + const validation = validateTemplate(template); + + if (validation.valid) { + setLivePreview({ + isValid: true, + previewPaths: generateMockPreviews(template), + }); + } else { + setLivePreview({ + isValid: false, + error: validation.error, + }); + } + }, [paths.audiobookPathTemplate]); + return (
@@ -69,6 +95,78 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)

+ {/* Audiobook Organization Template */} +
+ + updatePath('audiobookPathTemplate', e.target.value)} + placeholder="{author}/{title} {asin}" + className="font-mono" + /> +

+ Customize how audiobooks are organized within the media directory +

+ + {/* Variable Reference Panel */} +
+

+ Available Variables +

+
+
+ {'{author}'} + - Book author +
+
+ {'{title}'} + - Book title +
+
+ {'{narrator}'} + - Narrator name +
+
+ {'{year}'} + - Release year +
+
+ {'{asin}'} + - Audible ASIN +
+
+
+ + {/* Live Preview - Client-side validation */} + {livePreview && !livePreview.isValid && ( +
+ +
+ {livePreview.error || 'Invalid template format'} +
+
+ )} + + {/* Live Preview Examples - Show while editing */} + {livePreview && livePreview.isValid && livePreview.previewPaths && ( +
+

+ Preview Examples +

+
+ {livePreview.previewPaths.map((preview, index) => ( +
+ {paths.mediaDir || '/media/audiobooks'}/{preview} +
+ ))} +
+
+ )} +
+ {/* Metadata Tagging Toggle */}
diff --git a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts index 6d5f206..f741d99 100644 --- a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts +++ b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts @@ -27,7 +27,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat }; /** - * Test if paths are valid and writable + * Test if paths are valid and writable, including template validation */ const testPaths = async () => { setTesting(true); @@ -40,6 +40,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat body: JSON.stringify({ downloadDir: paths.downloadDir, mediaDir: paths.mediaDir, + audiobookPathTemplate: paths.audiobookPathTemplate, }), }); @@ -48,7 +49,8 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat if (data.success) { const result: TestResult = { success: true, - message: 'All paths are valid and writable' + message: 'All paths are valid and writable', + templateValidation: data.template }; setTestResult(result); onValidationChange(true); @@ -56,10 +58,13 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat } else { const result: TestResult = { success: false, - message: data.error || 'Path validation failed' + message: data.error || 'Path validation failed', + templateValidation: data.template }; setTestResult(result); - onValidationChange(false); + // Only mark as valid if paths are valid AND template is valid (if provided) + const isValid = false; + onValidationChange(isValid); return result; } } catch (error) { diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 957af39..6cc7970 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -5,7 +5,7 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import useSWR from 'swr'; import Link from 'next/link'; import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api'; @@ -24,6 +24,7 @@ interface User { createdAt: string; updatedAt: string; lastLoginAt: string | null; + autoApproveRequests: boolean | null; _count: { requests: number; }; @@ -43,6 +44,10 @@ function AdminUsersPageContent() { '/api/admin/users/pending', authenticatedFetcher ); + const { data: globalAutoApproveData, error: globalAutoApproveError, mutate: mutateGlobalAutoApprove } = useSWR( + '/api/admin/settings/auto-approve', + authenticatedFetcher + ); const [editDialog, setEditDialog] = useState<{ isOpen: boolean; user: User | null; @@ -60,11 +65,77 @@ function AdminUsersPageContent() { user: User | null; }>({ isOpen: false, user: null }); const [deleting, setDeleting] = useState(false); + const [globalAutoApprove, setGlobalAutoApprove] = useState(false); const toast = useToast(); const isLoading = !data && !error; const pendingUsers: PendingUser[] = pendingData?.users || []; + // Sync global auto-approve state (default to true if not set) + useEffect(() => { + if (globalAutoApproveData?.autoApproveRequests !== undefined) { + setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests); + } else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) { + // API returned but no value - default to true + setGlobalAutoApprove(true); + } + }, [globalAutoApproveData]); + + const handleGlobalAutoApproveToggle = async (newValue: boolean) => { + // Optimistic update + setGlobalAutoApprove(newValue); + + try { + await fetchJSON('/api/admin/settings/auto-approve', { + method: 'PATCH', + body: JSON.stringify({ autoApproveRequests: newValue }), + }); + toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`); + mutateGlobalAutoApprove(); + mutate(); // Refresh users list to show updated state + } catch (err) { + // Revert on error + setGlobalAutoApprove(!newValue); + const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting'; + toast.error(errorMsg); + console.error(err); + } + }; + + const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => { + console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue }); + + // Optimistic update + const previousUsers = data?.users || []; + const optimisticUsers = previousUsers.map((u: User) => + u.id === user.id ? { ...u, autoApproveRequests: newValue } : u + ); + console.log('[AutoApprove] Applying optimistic update'); + mutate({ users: optimisticUsers }, false); + + try { + console.log('[AutoApprove] Sending API request...'); + const response = await fetchJSON(`/api/admin/users/${user.id}`, { + method: 'PUT', + body: JSON.stringify({ + role: user.role, + autoApproveRequests: newValue + }), + }); + console.log('[AutoApprove] API response received:', response); + toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); + console.log('[AutoApprove] Triggering cache revalidation...'); + mutate(); // Refresh users list + } catch (err) { + // Revert on error + console.error('[AutoApprove] Error occurred, reverting:', err); + mutate({ users: previousUsers }, false); + const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting'; + toast.error(errorMsg); + console.error(err); + } + }; + const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); @@ -207,7 +278,7 @@ function AdminUsersPageContent() {
{/* Header */} -
+

User Management @@ -227,6 +298,32 @@ function AdminUsersPageContent() {

+ {/* Global Auto-Approve Toggle */} +
+
+ +
+ +

+ When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings below. +

+
+
+
+ {/* Pending Users Section */} {pendingUsers.length > 0 && (
@@ -305,6 +402,9 @@ function AdminUsersPageContent() { Role + + Auto-Approve + Requests @@ -370,6 +470,33 @@ function AdminUsersPageContent() { )}
+ +
+ {user.role === 'admin' ? ( + + + + + Always On + + ) : globalAutoApprove ? ( + + Global Setting + + ) : ( + + )} +
+ {user._count.requests} @@ -460,6 +587,7 @@ function AdminUsersPageContent() {
  • User: Can request audiobooks, view own requests, and search the catalog
  • Admin: Full system access including settings, user management, and all requests
  • Setup Admin: The initial admin account created during setup - this account is protected and cannot be changed or deleted
  • +
  • Auto-Approve: When the global setting is enabled, all requests are automatically processed. When disabled, you can control auto-approval per user. Admin requests are always auto-approved.
  • OIDC Users: Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.
  • Plex Users: Can have their roles changed, but cannot be deleted as access is managed by Plex.
  • Local Users: Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).
  • diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts new file mode 100644 index 0000000..4b8473e --- /dev/null +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -0,0 +1,169 @@ +/** + * Component: Admin Request Approval API + * Documentation: documentation/admin-features/request-approval.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { RMABLogger } from '@/lib/utils/logger'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.Admin.Requests.Approve'); + +const ApprovalActionSchema = z.object({ + action: z.enum(['approve', 'deny']), +}); + +/** + * POST /api/admin/requests/[id]/approve + * Approve or deny a request in 'awaiting_approval' status + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + if (!req.user) { + return NextResponse.json( + { error: 'Unauthorized', message: 'User not authenticated' }, + { status: 401 } + ); + } + + const { id } = await params; + const body = await request.json(); + + // Validate action + const { action } = ApprovalActionSchema.parse(body); + + // Fetch the request + const existingRequest = await prisma.request.findUnique({ + where: { id }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + }, + }, + }, + }); + + if (!existingRequest) { + return NextResponse.json( + { error: 'NotFound', message: 'Request not found' }, + { status: 404 } + ); + } + + // Validate request is in 'awaiting_approval' status + if (existingRequest.status !== 'awaiting_approval') { + return NextResponse.json( + { + error: 'InvalidStatus', + message: `Request is not awaiting approval (current status: ${existingRequest.status})`, + currentStatus: existingRequest.status, + }, + { status: 400 } + ); + } + + // Update request based on action + if (action === 'approve') { + // Approve: Change status to 'pending' and trigger search job + const updatedRequest = await prisma.request.update({ + where: { id }, + data: { status: 'pending' }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + }, + }, + }, + }); + + // Trigger search job + const jobQueue = getJobQueueService(); + await jobQueue.addSearchJob(updatedRequest.id, { + id: updatedRequest.audiobook.id, + title: updatedRequest.audiobook.title, + author: updatedRequest.audiobook.author, + asin: updatedRequest.audiobook.audibleAsin || undefined, + }); + + logger.info(`Request ${id} approved by admin ${req.user.sub}`, { + requestId: id, + userId: updatedRequest.userId, + audiobookTitle: updatedRequest.audiobook.title, + adminId: req.user.sub, + }); + + return NextResponse.json({ + success: true, + message: 'Request approved and search job triggered', + request: updatedRequest, + }); + } else { + // Deny: Change status to 'denied' + const updatedRequest = await prisma.request.update({ + where: { id }, + data: { status: 'denied' }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + }, + }, + }, + }); + + logger.info(`Request ${id} denied by admin ${req.user.sub}`, { + requestId: id, + userId: updatedRequest.userId, + audiobookTitle: updatedRequest.audiobook.title, + adminId: req.user.sub, + }); + + return NextResponse.json({ + success: true, + message: 'Request denied', + request: updatedRequest, + }); + } + } catch (error) { + logger.error('Failed to process approval action', { + error: error instanceof Error ? error.message : String(error) + }); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'ValidationError', + message: 'Invalid action. Must be "approve" or "deny"', + details: error.errors, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + error: 'ApprovalError', + message: 'Failed to process approval action', + }, + { status: 500 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/requests/pending-approval/route.ts b/src/app/api/admin/requests/pending-approval/route.ts new file mode 100644 index 0000000..00161e5 --- /dev/null +++ b/src/app/api/admin/requests/pending-approval/route.ts @@ -0,0 +1,58 @@ +/** + * Component: Admin Pending Approval Requests API + * Documentation: documentation/admin-features/request-approval.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Admin.Requests.PendingApproval'); + +/** + * GET /api/admin/requests/pending-approval + * Get all requests with status 'awaiting_approval' + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const requests = await prisma.request.findMany({ + where: { + status: 'awaiting_approval', + deletedAt: null, + }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + avatarUrl: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + success: true, + requests, + count: requests.length, + }); + } catch (error) { + logger.error('Failed to fetch pending approval requests', { + error: error instanceof Error ? error.message : String(error) + }); + return NextResponse.json( + { + error: 'FetchError', + message: 'Failed to fetch pending approval requests', + }, + { status: 500 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/settings/auto-approve/route.ts b/src/app/api/admin/settings/auto-approve/route.ts new file mode 100644 index 0000000..a5f2c93 --- /dev/null +++ b/src/app/api/admin/settings/auto-approve/route.ts @@ -0,0 +1,89 @@ +/** + * Component: Admin Auto-Approve Settings API + * Documentation: documentation/settings-pages.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Admin.Settings.AutoApprove'); + +/** + * GET /api/admin/settings/auto-approve + * Get current global auto-approve setting + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const config = await prisma.configuration.findUnique({ + where: { key: 'auto_approve_requests' }, + }); + + // Default to true if not configured (backward compatibility) + const autoApproveRequests = config === null ? true : config.value === 'true'; + + return NextResponse.json({ autoApproveRequests }); + } catch (error) { + logger.error('Failed to fetch auto-approve setting', { + error: error instanceof Error ? error.message : String(error) + }); + return NextResponse.json( + { error: 'Failed to fetch auto-approve setting' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * PATCH /api/admin/settings/auto-approve + * Update global auto-approve setting + */ +export async function PATCH(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const body = await request.json(); + const { autoApproveRequests } = body; + + // Validate input + if (typeof autoApproveRequests !== 'boolean') { + return NextResponse.json( + { error: 'Invalid input. autoApproveRequests must be a boolean' }, + { status: 400 } + ); + } + + // Update configuration + await prisma.configuration.upsert({ + where: { key: 'auto_approve_requests' }, + create: { + key: 'auto_approve_requests', + value: autoApproveRequests.toString(), + }, + update: { + value: autoApproveRequests.toString(), + }, + }); + + logger.info(`Auto-approve setting updated to: ${autoApproveRequests}`, { + userId: req.user?.sub, + }); + + return NextResponse.json({ autoApproveRequests }); + } catch (error) { + logger.error('Failed to update auto-approve setting', { + error: error instanceof Error ? error.message : String(error) + }); + return NextResponse.json( + { error: 'Failed to update auto-approve setting' }, + { status: 500 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/settings/paths/route.ts b/src/app/api/admin/settings/paths/route.ts index a24e6e3..28e766b 100644 --- a/src/app/api/admin/settings/paths/route.ts +++ b/src/app/api/admin/settings/paths/route.ts @@ -14,7 +14,7 @@ export async function PUT(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - const { downloadDir, mediaDir, metadataTaggingEnabled, chapterMergingEnabled } = await request.json(); + const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json(); if (!downloadDir || !mediaDir) { return NextResponse.json( @@ -44,6 +44,20 @@ export async function PUT(request: NextRequest) { create: { key: 'media_dir', value: mediaDir }, }); + // Update audiobook path template + if (audiobookPathTemplate !== undefined) { + await prisma.configuration.upsert({ + where: { key: 'audiobook_path_template' }, + update: { value: audiobookPathTemplate }, + create: { + key: 'audiobook_path_template', + value: audiobookPathTemplate, + category: 'automation', + description: 'Template for organizing audiobook files in media directory', + }, + }); + } + // Update metadata tagging setting await prisma.configuration.upsert({ where: { key: 'metadata_tagging_enabled' }, diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 54d3ed6..e680e37 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -86,6 +86,7 @@ export async function GET(request: NextRequest) { paths: { downloadDir: configMap.get('download_dir') || '/downloads', mediaDir: configMap.get('media_dir') || '/media/audiobooks', + audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}', metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true', chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true', }, diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index b0b188a..9b491f2 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -19,7 +19,7 @@ export async function PUT( try { const { id } = await params; const body = await request.json(); - const { role } = body; + const { role, autoApproveRequests } = body; // Validate role if (!role || (role !== 'user' && role !== 'admin')) { @@ -29,6 +29,14 @@ export async function PUT( ); } + // Validate autoApproveRequests (optional) + if (autoApproveRequests !== undefined && autoApproveRequests !== null && typeof autoApproveRequests !== 'boolean') { + return NextResponse.json( + { error: 'Invalid autoApproveRequests. Must be a boolean or null' }, + { status: 400 } + ); + } + // Prevent user from demoting themselves if (req.user && id === req.user.sub) { return NextResponse.json( @@ -45,6 +53,7 @@ export async function PUT( authProvider: true, plexUsername: true, deletedAt: true, + role: true, // Need current role to detect role changes }, }); @@ -63,30 +72,48 @@ export async function PUT( ); } - // Prevent changing setup admin role - if (targetUser.isSetupAdmin && role !== 'admin') { + // Detect if role is being changed + const isRoleChange = targetUser.role !== role; + + // Prevent changing setup admin role (only if role is actually being changed) + if (targetUser.isSetupAdmin && isRoleChange && role !== 'admin') { return NextResponse.json( { error: 'Cannot change the setup admin role. This account must always remain an admin.' }, { status: 403 } ); } - // Prevent changing OIDC user roles (managed by identity provider) - if (targetUser.authProvider === 'oidc') { + // Prevent changing OIDC user roles (only if role is actually being changed) + if (targetUser.authProvider === 'oidc' && isRoleChange) { return NextResponse.json( { error: 'Cannot change OIDC user roles. Use admin role mapping in OIDC settings instead.' }, { status: 403 } ); } - // Update user role + // Validate that admins cannot have autoApproveRequests set to false + if (role === 'admin' && autoApproveRequests === false) { + return NextResponse.json( + { error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' }, + { status: 400 } + ); + } + + // Prepare update data + const updateData: { role: string; autoApproveRequests?: boolean | null } = { role }; + if (autoApproveRequests !== undefined) { + updateData.autoApproveRequests = autoApproveRequests; + } + + // Update user role and autoApproveRequests const updatedUser = await prisma.user.update({ where: { id }, - data: { role }, + data: updateData, select: { id: true, plexUsername: true, role: true, + autoApproveRequests: true, }, }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 054a26c..071d640 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -30,6 +30,7 @@ export async function GET(request: NextRequest) { createdAt: true, updatedAt: true, lastLoginAt: true, + autoApproveRequests: true, _count: { select: { requests: true, diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index ffc623e..3706176 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -10,6 +10,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; +import { getAudibleService } from '@/lib/integrations/audible.service'; import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; @@ -112,6 +113,27 @@ export async function POST(request: NextRequest) { ); } + // Fetch full details from Audnexus to get releaseDate and year + let year: number | undefined; + try { + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin); + + if (audnexusData?.releaseDate) { + try { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`); + } + } catch (error) { + logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } catch (error) { + logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + // Try to find existing audiobook record by ASIN let audiobookRecord = await prisma.audiobook.findFirst({ where: { audibleAsin: audiobook.asin }, @@ -127,9 +149,18 @@ export async function POST(request: NextRequest) { narrator: audiobook.narrator, description: audiobook.description, coverArtUrl: audiobook.coverArtUrl, + year, status: 'requested', }, }); + logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`); + } else if (year) { + // Always update year if we have it from Audnexus (even if audiobook already has one) + audiobookRecord = await prisma.audiobook.update({ + where: { id: audiobookRecord.id }, + data: { year }, + }); + logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`); } // Check if user already has an active (non-deleted) request for this audiobook diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts index b83f2b9..3477f68 100644 --- a/src/app/api/bookdate/swipe/route.ts +++ b/src/app/api/bookdate/swipe/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; +import { getAudibleService } from '@/lib/integrations/audible.service'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.BookDateSwipe'); @@ -62,12 +63,33 @@ async function handler(req: AuthenticatedRequest) { // If swiped right and not marked as known, create request if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) { try { + // Fetch full details from Audnexus to get releaseDate and year + let year: number | undefined; + try { + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin); + + if (audnexusData?.releaseDate) { + try { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`); + } + } catch (error) { + logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } catch (error) { + logger.warn(`Failed to fetch Audnexus data for ASIN ${recommendation.audnexusAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + // Check if book already exists in audiobooks table let audiobook = await prisma.audiobook.findFirst({ where: { audibleAsin: recommendation.audnexusAsin }, }); - // If not, create it + // If not, create it with year if (!audiobook) { audiobook = await prisma.audiobook.create({ data: { @@ -77,9 +99,18 @@ async function handler(req: AuthenticatedRequest) { narrator: recommendation.narrator, description: recommendation.description, coverArtUrl: recommendation.coverUrl, + year, status: 'requested', }, }); + logger.debug(`Created audiobook ${audiobook.id} with year: ${year || 'none'}`); + } else if (year) { + // Always update year if we have it from Audnexus (even if audiobook already has one) + audiobook = await prisma.audiobook.update({ + where: { id: audiobook.id }, + data: { year }, + }); + logger.debug(`Updated audiobook ${audiobook.id} with year ${year}`); } // Create request (if not already exists) diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts index ff9d241..fdfda36 100644 --- a/src/app/api/requests/[id]/fetch-ebook/route.ts +++ b/src/app/api/requests/[id]/fetch-ebook/route.ts @@ -9,53 +9,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { downloadEbook } from '@/lib/services/ebook-scraper'; +import { buildAudiobookPath } from '@/lib/utils/file-organizer'; import fs from 'fs/promises'; import path from 'path'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.FetchEbook'); -/** - * Sanitize path component (same logic as file-organizer) - */ -function sanitizePath(name: string): string { - return ( - name - .replace(/[<>:"/\\|?*]/g, '') - .trim() - .replace(/^\.+/, '') - .replace(/\.+$/, '') - .replace(/\s+/g, ' ') - .slice(0, 200) - ); -} - -/** - * Build target path (same logic as file-organizer) - */ -function buildTargetPath( - baseDir: string, - author: string, - title: string, - year?: number | null, - asin?: string | null -): string { - const authorClean = sanitizePath(author); - const titleClean = sanitizePath(title); - - let folderName = titleClean; - - if (year) { - folderName = `${folderName} (${year})`; - } - - if (asin) { - folderName = `${folderName} ${asin}`; - } - - return path.join(baseDir, authorClean, folderName); -} - export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -103,37 +63,43 @@ export async function POST( const audiobook = requestRecord.audiobook; // Get configuration - const [mediaDirConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([ + const [mediaDirConfig, templateConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([ prisma.configuration.findUnique({ where: { key: 'media_dir' } }), + prisma.configuration.findUnique({ where: { key: 'audiobook_path_template' } }), prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }), prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }), prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }), ]); const mediaDir = mediaDirConfig?.value || '/media/audiobooks'; + const template = templateConfig?.value || '{author}/{title} {asin}'; const preferredFormat = formatConfig?.value || 'epub'; const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li'; const flaresolverrUrl = flaresolverrConfig?.value || undefined; - // Get year from AudibleCache if available + // Fetch year from audible cache if ASIN is available let year: number | undefined; if (audiobook.audibleAsin) { - const audibleCacheData = await prisma.audibleCache.findUnique({ + const audibleCache = await prisma.audibleCache.findUnique({ where: { asin: audiobook.audibleAsin }, select: { releaseDate: true }, }); - if (audibleCacheData?.releaseDate) { - year = new Date(audibleCacheData.releaseDate).getFullYear(); + if (audibleCache?.releaseDate) { + year = new Date(audibleCache.releaseDate).getFullYear(); } } - // Build target path - const targetPath = buildTargetPath( + // Build target path using centralized function + const targetPath = buildAudiobookPath( mediaDir, - audiobook.author, - audiobook.title, - year, - audiobook.audibleAsin + template, + { + author: audiobook.author, + title: audiobook.title, + narrator: audiobook.narrator || undefined, + asin: audiobook.audibleAsin || undefined, + year, + } ); logger.debug('Fetch e-book request', { diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 1f174ab..61b5811 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; +import { getAudibleService } from '@/lib/integrations/audible.service'; import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; @@ -96,6 +97,27 @@ export async function POST(request: NextRequest) { ); } + // Fetch full details from Audnexus to get releaseDate and year + let year: number | undefined; + try { + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin); + + if (audnexusData?.releaseDate) { + try { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`); + } + } catch (error) { + logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } catch (error) { + logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + // Try to find existing audiobook record by ASIN let audiobookRecord = await prisma.audiobook.findFirst({ where: { audibleAsin: audiobook.asin }, @@ -111,9 +133,18 @@ export async function POST(request: NextRequest) { narrator: audiobook.narrator, description: audiobook.description, coverArtUrl: audiobook.coverArtUrl, + year, status: 'requested', }, }); + logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`); + } else if (year) { + // Always update year if we have it from Audnexus (even if audiobook already has one) + audiobookRecord = await prisma.audiobook.update({ + where: { id: audiobookRecord.id }, + data: { year }, + }); + logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`); } // Check if user already has an active (non-deleted) request for this audiobook @@ -150,12 +181,64 @@ export async function POST(request: NextRequest) { // Check if we should skip auto-search (for interactive search) const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true'; + // Check if request needs approval + let needsApproval = false; + let shouldTriggerSearch = !skipAutoSearch; + + // Fetch user with autoApproveRequests setting + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + role: true, + autoApproveRequests: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: 'UserNotFound', message: 'User not found' }, + { status: 404 } + ); + } + + // Determine if approval is needed + if (user.role === 'admin') { + // Admins always auto-approve + needsApproval = false; + } else { + // Check user's personal setting first + if (user.autoApproveRequests === true) { + needsApproval = false; + } else if (user.autoApproveRequests === false) { + needsApproval = true; + } else { + // User setting is null, check global setting + const globalConfig = await prisma.configuration.findUnique({ + where: { key: 'auto_approve_requests' }, + }); + // Default to true if not configured (backward compatibility) + const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true'; + needsApproval = !globalAutoApprove; + } + } + + // Determine initial status + let initialStatus: string; + if (needsApproval) { + initialStatus = 'awaiting_approval'; + shouldTriggerSearch = false; // Don't trigger search if awaiting approval + } else if (skipAutoSearch) { + initialStatus = 'awaiting_search'; + } else { + initialStatus = 'pending'; + } + // Create request with appropriate status const newRequest = await prisma.request.create({ data: { userId: req.user.id, audiobookId: audiobookRecord.id, - status: skipAutoSearch ? 'awaiting_search' : 'pending', + status: initialStatus, progress: 0, }, include: { @@ -169,8 +252,8 @@ export async function POST(request: NextRequest) { }, }); - // Trigger search job only if not skipped - if (!skipAutoSearch) { + // Trigger search job only if not skipped and not awaiting approval + if (shouldTriggerSearch) { const jobQueue = getJobQueueService(); await jobQueue.addSearchJob(newRequest.id, { id: audiobookRecord.id, diff --git a/src/app/api/setup/test-paths/route.ts b/src/app/api/setup/test-paths/route.ts index 2b3a914..cac8e90 100644 --- a/src/app/api/setup/test-paths/route.ts +++ b/src/app/api/setup/test-paths/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs/promises'; import path from 'path'; import { RMABLogger } from '@/lib/utils/logger'; +import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; const logger = RMABLogger.create('API.Setup.TestPaths'); @@ -45,7 +46,7 @@ async function testPath(dirPath: string): Promise { export async function POST(request: NextRequest) { try { - const { downloadDir, mediaDir } = await request.json(); + const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json(); if (!downloadDir || !mediaDir) { return NextResponse.json( @@ -58,6 +59,26 @@ export async function POST(request: NextRequest) { const downloadDirValid = await testPath(downloadDir); const mediaDirValid = await testPath(mediaDir); + // Validate template if provided + let templateValidation: { + isValid: boolean; + error?: string; + previewPaths?: string[]; + } | undefined; + + if (audiobookPathTemplate) { + const validation = validateTemplate(audiobookPathTemplate); + templateValidation = { + isValid: validation.valid, + error: validation.error, + }; + + // Generate previews only if template is valid + if (validation.valid) { + templateValidation.previewPaths = generateMockPreviews(audiobookPathTemplate); + } + } + const success = downloadDirValid && mediaDirValid; if (!success) { @@ -71,16 +92,28 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, - downloadDirValid, - mediaDirValid, + downloadDir: { + valid: downloadDirValid, + error: downloadDirValid ? undefined : 'Download directory path is invalid or parent mount is not writable', + }, + mediaDir: { + valid: mediaDirValid, + error: mediaDirValid ? undefined : 'Media directory path is invalid or parent mount is not writable', + }, + template: templateValidation, error: errors.join('. '), }); } return NextResponse.json({ success: true, - downloadDirValid, - mediaDirValid, + downloadDir: { + valid: downloadDirValid, + }, + mediaDir: { + valid: mediaDirValid, + }, + template: templateValidation, message: 'Directories are ready and writable (created if needed)', }); } catch (error) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 192a891..468ac29 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { AuthProvider } from "@/contexts/AuthContext"; +import { PreferencesProvider } from "@/contexts/PreferencesContext"; import "./globals.css"; const geistSans = Geist({ @@ -50,7 +51,9 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100`} > - {children} + + {children} + diff --git a/src/app/page.tsx b/src/app/page.tsx index 083945b..d1abd9f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,10 +11,13 @@ import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { StickyPagination } from '@/components/ui/StickyPagination'; +import { CardSizeControls } from '@/components/ui/CardSizeControls'; +import { usePreferences } from '@/contexts/PreferencesContext'; export default function HomePage() { const [popularPage, setPopularPage] = useState(1); const [newReleasesPage, setNewReleasesPage] = useState(1); + const { cardSize, setCardSize } = usePreferences(); // Refs for auto-scrolling to section tops const popularSectionRef = useRef(null); @@ -62,6 +65,9 @@ export default function HomePage() {

    Popular Audiobooks

    +
    + +
    @@ -82,6 +88,7 @@ export default function HomePage() { audiobooks={popular} isLoading={loadingPopular} emptyMessage="No popular audiobooks available" + cardSize={cardSize} /> )}
    @@ -97,6 +104,9 @@ export default function HomePage() {

    New Releases

    +
    + +
    @@ -117,6 +127,7 @@ export default function HomePage() { audiobooks={newReleases} isLoading={loadingNewReleases} emptyMessage="No new releases available" + cardSize={cardSize} /> )}
    diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 525a1cd..8852f5c 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -10,11 +10,14 @@ import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; import { useSearch } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { CardSizeControls } from '@/components/ui/CardSizeControls'; +import { usePreferences } from '@/contexts/PreferencesContext'; export default function SearchPage() { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [page, setPage] = useState(1); + const { cardSize, setCardSize } = usePreferences(); // Debounce search query useEffect(() => { @@ -101,18 +104,32 @@ export default function SearchPage() { {/* Results */} {debouncedQuery ? (
    - {/* Results Count */} - {!isLoading && totalResults > 0 && ( -
    - Found {totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''} for "{debouncedQuery}" + {/* Sticky Results Header with Card Size Controls */} +
    +
    +
    +
    +

    + Search Results +

    + {!isLoading && totalResults > 0 && ( + + ({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''}) + + )} +
    + +
    +
    - )} +
    {/* Results Grid */} {/* Load More */} diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index bcba443..8d38a16 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -173,12 +173,21 @@ export function AudiobookCard({ } // Check if book is requested and in progress (non-re-requestable statuses) - const inProgressStatuses = ['pending', 'awaiting_search', 'searching', 'downloading', 'processing', 'downloaded', 'awaiting_import']; + const inProgressStatuses = ['pending', 'awaiting_search', 'searching', 'downloading', 'processing', 'downloaded', 'awaiting_import', 'awaiting_approval', 'denied']; if (audiobook.isRequested && audiobook.requestStatus && inProgressStatuses.includes(audiobook.requestStatus)) { - // Special text for 'downloaded' status (waiting for Plex scan) + // Determine button text based on status let buttonText; + let buttonClass = 'w-full cursor-not-allowed opacity-75'; + if (audiobook.requestStatus === 'downloaded') { buttonText = 'Processing...'; + } else if (audiobook.requestStatus === 'awaiting_approval') { + buttonText = audiobook.requestedByUsername + ? `Pending Approval (${audiobook.requestedByUsername})` + : 'Pending Approval'; + } else if (audiobook.requestStatus === 'denied') { + buttonText = 'Request Denied'; + buttonClass = 'w-full cursor-not-allowed opacity-75 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30'; } else { buttonText = audiobook.requestedByUsername ? `Requested by ${audiobook.requestedByUsername}` @@ -191,7 +200,7 @@ export function AudiobookCard({ disabled={true} variant="primary" size="md" - className="w-full cursor-not-allowed opacity-75" + className={buttonClass} > {buttonText} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index fced29c..d15f626 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -411,16 +411,27 @@ export function AudiobookDetailsModal({ 'processing', 'downloaded', 'awaiting_import', + 'awaiting_approval', + 'denied', ]; if ( isRequested && requestStatus && inProgressStatuses.includes(requestStatus) ) { - // Special text for 'downloaded' status (waiting for Plex scan) + // Determine button text and styling based on status let buttonText; + let buttonClass = 'w-full cursor-not-allowed opacity-75'; + if (requestStatus === 'downloaded') { buttonText = 'Processing...'; + } else if (requestStatus === 'awaiting_approval') { + buttonText = requestedByUsername + ? `Pending Approval (${requestedByUsername})` + : 'Pending Approval'; + } else if (requestStatus === 'denied') { + buttonText = 'Request Denied'; + buttonClass = 'w-full cursor-not-allowed opacity-75 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30'; } else { buttonText = requestedByUsername ? `Requested by ${requestedByUsername}` @@ -434,7 +445,7 @@ export function AudiobookDetailsModal({ disabled={true} variant="primary" size="lg" - className="w-full cursor-not-allowed opacity-75" + className={buttonClass} > {buttonText} diff --git a/src/components/audiobooks/AudiobookGrid.tsx b/src/components/audiobooks/AudiobookGrid.tsx index 245435e..2599ebb 100644 --- a/src/components/audiobooks/AudiobookGrid.tsx +++ b/src/components/audiobooks/AudiobookGrid.tsx @@ -14,6 +14,25 @@ interface AudiobookGridProps { isLoading?: boolean; emptyMessage?: string; onRequestSuccess?: () => void; + cardSize?: number; // 1-9, default 5 +} + +// Helper function to get grid classes based on card size +// IMPORTANT: Classes must be explicit strings (not template literals) for Tailwind purging +function getGridClasses(size: number): string { + const sizeMap: Record = { + 1: 'grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10', // Smallest + 2: 'grid-cols-3 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9', + 3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8', + 4: 'grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7', + 5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5', // Default + 6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4', + 7: 'grid-cols-2 md:grid-cols-3', + 8: 'grid-cols-2', + 9: 'grid-cols-1', // Largest + }; + + return sizeMap[size] || sizeMap[5]; } export function AudiobookGrid({ @@ -21,10 +40,13 @@ export function AudiobookGrid({ isLoading = false, emptyMessage = 'No audiobooks found', onRequestSuccess, + cardSize = 5, }: AudiobookGridProps) { + const gridClasses = getGridClasses(cardSize); + if (isLoading) { return ( -
    +
    {Array.from({ length: 8 }).map((_, i) => ( ))} @@ -54,7 +76,7 @@ export function AudiobookGrid({ } return ( -
    +
    {audiobooks.map((audiobook) => ( void; +} + +// Column count mapping for each size at each breakpoint +const columnMap = { + base: { 1: 4, 2: 3, 3: 3, 4: 2, 5: 2, 6: 2, 7: 2, 8: 2, 9: 1 }, + md: { 1: 6, 2: 5, 3: 4, 4: 4, 5: 3, 6: 3, 7: 3, 8: 2, 9: 1 }, + lg: { 1: 8, 2: 7, 3: 6, 4: 5, 5: 4, 6: 4, 7: 3, 8: 2, 9: 1 }, + xl: { 1: 10, 2: 9, 3: 8, 4: 7, 5: 5, 6: 4, 7: 3, 8: 2, 9: 1 }, +}; + +// Get current breakpoint based on window width +function getCurrentBreakpoint(): 'base' | 'md' | 'lg' | 'xl' { + if (typeof window === 'undefined') return 'base'; + const width = window.innerWidth; + if (width >= 1280) return 'xl'; + if (width >= 1024) return 'lg'; + if (width >= 768) return 'md'; + return 'base'; +} + +// Get column count for a size at current breakpoint +function getColumnCount(size: number, breakpoint: 'base' | 'md' | 'lg' | 'xl'): number { + return columnMap[breakpoint][size as keyof typeof columnMap.base]; +} + +// Find next size that produces a visible column change +function findNextVisibleSize(currentSize: number, direction: 'in' | 'out'): number { + const breakpoint = getCurrentBreakpoint(); + const currentCols = getColumnCount(currentSize, breakpoint); + + if (direction === 'in') { + // Zoom in: increase size (fewer columns, bigger cards) + for (let size = currentSize + 1; size <= 9; size++) { + const cols = getColumnCount(size, breakpoint); + if (cols < currentCols) { + return size; + } + } + return 9; // Max boundary + } else { + // Zoom out: decrease size (more columns, smaller cards) + for (let size = currentSize - 1; size >= 1; size--) { + const cols = getColumnCount(size, breakpoint); + if (cols > currentCols) { + return size; + } + } + return 1; // Min boundary + } +} + +export function CardSizeControls({ size, onSizeChange }: CardSizeControlsProps) { + const handleZoomOut = () => { + const nextSize = findNextVisibleSize(size, 'out'); + if (nextSize !== size) { + onSizeChange(nextSize); + } + }; + + const handleZoomIn = () => { + const nextSize = findNextVisibleSize(size, 'in'); + if (nextSize !== size) { + onSizeChange(nextSize); + } + }; + + // Check if zoom buttons should be disabled + const canZoomOut = findNextVisibleSize(size, 'out') !== size; + const canZoomIn = findNextVisibleSize(size, 'in') !== size; + + return ( +
    + {/* Zoom Out Button */} + + + {/* Zoom In Button */} + +
    + ); +} diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx new file mode 100644 index 0000000..540068c --- /dev/null +++ b/src/contexts/PreferencesContext.tsx @@ -0,0 +1,108 @@ +/** + * Component: User Preferences Context Provider + * Documentation: Manages user preferences (card size, etc.) with localStorage persistence + */ + +'use client'; + +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +interface Preferences { + cardSize: number; // 1-9, default 5 +} + +interface PreferencesContextType { + cardSize: number; + setCardSize: (size: number) => void; +} + +const PreferencesContext = createContext(undefined); + +const DEFAULT_PREFERENCES: Preferences = { + cardSize: 5, +}; + +const STORAGE_KEY = 'preferences'; + +export function PreferencesProvider({ children }: { children: ReactNode }) { + const [cardSize, setCardSizeState] = useState(DEFAULT_PREFERENCES.cardSize); + + // Load preferences from localStorage on mount + useEffect(() => { + if (typeof window === 'undefined') return; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const preferences: Preferences = JSON.parse(stored); + // Validate cardSize is within range 1-9 + if (preferences.cardSize >= 1 && preferences.cardSize <= 9) { + setCardSizeState(preferences.cardSize); + } else { + // Invalid size, reset to default + setCardSizeState(DEFAULT_PREFERENCES.cardSize); + } + } + } catch (error) { + console.error('Failed to load preferences from localStorage:', error); + setCardSizeState(DEFAULT_PREFERENCES.cardSize); + } + }, []); + + // Update card size in state and localStorage + const setCardSize = (size: number) => { + if (typeof window === 'undefined') return; + + // Validate size is within range 1-9 + const validSize = Math.max(1, Math.min(9, size)); + + setCardSizeState(validSize); + + try { + const stored = localStorage.getItem(STORAGE_KEY); + const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES }; + preferences.cardSize = validSize; + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch (error) { + console.error('Failed to save preferences to localStorage:', error); + } + }; + + // Listen for storage changes in other tabs (cross-tab sync) + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleStorageChange = (e: StorageEvent) => { + if (e.key === STORAGE_KEY && e.newValue) { + try { + const preferences: Preferences = JSON.parse(e.newValue); + // Validate cardSize is within range 1-9 + if (preferences.cardSize >= 1 && preferences.cardSize <= 9) { + setCardSizeState(preferences.cardSize); + } + } catch (error) { + console.error('Failed to parse preferences from storage event:', error); + } + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); + + return ( + + {children} + + ); +} + +export function usePreferences() { + const context = useContext(PreferencesContext); + if (context === undefined) { + throw new Error('usePreferences must be used within a PreferencesProvider'); + } + return context; +} diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index b8fb188..f553050 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -44,10 +44,47 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi logger.info(`Organizing: ${audiobook.title} by ${audiobook.author}`); + // Fetch year from multiple sources (priority order) + let year = audiobook.year || undefined; + logger.info(`Initial year from audiobook record: ${year || 'null'}`); + + if (!year && audiobook.audibleAsin) { + logger.info(`No year in audiobook record, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`); + + // Try AudibleCache (for popular/new releases) + const audibleCache = await prisma.audibleCache.findUnique({ + where: { asin: audiobook.audibleAsin }, + select: { releaseDate: true }, + }); + + if (audibleCache?.releaseDate) { + logger.info(`Found AudibleCache entry with releaseDate: ${audibleCache.releaseDate}`); + year = new Date(audibleCache.releaseDate).getFullYear(); + logger.info(`Extracted year ${year} from AudibleCache releaseDate`); + + // Update audiobook record with year for future use + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: { year }, + }); + logger.info(`Updated audiobook record with year ${year}`); + } else { + logger.info(`No year found in AudibleCache for ASIN ${audiobook.audibleAsin}`); + } + } + + logger.info(`Final year value for path organization: ${year || 'null (year will be omitted from path)'}`) + // Get file organizer (reads media_dir from database config) const organizer = await getFileOrganizer(); - // Organize files (pass logger to file organizer) + // Read path template from configuration + const templateConfig = await prisma.configuration.findUnique({ + where: { key: 'audiobook_path_template' }, + }); + const template = templateConfig?.value || '{author}/{title} {asin}'; + + // Organize files (pass template and logger to file organizer) const result = await organizer.organize( downloadPath, { @@ -56,7 +93,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi narrator: audiobook.narrator || undefined, coverArtUrl: audiobook.coverArtUrl || undefined, asin: audiobook.audibleAsin || undefined, + year, }, + template, jobId ? { jobId, context: 'FileOrganizer' } : undefined ); diff --git a/src/lib/services/audiobookshelf/api.ts b/src/lib/services/audiobookshelf/api.ts index 9ceffaf..7fdea66 100644 --- a/src/lib/services/audiobookshelf/api.ts +++ b/src/lib/services/audiobookshelf/api.ts @@ -152,3 +152,34 @@ export async function triggerABSItemMatch(itemId: string, asin?: string) { logger.error(`Failed to trigger match for item ${itemId}`, { error: error instanceof Error ? error.message : String(error) }); } } + +/** + * Delete a library item from Audiobookshelf + * Note: This only removes the item from Audiobookshelf's database, not the actual files + * + * @param itemId - The Audiobookshelf item ID to delete + */ +export async function deleteABSItem(itemId: string): Promise { + const configService = getConfigService(); + const serverUrl = await configService.get('audiobookshelf.server_url'); + const apiToken = await configService.get('audiobookshelf.api_token'); + + if (!serverUrl || !apiToken) { + throw new Error('Audiobookshelf not configured'); + } + + const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${apiToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`ABS API error: ${response.status} ${response.statusText}`); + } + + logger.info(`Deleted library item ${itemId} from Audiobookshelf`); +} diff --git a/src/lib/services/request-delete.service.ts b/src/lib/services/request-delete.service.ts index dbcc0d5..6fbbd23 100644 --- a/src/lib/services/request-delete.service.ts +++ b/src/lib/services/request-delete.service.ts @@ -9,6 +9,7 @@ import { prisma } from '../db'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RMABLogger } from '../utils/logger'; +import { buildAudiobookPath } from '../utils/file-organizer'; const logger = RMABLogger.create('RequestDelete'); @@ -52,6 +53,7 @@ export async function deleteRequest( id: true, title: true, author: true, + narrator: true, audibleAsin: true, plexGuid: true, absItemId: true, @@ -190,42 +192,34 @@ export async function deleteRequest( const { getConfigService } = await import('./config.service'); const configService = getConfigService(); const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks'; + const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}'; - // Sanitize author and title for path (same logic as file-organizer.ts) - const sanitizedAuthor = sanitizePath(request.audiobook.author); - const sanitizedTitle = sanitizePath(request.audiobook.title); - - // Build folder name with optional year and ASIN (matches file-organizer.ts logic) - let folderName = sanitizedTitle; - - // Get ASIN and check for year in AudibleCache - const asin = request.audiobook.audibleAsin; + // Fetch year from audible cache if ASIN is available let year: number | undefined; - - if (asin) { - // Try to get year from AudibleCache if it exists + if (request.audiobook.audibleAsin) { const audibleCache = await prisma.audibleCache.findUnique({ - where: { asin }, + where: { asin: request.audiobook.audibleAsin }, select: { releaseDate: true }, }); - if (audibleCache?.releaseDate) { year = new Date(audibleCache.releaseDate).getFullYear(); } } - if (year) { - folderName = `${folderName} (${year})`; - } + // Build path using centralized function + const titleFolderPath = buildAudiobookPath( + mediaDir, + template, + { + author: request.audiobook.author, + title: request.audiobook.title, + narrator: request.audiobook.narrator || undefined, + asin: request.audiobook.audibleAsin || undefined, + year, + } + ); - if (asin) { - folderName = `${folderName} ${asin}`; - } - - // Build path: [media_dir]/[author]/[title (year) asin]/ - const titleFolderPath = path.join(mediaDir, sanitizedAuthor, folderName); - - // Check if folder exists + // Check if folder exists and delete it try { await fs.access(titleFolderPath); @@ -235,20 +229,9 @@ export async function deleteRequest( logger.info(`Deleted media directory: ${titleFolderPath}`); filesDeleted = true; } catch (accessError) { - // Folder doesn't exist - try without year/ASIN (fallback for older files) - const fallbackPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle); - try { - await fs.access(fallbackPath); - await fs.rm(fallbackPath, { recursive: true, force: true }); - logger.info(`Deleted media directory (fallback path): ${fallbackPath}`); - filesDeleted = true; - } catch (fallbackError) { - // Neither path exists - that's okay - logger.info( - `Media directory not found (tried: ${titleFolderPath}, ${fallbackPath})` - ); - filesDeleted = false; - } + // Folder doesn't exist - that's okay + logger.info(`Media directory not found: ${titleFolderPath}`); + filesDeleted = false; } } catch (error) { logger.error( @@ -265,6 +248,23 @@ export async function deleteRequest( const configService = getConfigService(); const backendMode = await configService.getBackendMode(); + // If backend is Audiobookshelf, delete the library item from ABS + if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) { + try { + const { deleteABSItem } = await import('../services/audiobookshelf/api'); + await deleteABSItem(request.audiobook.absItemId); + logger.info( + `Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"` + ); + } catch (absError) { + logger.error( + `Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`, + { error: absError instanceof Error ? absError.message : String(absError) } + ); + // Continue with deletion even if ABS deletion fails + } + } + // Delete ALL plex_library records matching this audiobook's title and author // This handles cases where there might be duplicate library records // and ensures the book doesn't show as "In Your Library" during searches @@ -377,21 +377,3 @@ export async function deleteRequest( }; } } - -/** - * Sanitize a path component (removes invalid characters) - */ -function sanitizePath(input: string): string { - return ( - input - // Remove invalid path characters - .replace(/[<>:"/\\|?*]/g, '') - // Trim dots and spaces from start/end - .replace(/^[.\s]+|[.\s]+$/g, '') - // Collapse multiple spaces - .replace(/\s+/g, ' ') - // Limit length - .substring(0, 200) - .trim() - ); -} diff --git a/src/lib/utils/PATH_TEMPLATE_README.md b/src/lib/utils/PATH_TEMPLATE_README.md new file mode 100644 index 0000000..44bc0ce --- /dev/null +++ b/src/lib/utils/PATH_TEMPLATE_README.md @@ -0,0 +1,231 @@ +# Path Template Engine Utility + +Location: `src/lib/utils/path-template.util.ts` + +## Overview + +Provides template variable substitution, validation, and preview generation for audiobook file organization paths. + +## Features + +1. **Template Variable Substitution** - Replace variables with actual values +2. **Template Validation** - Validate template syntax and characters +3. **Mock Preview Generation** - Generate example paths with sample data +4. **Path Sanitization** - Automatic removal of invalid file path characters + +## Supported Variables + +- `{author}` - Audiobook author name +- `{title}` - Audiobook title +- `{narrator}` - Audiobook narrator (optional) +- `{asin}` - Amazon ASIN identifier (optional) + +## API Reference + +### `substituteTemplate(template: string, variables: TemplateVariables): string` + +Substitute template variables with actual values. + +**Features:** +- Handles missing/null variables gracefully (omits them) +- Applies path sanitization to all substituted values +- Removes multiple consecutive spaces +- Normalizes path separators (converts backslashes to forward slashes) + +**Example:** +```typescript +const result = substituteTemplate( + '{author}/{title}', + { author: 'Brandon Sanderson', title: 'Mistborn' } +); +// Returns: "Brandon Sanderson/Mistborn" +``` + +### `validateTemplate(template: string): ValidationResult` + +Validate a path template string. + +**Checks for:** +- Valid variable names only (rejects unknown variables) +- No invalid file path characters outside of variables (`:`, `|`, `<`, `>`, `*`, `?`, `"`) +- Non-empty template +- Relative paths only (no absolute paths) + +**Returns:** +```typescript +interface ValidationResult { + valid: boolean; + error?: string; // Helpful error message if invalid +} +``` + +**Example:** +```typescript +const result = validateTemplate('{author}/{title}'); +// Returns: { valid: true } + +const invalid = validateTemplate('{invalid}/{title}'); +// Returns: { valid: false, error: "Unknown variable: {invalid}. Valid variables are: {author}, {title}, {narrator}, {asin}" } +``` + +### `generateMockPreviews(template: string): string[]` + +Generate 2-3 example paths using mock audiobook data. + +**Mock Examples:** +1. Brandon Sanderson / Mistborn: The Final Empire / Michael Kramer / B002UZMLXM +2. Douglas Adams / The Hitchhiker's Guide to the Galaxy / Stephen Fry / B0009JKV9W +3. Andy Weir / Project Hail Mary / (no narrator) / B08G9PRS1K + +**Example:** +```typescript +const previews = generateMockPreviews('{author}/{title}'); +// Returns: +// [ +// "Brandon Sanderson/Mistborn The Final Empire", +// "Douglas Adams/The Hitchhiker's Guide to the Galaxy", +// "Andy Weir/Project Hail Mary" +// ] +``` + +### `getValidVariables(): string[]` + +Get list of valid template variable names. + +**Example:** +```typescript +const variables = getValidVariables(); +// Returns: ['author', 'title', 'narrator', 'asin'] +``` + +## Usage Examples + +### Basic Template +```typescript +import { substituteTemplate } from '@/lib/utils/path-template.util'; + +const result = substituteTemplate( + '{author}/{title}', + { + author: 'Brandon Sanderson', + title: 'Mistborn: The Final Empire' + } +); +// Result: "Brandon Sanderson/Mistborn The Final Empire" +``` + +### Template with Optional Variables +```typescript +// With narrator +const withNarrator = substituteTemplate( + '{author}/{title}/{narrator}', + { + author: 'Douglas Adams', + title: "The Hitchhiker's Guide to the Galaxy", + narrator: 'Stephen Fry' + } +); +// Result: "Douglas Adams/The Hitchhiker's Guide to the Galaxy/Stephen Fry" + +// Without narrator (gracefully omitted) +const withoutNarrator = substituteTemplate( + '{author}/{title}/{narrator}', + { + author: 'Andy Weir', + title: 'Project Hail Mary' + // No narrator + } +); +// Result: "Andy Weir/Project Hail Mary" +``` + +### Template Validation +```typescript +import { validateTemplate } from '@/lib/utils/path-template.util'; + +// Valid templates +validateTemplate('{author}/{title}'); +// { valid: true } + +validateTemplate('Audiobooks/{author}/{title}'); +// { valid: true } + +// Invalid templates +validateTemplate('{author}/{invalid}'); +// { valid: false, error: "Unknown variable: {invalid}..." } + +validateTemplate('/absolute/path/{author}'); +// { valid: false, error: "Template must be a relative path..." } + +validateTemplate('{author}|{title}'); +// { valid: false, error: "Invalid characters found: |..." } +``` + +### Generate Previews +```typescript +import { generateMockPreviews } from '@/lib/utils/path-template.util'; + +const previews = generateMockPreviews('{author}/{title}/{narrator}'); +// Returns 3 examples, including one without a narrator + +previews.forEach(preview => console.log(preview)); +// Brandon Sanderson/Mistborn The Final Empire/Michael Kramer +// Douglas Adams/The Hitchhiker's Guide to the Galaxy/Stephen Fry +// Andy Weir/Project Hail Mary +``` + +### Automatic Sanitization +```typescript +const result = substituteTemplate( + '{author}/{title}', + { + author: 'Author: ', + title: 'Title|With*Invalid?Chars"' + } +); +// Result: "Author Test/TitleWithInvalidChars" +// Invalid characters automatically removed +``` + +## Path Sanitization Rules + +The utility automatically sanitizes all substituted values: + +1. **Removes invalid characters:** `<`, `>`, `:`, `"`, `/`, `\`, `|`, `?`, `*` +2. **Trims dots and spaces** from beginning and end +3. **Collapses multiple spaces** into single space +4. **Limits length** to 200 characters per component +5. **Normalizes path separators** (converts `\` to `/`) + +## Integration Points + +### File Organizer Service +The path template utility is used by `file-organizer.ts` to generate organized directory structures for downloaded audiobook files. + +### Test Paths API +The utility is also used by the `/api/test-paths` endpoint to allow users to preview how their custom path templates will look before applying them. + +## Testing + +Comprehensive test suite located at: `tests/lib/utils/path-template.util.test.ts` + +Run tests: +```bash +npm test -- path-template +``` + +## Type Definitions + +```typescript +interface TemplateVariables { + author: string; + title: string; + narrator?: string; + asin?: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; +} +``` diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 957648c..18112cc 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -21,6 +21,7 @@ import { } from './chapter-merger'; import { prisma } from '../db'; import { downloadEbook } from '../services/ebook-scraper'; +import { substituteTemplate, type TemplateVariables } from './path-template.util'; export interface AudiobookMetadata { title: string; @@ -66,6 +67,7 @@ export class FileOrganizer { async organize( downloadPath: string, audiobook: AudiobookMetadata, + template: string, loggerConfig?: LoggerConfig ): Promise { // Create logger if config provided @@ -268,10 +270,12 @@ export class FileOrganizer { // Build target directory const targetPath = this.buildTargetPath( this.mediaDir, + template, audiobook.author, audiobook.title, - audiobook.year, - audiobook.asin + audiobook.narrator, + audiobook.asin, + audiobook.year ); await logger?.info(`Target path: ${targetPath}`); @@ -542,31 +546,28 @@ export class FileOrganizer { } /** - * Build target path with sanitized names - * Format: Author/Title (Year) ASIN or Author/Title ASIN or Author/Title (Year) + * Build target path using template-based path building + * Uses the path template engine to substitute variables and sanitize paths */ private buildTargetPath( baseDir: string, + template: string, author: string, title: string, - year?: number, - asin?: string + narrator?: string, + asin?: string, + year?: number ): string { - const authorClean = this.sanitizePath(author); - const titleClean = this.sanitizePath(title); + const variables: TemplateVariables = { + author, + title, + narrator, + asin, + year, + }; - // Build folder name with optional year and ASIN - let folderName = titleClean; - - if (year) { - folderName = `${folderName} (${year})`; - } - - if (asin) { - folderName = `${folderName} ${asin}`; - } - - return path.join(baseDir, authorClean, folderName); + const relativePath = substituteTemplate(template, variables); + return path.join(baseDir, relativePath); } /** @@ -689,3 +690,39 @@ export async function getFileOrganizer(): Promise { return new FileOrganizer(mediaDir, tempDir); } + +/** + * Build audiobook path using template-based path building + * Standalone function for use by other modules (e.g., fetch-ebook route, request-delete service) + * + * @param baseDir - Base directory for audiobooks (e.g., /media/audiobooks) + * @param template - Path template string (e.g., "{author}/{title} {asin}") + * @param variables - Object containing variable values (author, title, narrator, asin) + * @returns Full path to audiobook directory + * + * @example + * ```typescript + * const path = buildAudiobookPath( + * '/media/audiobooks', + * '{author}/{title} {asin}', + * { author: 'Brandon Sanderson', title: 'Mistborn', asin: 'B002UZMLXM' } + * ); + * // Returns: "/media/audiobooks/Brandon Sanderson/Mistborn B002UZMLXM" + * ``` + */ +export function buildAudiobookPath( + baseDir: string, + template: string, + variables: { author: string; title: string; narrator?: string; asin?: string; year?: number } +): string { + const templateVars: TemplateVariables = { + author: variables.author, + title: variables.title, + narrator: variables.narrator, + asin: variables.asin, + year: variables.year, + }; + + const relativePath = substituteTemplate(template, templateVars); + return path.join(baseDir, relativePath); +} diff --git a/src/lib/utils/path-template.util.ts b/src/lib/utils/path-template.util.ts new file mode 100644 index 0000000..23f8517 --- /dev/null +++ b/src/lib/utils/path-template.util.ts @@ -0,0 +1,259 @@ +/** + * Path Template Engine Utility + * Documentation: documentation/backend/services/file-organizer.md + * + * Provides template variable substitution, validation, and preview generation + * for audiobook file organization paths. + */ + +/** + * Template variables for path substitution + */ +export interface TemplateVariables { + author: string; + title: string; + narrator?: string; + asin?: string; + year?: number; +} + +/** + * Template validation result + */ +export interface ValidationResult { + valid: boolean; + error?: string; +} + +/** + * Supported template variable names + */ +const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year']; + +/** + * Invalid file path characters (outside of template variables) + */ +const INVALID_PATH_CHARS = /[<>:"|?*]/; + +/** + * Sanitize a path component by removing invalid characters + * Reuses logic from file-organizer.ts + * + * @param name - Path component to sanitize + * @returns Sanitized path component + */ +function sanitizePath(name: string): string { + return ( + name + // Remove invalid filename characters + .replace(/[<>:"/\\|?*]/g, '') + // Remove leading/trailing dots and spaces + .trim() + .replace(/^\.+/, '') + .replace(/\.+$/, '') + // Collapse multiple spaces + .replace(/\s+/g, ' ') + // Limit length (255 chars max for most filesystems) + .slice(0, 200) + ); +} + +/** + * Substitute template variables with actual values + * + * Supported variables: {author}, {title}, {narrator}, {asin} + * - Handles missing/null variables gracefully (omits them) + * - Applies path sanitization to all substituted values + * - Removes multiple consecutive spaces after substitution + * + * @param template - Path template string (e.g., "{author}/{title}") + * @param variables - Object containing variable values + * @returns Substituted and sanitized path string + * + * @example + * ```typescript + * const result = substituteTemplate( + * "{author}/{title}", + * { author: "Brandon Sanderson", title: "Mistborn" } + * ); + * // Returns: "Brandon Sanderson/Mistborn" + * ``` + */ +export function substituteTemplate( + template: string, + variables: TemplateVariables +): string { + let result = template; + + // Substitute each variable + for (const key of VALID_VARIABLES) { + const value = variables[key as keyof TemplateVariables]; + const regex = new RegExp(`\\{${key}\\}`, 'g'); + + if (value !== undefined && value !== null) { + // Convert value to string and sanitize + const stringValue = String(value); + if (stringValue.trim()) { + const sanitizedValue = sanitizePath(stringValue.trim()); + result = result.replace(regex, sanitizedValue); + } else { + // Remove the variable placeholder if value is empty + result = result.replace(regex, ''); + } + } else { + // Remove the variable placeholder if value is missing + result = result.replace(regex, ''); + } + } + + // Clean up the result + result = result + // Remove multiple consecutive slashes (forward or backward) + .replace(/[\/\\]+/g, '/') + // Remove multiple consecutive spaces + .replace(/\s+/g, ' ') + // Remove leading/trailing slashes and spaces from each path component + .split('/') + .map(part => part.trim()) + .filter(part => part.length > 0) + .join('/'); + + return result; +} + +/** + * Validate a path template string + * + * Checks for: + * - Valid variable names only (rejects unknown variables) + * - No invalid file path characters outside of variables + * - Non-empty template + * - Relative paths only (no absolute paths) + * + * @param template - Path template string to validate + * @returns Validation result with error message if invalid + * + * @example + * ```typescript + * const result = validateTemplate("{author}/{title}"); + * // Returns: { valid: true } + * + * const invalid = validateTemplate("{invalid}/{title}"); + * // Returns: { valid: false, error: "Unknown variable: {invalid}" } + * ``` + */ +export function validateTemplate(template: string): ValidationResult { + // Check for empty template + if (!template || template.trim().length === 0) { + return { + valid: false, + error: 'Template cannot be empty' + }; + } + + // Check for absolute paths + if (template.startsWith('/') || template.startsWith('\\') || /^[a-zA-Z]:/.test(template)) { + return { + valid: false, + error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")' + }; + } + + // Extract all variables from template + const variableMatches = template.match(/\{[^}]+\}/g); + + if (variableMatches) { + for (const match of variableMatches) { + const varName = match.slice(1, -1); // Remove { and } + + if (!VALID_VARIABLES.includes(varName)) { + return { + valid: false, + error: `Unknown variable: {${varName}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}` + }; + } + } + } + + // Remove valid variables temporarily to check for invalid characters + let templateWithoutVars = template; + for (const varName of VALID_VARIABLES) { + templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), ''); + } + + // Check for invalid characters outside of variables + const invalidChars = templateWithoutVars.match(INVALID_PATH_CHARS); + if (invalidChars) { + return { + valid: false, + error: `Invalid characters found: ${[...new Set(invalidChars)].join(', ')}. These characters are not allowed in path templates.` + }; + } + + // Check for backslashes (Windows-style paths) + if (templateWithoutVars.includes('\\')) { + return { + valid: false, + error: 'Use forward slashes (/) for path separators, not backslashes (\\)' + }; + } + + return { valid: true }; +} + +/** + * Generate mock preview paths using sample audiobook data + * + * Creates 2-3 example paths to demonstrate how the template will look + * with real audiobook metadata. + * + * @param template - Path template string + * @returns Array of example paths (2-3 examples) + * + * @example + * ```typescript + * const previews = generateMockPreviews("{author}/{title}"); + * // Returns: + * // [ + * // "Brandon Sanderson/Mistborn The Final Empire", + * // "Douglas Adams/The Hitchhiker's Guide to the Galaxy", + * // "Andy Weir/Project Hail Mary" + * // ] + * ``` + */ +export function generateMockPreviews(template: string): string[] { + const mockData: TemplateVariables[] = [ + { + author: 'Brandon Sanderson', + title: 'Mistborn: The Final Empire', + narrator: 'Michael Kramer', + asin: 'B002UZMLXM', + year: 2006 + }, + { + author: 'Douglas Adams', + title: "The Hitchhiker's Guide to the Galaxy", + narrator: 'Stephen Fry', + asin: 'B0009JKV9W', + year: 2005 + }, + { + author: 'Andy Weir', + title: 'Project Hail Mary', + // No narrator for this example + asin: 'B08G9PRS1K', + year: 2021 + } + ]; + + return mockData.map(variables => substituteTemplate(template, variables)); +} + +/** + * Get list of valid template variable names + * + * @returns Array of valid variable names + */ +export function getValidVariables(): string[] { + return [...VALID_VARIABLES]; +} diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts index b74b102..7006521 100644 --- a/tests/api/admin-settings-core.routes.test.ts +++ b/tests/api/admin-settings-core.routes.test.ts @@ -147,6 +147,66 @@ describe('Admin settings core routes', () => { expect(invalidateQbMock).toHaveBeenCalled(); }); + it('updates paths settings with custom audiobook path template', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + downloadDir: '/downloads', + mediaDir: '/media', + audiobookPathTemplate: '{author}/{title} - {narrator}', + metadataTaggingEnabled: true, + chapterMergingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/paths/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(prismaMock.configuration.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: 'audiobook_path_template' }, + update: { value: '{author}/{title} - {narrator}' }, + }) + ); + }); + + it('rejects paths settings when directories are the same', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + downloadDir: '/same', + mediaDir: '/same', + metadataTaggingEnabled: true, + chapterMergingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/paths/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toContain('must be different'); + }); + + it('rejects paths settings when directories are missing', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + downloadDir: '', + mediaDir: '/media', + metadataTaggingEnabled: true, + chapterMergingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/paths/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toContain('required'); + }); + it('updates Prowlarr settings', async () => { const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) }; diff --git a/tests/api/admin-users.routes.test.ts b/tests/api/admin-users.routes.test.ts index 9a81c01..a884044 100644 --- a/tests/api/admin-users.routes.test.ts +++ b/tests/api/admin-users.routes.test.ts @@ -55,6 +55,7 @@ describe('Admin users routes', () => { authProvider: 'local', plexUsername: 'user', deletedAt: null, + role: 'user', }); prismaMock.user.update.mockResolvedValueOnce({ id: 'u3', plexUsername: 'user', role: 'admin' }); const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) }; @@ -66,6 +67,72 @@ describe('Admin users routes', () => { expect(payload.user.role).toBe('admin'); }); + it('allows autoApproveRequests update for OIDC users without role change', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: false, + authProvider: 'oidc', + plexUsername: 'oidc-user', + deletedAt: null, + role: 'user', + }); + prismaMock.user.update.mockResolvedValueOnce({ + id: 'oidc-1', + plexUsername: 'oidc-user', + role: 'user', + autoApproveRequests: true, + }); + const request = { json: vi.fn().mockResolvedValue({ role: 'user', autoApproveRequests: true }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'oidc-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.user.autoApproveRequests).toBe(true); + }); + + it('prevents OIDC user role change', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: false, + authProvider: 'oidc', + plexUsername: 'oidc-user', + deletedAt: null, + role: 'user', + }); + const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: true }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'oidc-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toContain('OIDC'); + }); + + it('allows autoApproveRequests update for setup admin without role change', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: true, + authProvider: 'local', + plexUsername: 'setup-admin', + deletedAt: null, + role: 'admin', + }); + prismaMock.user.update.mockResolvedValueOnce({ + id: 'setup-1', + plexUsername: 'setup-admin', + role: 'admin', + autoApproveRequests: true, + }); + const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: true }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'setup-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.user.autoApproveRequests).toBe(true); + }); + it('soft deletes a local user', async () => { prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'u4', diff --git a/tests/api/bookdate.routes.test.ts b/tests/api/bookdate.routes.test.ts index e845d74..3305fec 100644 --- a/tests/api/bookdate.routes.test.ts +++ b/tests/api/bookdate.routes.test.ts @@ -19,7 +19,7 @@ const configServiceMock = vi.hoisted(() => ({ getBackendMode: vi.fn(), })); const jobQueueMock = vi.hoisted(() => ({ - addSearchJob: vi.fn(), + addSearchJob: vi.fn().mockResolvedValue(undefined), })); const bookdateHelpersMock = vi.hoisted(() => ({ buildAIPrompt: vi.fn(), diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index 73733cc..2efcb34 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -185,6 +185,7 @@ describe('Request action routes', () => { it('downloads ebook and returns success', async () => { configState.values.set('ebook_sidecar_enabled', 'true'); configState.values.set('media_dir', '/media/audiobooks'); + configState.values.set('audiobook_path_template', '{author}/{title} {asin}'); configState.values.set('ebook_sidecar_preferred_format', 'epub'); configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example'); configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr'); @@ -199,7 +200,7 @@ describe('Request action routes', () => { downloadEbookMock.mockResolvedValueOnce({ success: true, format: 'epub', - filePath: '/media/audiobooks/Author/Title (2022) ASIN123/Title.epub', + filePath: '/media/audiobooks/Author/Title ASIN123/Title.epub', }); const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); @@ -211,7 +212,7 @@ describe('Request action routes', () => { 'ASIN123', 'Title', 'Author', - expect.stringContaining('Title (2022) ASIN123'), + expect.stringContaining('Title ASIN123'), 'epub', 'https://ebooks.example', undefined, diff --git a/tests/api/requests-approval.routes.test.ts b/tests/api/requests-approval.routes.test.ts new file mode 100644 index 0000000..8dafe23 --- /dev/null +++ b/tests/api/requests-approval.routes.test.ts @@ -0,0 +1,823 @@ +/** + * Component: Request Approval API Route Tests + * Documentation: documentation/admin-features/request-approval.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSearchJob: vi.fn(), +})); +const findPlexMatchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: findPlexMatchMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => ({ + getAudiobookDetails: vi.fn().mockResolvedValue(null), + }), +})); + +describe('Request Approval Workflow', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', sub: 'user-1', role: 'user' }, + nextUrl: new URL('http://localhost/api/requests'), + json: vi.fn(), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + describe('1. Request Creation with Approval Logic', () => { + beforeEach(() => { + // Setup common mocks for request creation + prismaMock.request.findFirst.mockResolvedValue(null); + findPlexMatchMock.mockResolvedValue(null); + prismaMock.audiobook.findFirst.mockResolvedValue(null); + prismaMock.audiobook.create.mockResolvedValue({ + id: 'ab-1', + title: 'Test Book', + author: 'Test Author', + audibleAsin: 'ASIN-1', + }); + }); + + it('Admin user creates request → should auto-approve (status: pending)', async () => { + authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' }; + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-1', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'admin-1', + role: 'admin', + autoApproveRequests: null, + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-1', + status: 'pending', + userId: 'admin-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' }, + user: { id: 'admin-1', plexUsername: 'admin' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'pending' }), + }) + ); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-1', expect.any(Object)); + }); + + it('User with autoApproveRequests=true → should auto-approve (status: pending)', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-2', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: true, + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-2', + status: 'pending', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-2' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'pending' }), + }) + ); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-2', expect.any(Object)); + }); + + it('User with autoApproveRequests=false → should require approval (status: awaiting_approval)', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-3', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: false, + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-3', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-3' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'awaiting_approval' }), + }) + ); + }); + + it('User with autoApproveRequests=null + global=true → should auto-approve (status: pending)', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-4', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: null, + } as any); + + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'auto_approve_requests', + value: 'true', + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-4', + status: 'pending', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-4' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'pending' }), + }) + ); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-4', expect.any(Object)); + }); + + it('User with autoApproveRequests=null + global=false → should require approval (status: awaiting_approval)', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-5', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: null, + } as any); + + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'auto_approve_requests', + value: 'false', + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-5', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-5' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'awaiting_approval' }), + }) + ); + }); + + it('Request requiring approval should NOT trigger search job', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-6', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: false, + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-6', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-6' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + await POST({} as any); + + expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); + }); + + it('Auto-approved request SHOULD trigger search job', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-7', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.audiobook.create.mockResolvedValueOnce({ + id: 'ab-7', + title: 'Test Book', + author: 'Test Author', + audibleAsin: 'ASIN-7', + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: true, + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-7', + status: 'pending', + userId: 'user-1', + audiobook: { id: 'ab-7', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-7' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + await POST({} as any); + + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-7', { + id: 'ab-7', + title: 'Test Book', + author: 'Test Author', + asin: 'ASIN-7', + }); + }); + + it('Request with skipAutoSearch=true should have status awaiting_search and not trigger job', async () => { + authRequest.nextUrl = new URL('http://localhost/api/requests?skipAutoSearch=true'); + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-8', title: 'Test Book', author: 'Test Author' }, + }); + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'user', + autoApproveRequests: true, + } as any); + + prismaMock.request.create.mockResolvedValue({ + id: 'req-8', + status: 'awaiting_search', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-8' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/requests/route'); + await POST({} as any); + + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'awaiting_search' }), + }) + ); + expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); + }); + }); + + describe('2. Global Auto-Approve Settings API', () => { + beforeEach(() => { + authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' }; + }); + + it('GET /api/admin/settings/auto-approve returns current setting', async () => { + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'auto_approve_requests', + value: 'true', + } as any); + + const { GET } = await import('@/app/api/admin/settings/auto-approve/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.autoApproveRequests).toBe(true); + expect(prismaMock.configuration.findUnique).toHaveBeenCalledWith({ + where: { key: 'auto_approve_requests' }, + }); + }); + + it('PATCH /api/admin/settings/auto-approve updates setting', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ autoApproveRequests: false }), + }; + + prismaMock.configuration.upsert.mockResolvedValue({ + key: 'auto_approve_requests', + value: 'false', + } as any); + + const { PATCH } = await import('@/app/api/admin/settings/auto-approve/route'); + const response = await PATCH(mockRequest as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.autoApproveRequests).toBe(false); + expect(prismaMock.configuration.upsert).toHaveBeenCalledWith({ + where: { key: 'auto_approve_requests' }, + create: { + key: 'auto_approve_requests', + value: 'false', + }, + update: { + value: 'false', + }, + }); + }); + + it('Non-admin user cannot access endpoint (403)', async () => { + authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' }; + requireAdminMock.mockImplementation((_req: any, _handler: any) => { + return Promise.resolve( + new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) + ); + }); + + const { GET } = await import('@/app/api/admin/settings/auto-approve/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBeDefined(); + }); + + it('Missing/invalid values handled properly', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ autoApproveRequests: 'invalid' }), + }; + + const { PATCH } = await import('@/app/api/admin/settings/auto-approve/route'); + const response = await PATCH(mockRequest as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toContain('must be a boolean'); + }); + }); + + describe('3. Per-User Auto-Approve Settings', () => { + beforeEach(() => { + authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' }; + }); + + it('PUT /api/admin/users/[id] can update autoApproveRequests', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ + role: 'user', + autoApproveRequests: true, + }), + }; + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + isSetupAdmin: false, + authProvider: 'plex', + plexUsername: 'testuser', + deletedAt: null, + } as any); + + prismaMock.user.update.mockResolvedValue({ + id: 'user-1', + plexUsername: 'testuser', + role: 'user', + autoApproveRequests: true, + } as any); + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(mockRequest as any, { params: Promise.resolve({ id: 'user-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.user.autoApproveRequests).toBe(true); + expect(prismaMock.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { role: 'user', autoApproveRequests: true }, + select: { + id: true, + plexUsername: true, + role: true, + autoApproveRequests: true, + }, + }); + }); + + it('Cannot set admin user autoApproveRequests to false (validation error)', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ + role: 'admin', + autoApproveRequests: false, + }), + }; + + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-2', + isSetupAdmin: false, + authProvider: 'plex', + plexUsername: 'adminuser', + deletedAt: null, + } as any); + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(mockRequest as any, { params: Promise.resolve({ id: 'user-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toContain('Admins must always auto-approve'); + }); + + it('Non-admin user cannot update user settings (403)', async () => { + authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' }; + requireAdminMock.mockImplementation((_req: any, _handler: any) => { + return Promise.resolve( + new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) + ); + }); + + const mockRequest = { + json: vi.fn().mockResolvedValue({ + role: 'user', + autoApproveRequests: true, + }), + }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(mockRequest as any, { params: Promise.resolve({ id: 'user-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBeDefined(); + }); + }); + + describe('4. Request Approval API', () => { + beforeEach(() => { + authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' }; + }); + + it('POST /api/admin/requests/[id]/approve with action=approve changes status to pending and triggers search job', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ action: 'approve' }), + }; + + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-1', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + prismaMock.request.update.mockResolvedValue({ + id: 'req-1', + status: 'pending', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.message).toContain('approved'); + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-1' }, + data: { status: 'pending' }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + }, + }, + }, + }); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-1', { + id: 'ab-1', + title: 'Test Book', + author: 'Test Author', + asin: 'ASIN-1', + }); + }); + + it('POST /api/admin/requests/[id]/approve with action=deny changes status to denied and does NOT trigger search job', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ action: 'deny' }), + }; + + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-2', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2', audibleAsin: 'ASIN-2' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + prismaMock.request.update.mockResolvedValue({ + id: 'req-2', + status: 'denied', + userId: 'user-1', + audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2', audibleAsin: 'ASIN-2' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.message).toContain('denied'); + expect(prismaMock.request.update).toHaveBeenCalledWith({ + where: { id: 'req-2' }, + data: { status: 'denied' }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + }, + }, + }, + }); + expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); + }); + + it('Cannot approve request that is not in awaiting_approval status (400)', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ action: 'approve' }), + }; + + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-3', + status: 'pending', + userId: 'user-1', + audiobook: { id: 'ab-3', title: 'Test Book 3', author: 'Test Author 3', audibleAsin: 'ASIN-3' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('InvalidStatus'); + expect(payload.message).toContain('not awaiting approval'); + expect(payload.currentStatus).toBe('pending'); + }); + + it('Cannot approve non-existent request (404)', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ action: 'approve' }), + }; + + prismaMock.request.findUnique.mockResolvedValue(null); + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'non-existent' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + expect(payload.message).toContain('not found'); + }); + + it('Non-admin user cannot approve requests (403)', async () => { + authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' }; + requireAdminMock.mockImplementation((_req: any, _handler: any) => { + return Promise.resolve( + new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) + ); + }); + + const mockRequest = { + json: vi.fn().mockResolvedValue({ action: 'approve' }), + }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBeDefined(); + }); + + it('Missing action parameter returns error (400)', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({}), + }; + + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-4', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-4', title: 'Test Book 4', author: 'Test Author 4', audibleAsin: 'ASIN-4' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + expect(payload.message).toContain('approve'); + }); + + it('Invalid action parameter returns error (400)', async () => { + const mockRequest = { + json: vi.fn().mockResolvedValue({ action: 'invalid' }), + }; + + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-5', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-5', title: 'Test Book 5', author: 'Test Author 5', audibleAsin: 'ASIN-5' }, + user: { id: 'user-1', plexUsername: 'testuser' }, + } as any); + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(mockRequest as any, { params: Promise.resolve({ id: 'req-5' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + expect(payload.message).toContain('approve'); + }); + }); + + describe('5. Pending Approval Requests API', () => { + beforeEach(() => { + authRequest.user = { id: 'admin-1', sub: 'admin-1', role: 'admin' }; + }); + + it('GET /api/admin/requests/pending-approval returns only awaiting_approval requests', async () => { + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { id: 'ab-1', title: 'Test Book 1', author: 'Test Author 1' }, + user: { id: 'user-1', plexUsername: 'user1', avatarUrl: null }, + createdAt: new Date(), + }, + { + id: 'req-2', + status: 'awaiting_approval', + userId: 'user-2', + audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2' }, + user: { id: 'user-2', plexUsername: 'user2', avatarUrl: null }, + createdAt: new Date(), + }, + ] as any); + + const { GET } = await import('@/app/api/admin/requests/pending-approval/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.requests).toHaveLength(2); + expect(payload.count).toBe(2); + expect(prismaMock.request.findMany).toHaveBeenCalledWith({ + where: { + status: 'awaiting_approval', + deletedAt: null, + }, + include: { + audiobook: true, + user: { + select: { + id: true, + plexUsername: true, + avatarUrl: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('Returns requests with audiobook and user details', async () => { + const mockDate = new Date('2024-01-01'); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + status: 'awaiting_approval', + userId: 'user-1', + audiobook: { + id: 'ab-1', + title: 'Test Book', + author: 'Test Author', + audibleAsin: 'ASIN-1', + coverArtUrl: 'https://example.com/cover.jpg', + }, + user: { + id: 'user-1', + plexUsername: 'testuser', + avatarUrl: 'https://example.com/avatar.jpg', + }, + createdAt: mockDate, + }, + ] as any); + + const { GET } = await import('@/app/api/admin/requests/pending-approval/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.requests[0]).toMatchObject({ + id: 'req-1', + status: 'awaiting_approval', + audiobook: { + id: 'ab-1', + title: 'Test Book', + author: 'Test Author', + }, + user: { + id: 'user-1', + plexUsername: 'testuser', + }, + }); + }); + + it('Non-admin user cannot access endpoint (403)', async () => { + authRequest.user = { id: 'user-1', sub: 'user-1', role: 'user' }; + requireAdminMock.mockImplementation((_req: any, _handler: any) => { + return Promise.resolve( + new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) + ); + }); + + const { GET } = await import('@/app/api/admin/requests/pending-approval/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBeDefined(); + }); + }); +}); diff --git a/tests/api/requests.route.test.ts b/tests/api/requests.route.test.ts index 159e02b..7774cb8 100644 --- a/tests/api/requests.route.test.ts +++ b/tests/api/requests.route.test.ts @@ -8,10 +8,16 @@ import { createPrismaMock } from '../helpers/prisma'; let authRequest: any; -const prismaMock = createPrismaMock(); -const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn() })); -const findPlexMatchMock = vi.hoisted(() => vi.fn()); const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addSearchJob: vi.fn(), +})); +const findPlexMatchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -25,10 +31,6 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({ findPlexMatch: findPlexMatchMock, })); -vi.mock('@/lib/middleware/auth', () => ({ - requireAuth: requireAuthMock, -})); - describe('Requests API routes', () => { beforeEach(() => { vi.clearAllMocks(); @@ -89,11 +91,36 @@ describe('Requests API routes', () => { audibleAsin: 'ASIN-3', }); prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'user', + autoApproveRequests: true, // Auto-approve enabled for this user + plexId: 'user-1', + plexUsername: 'testuser', + plexEmail: null, + isSetupAdmin: false, + avatarUrl: null, + authToken: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + plexHomeUserId: null, + authProvider: 'plex', + oidcSubject: null, + oidcProvider: null, + registrationStatus: 'approved', + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + bookDateOnboardingComplete: false, + deletedAt: null, + deletedBy: null, + } as any); prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2', + status: 'pending', audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN-3' }, user: { id: 'user-1', plexUsername: 'user' }, - }); + } as any); const { POST } = await import('@/app/api/requests/route'); const response = await POST({} as any); @@ -124,6 +151,30 @@ describe('Requests API routes', () => { audibleAsin: 'ASIN-4', }); prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'user', + autoApproveRequests: true, // Auto-approve enabled for this user + plexId: 'user-1', + plexUsername: 'testuser', + plexEmail: null, + isSetupAdmin: false, + avatarUrl: null, + authToken: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + plexHomeUserId: null, + authProvider: 'plex', + oidcSubject: null, + oidcProvider: null, + registrationStatus: 'approved', + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + bookDateOnboardingComplete: false, + deletedAt: null, + deletedBy: null, + } as any); prismaMock.request.create.mockResolvedValueOnce({ id: 'req-3', audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-4' }, diff --git a/tests/api/setup-tests.routes.test.ts b/tests/api/setup-tests.routes.test.ts index 0fdaef6..1dd6dac 100644 --- a/tests/api/setup-tests.routes.test.ts +++ b/tests/api/setup-tests.routes.test.ts @@ -178,7 +178,89 @@ describe('Setup test routes', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(payload.downloadDirValid).toBe(true); + expect(payload.downloadDir.valid).toBe(true); + }); + + it('validates path template when provided', async () => { + fsMock.access.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + downloadDir: '/downloads', + mediaDir: '/media', + audiobookPathTemplate: '{author}/{title} ({year})', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.template).toBeDefined(); + expect(payload.template.isValid).toBe(true); + expect(payload.template.previewPaths).toHaveLength(3); + }); + + it('returns error for invalid path template', async () => { + fsMock.access.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + downloadDir: '/downloads', + mediaDir: '/media', + audiobookPathTemplate: '{author}/{invalid_var}', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.template).toBeDefined(); + expect(payload.template.isValid).toBe(false); + expect(payload.template.error).toContain('Unknown variable'); + expect(payload.template.previewPaths).toBeUndefined(); + }); + + it('returns error when paths validation fails', async () => { + fsMock.access.mockRejectedValue(new Error('missing')); + fsMock.mkdir.mockRejectedValue(new Error('no permissions')); + + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + downloadDir: '/bad/downloads', + mediaDir: '/bad/media', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(false); + expect(payload.downloadDir.valid).toBe(false); + expect(payload.mediaDir.valid).toBe(false); + expect(payload.error).toBeDefined(); + }); + + it('validates template with absolute path and returns error', async () => { + fsMock.access.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + downloadDir: '/downloads', + mediaDir: '/media', + audiobookPathTemplate: '/absolute/{author}/{title}', + }), + } as any); + const payload = await response.json(); + + expect(payload.template).toBeDefined(); + expect(payload.template.isValid).toBe(false); + expect(payload.template.error).toContain('absolute'); }); it('tests Audiobookshelf connection with saved token', async () => { diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index d61e16a..3c9a4e8 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -23,11 +23,11 @@ const createModelMock = (): PrismaModelMock => ({ findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(() => Promise.resolve({})), - update: vi.fn(), - updateMany: vi.fn(), - upsert: vi.fn(), - delete: vi.fn(), - deleteMany: vi.fn(), + update: vi.fn(() => Promise.resolve({})), + updateMany: vi.fn(() => Promise.resolve({})), + upsert: vi.fn(() => Promise.resolve({})), + delete: vi.fn(() => Promise.resolve({})), + deleteMany: vi.fn(() => Promise.resolve({})), count: vi.fn(), }); diff --git a/tests/lib/utils/path-template.util.test.ts b/tests/lib/utils/path-template.util.test.ts new file mode 100644 index 0000000..49fd99f --- /dev/null +++ b/tests/lib/utils/path-template.util.test.ts @@ -0,0 +1,329 @@ +/** + * Tests for Path Template Engine Utility + */ + +import { describe, it, expect } from 'vitest'; +import { + substituteTemplate, + validateTemplate, + generateMockPreviews, + getValidVariables, + type TemplateVariables +} from '@/lib/utils/path-template.util'; + +describe('substituteTemplate', () => { + it('should substitute all valid variables', () => { + const template = '{author}/{title}/{narrator}/{asin}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn', + narrator: 'Michael Kramer', + asin: 'B002UZMLXM' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/Mistborn/Michael Kramer/B002UZMLXM'); + }); + + it('should handle missing optional variables gracefully', () => { + const template = '{author}/{title}/{narrator}'; + const variables: TemplateVariables = { + author: 'Andy Weir', + title: 'Project Hail Mary' + // narrator is missing + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Andy Weir/Project Hail Mary'); + }); + + it('should sanitize invalid characters in values', () => { + const template = '{author}/{title}'; + const variables: TemplateVariables = { + author: 'Author: ', + title: 'Title|With*Invalid?Chars"' + }; + + const result = substituteTemplate(template, variables); + expect(result).not.toContain('<'); + expect(result).not.toContain('>'); + expect(result).not.toContain(':'); + expect(result).not.toContain('|'); + expect(result).not.toContain('*'); + expect(result).not.toContain('?'); + expect(result).not.toContain('"'); + }); + + it('should remove multiple consecutive spaces', () => { + const template = '{author}/{title}'; + const variables: TemplateVariables = { + author: 'Author With Spaces', + title: 'Title With Spaces' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author With Spaces/Title With Spaces'); + }); + + it('should handle empty string values', () => { + const template = '{author}/{title}/{narrator}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title', + narrator: '' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/Title'); + }); + + it('should remove leading and trailing slashes', () => { + const template = '/{author}/{title}/'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/Title'); + }); + + it('should collapse multiple consecutive slashes', () => { + const template = '{author}//{title}///{narrator}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title', + narrator: 'Narrator' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/Title/Narrator'); + }); + + it('should handle mixed forward and backward slashes', () => { + const template = '{author}\\{title}/{narrator}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title', + narrator: 'Narrator' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/Title/Narrator'); + }); + + it('should trim dots from path components', () => { + const template = '{author}/{title}'; + const variables: TemplateVariables = { + author: '...Author...', + title: '..Title..' + }; + + const result = substituteTemplate(template, variables); + expect(result.startsWith('.')).toBe(false); + expect(result.endsWith('.')).toBe(false); + }); + + it('should limit path component length', () => { + const template = '{title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'A'.repeat(300) // Very long title + }; + + const result = substituteTemplate(template, variables); + expect(result.length).toBeLessThanOrEqual(200); + }); + + it('should handle static text in template', () => { + const template = 'Audiobooks/{author}/Books/{title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Audiobooks/Author/Books/Title'); + }); +}); + +describe('validateTemplate', () => { + it('should accept valid templates', () => { + const templates = [ + '{author}/{title}', + '{author}/{title}/{narrator}', + 'Audiobooks/{author}/{title}', + '{author} - {title}', + '{author}/{title}/{asin}' + ]; + + templates.forEach(template => { + const result = validateTemplate(template); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + it('should reject empty templates', () => { + const result = validateTemplate(''); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it('should reject whitespace-only templates', () => { + const result = validateTemplate(' '); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it('should reject unknown variables', () => { + const result = validateTemplate('{author}/{invalid}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Unknown variable'); + expect(result.error).toContain('{invalid}'); + }); + + it('should reject absolute paths with forward slash', () => { + const result = validateTemplate('/absolute/path/{author}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('absolute'); + }); + + it('should reject absolute paths with drive letter', () => { + const result = validateTemplate('C:\\Users\\{author}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('absolute'); + }); + + it('should reject invalid characters outside variables', () => { + const invalidChars = ['<', '>', ':', '"', '|', '?', '*']; + + invalidChars.forEach(char => { + const result = validateTemplate(`{author}${char}{title}`); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid characters'); + }); + }); + + it('should reject backslashes in template', () => { + const result = validateTemplate('{author}\\{title}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('forward slashes'); + }); + + it('should accept templates without variables', () => { + const result = validateTemplate('Audiobooks/Default'); + expect(result.valid).toBe(true); + }); + + it('should provide helpful error messages for multiple unknown variables', () => { + const result = validateTemplate('{author}/{invalid1}/{invalid2}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Unknown variable'); + }); + + it('should list valid variables in error message', () => { + const result = validateTemplate('{invalid}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('{author}'); + expect(result.error).toContain('{title}'); + expect(result.error).toContain('{narrator}'); + expect(result.error).toContain('{asin}'); + }); +}); + +describe('generateMockPreviews', () => { + it('should generate 3 preview examples', () => { + const template = '{author}/{title}'; + const previews = generateMockPreviews(template); + + expect(previews).toHaveLength(3); + }); + + it('should apply template correctly to all examples', () => { + const template = '{author}/{title}'; + const previews = generateMockPreviews(template); + + previews.forEach(preview => { + expect(preview).toContain('/'); + expect(preview.length).toBeGreaterThan(0); + }); + }); + + it('should include example without narrator', () => { + const template = '{author}/{title}/{narrator}'; + const previews = generateMockPreviews(template); + + // At least one preview should not have a third path component (no narrator) + const withoutNarrator = previews.some(preview => { + const parts = preview.split('/'); + return parts.length === 2; // Only author and title + }); + + expect(withoutNarrator).toBe(true); + }); + + it('should handle templates with only static text', () => { + const template = 'Static/Path/Example'; + const previews = generateMockPreviews(template); + + previews.forEach(preview => { + expect(preview).toBe('Static/Path/Example'); + }); + }); + + it('should sanitize mock data values', () => { + const template = '{author}/{title}'; + const previews = generateMockPreviews(template); + + previews.forEach(preview => { + expect(preview).not.toContain('<'); + expect(preview).not.toContain('>'); + expect(preview).not.toContain(':'); + }); + }); + + it('should include ASIN in examples when requested', () => { + const template = '{author}/{title}/{asin}'; + const previews = generateMockPreviews(template); + + // All examples should have ASIN (mock data includes it) + previews.forEach(preview => { + const parts = preview.split('/'); + expect(parts.length).toBe(3); + expect(parts[2]).toMatch(/^B[A-Z0-9]+$/); // ASIN format + }); + }); + + it('should handle complex templates with static text', () => { + const template = 'Library/{author}/Books/{title} - {asin}'; + const previews = generateMockPreviews(template); + + previews.forEach(preview => { + expect(preview).toContain('Library/'); + expect(preview).toContain('/Books/'); + expect(preview).toContain(' - B'); + }); + }); +}); + +describe('getValidVariables', () => { + it('should return all valid variable names', () => { + const variables = getValidVariables(); + + expect(variables).toContain('author'); + expect(variables).toContain('title'); + expect(variables).toContain('narrator'); + expect(variables).toContain('asin'); + expect(variables).toContain('year'); + expect(variables).toHaveLength(5); + }); + + it('should return a new array each time (not mutate original)', () => { + const vars1 = getValidVariables(); + const vars2 = getValidVariables(); + + expect(vars1).toEqual(vars2); + expect(vars1).not.toBe(vars2); // Different array instances + }); +}); diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index f50b77e..c2028f0 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -58,6 +58,7 @@ describe('processOrganizeFiles', () => { configMock.get.mockImplementation(async (key: string) => { if (key === 'plex.trigger_scan_after_import') return 'true'; if (key === 'plex_audiobook_library_id') return 'lib-1'; + if (key === 'audiobook_path_template') return '{author}/{title} {asin}'; return null; }); @@ -95,6 +96,10 @@ describe('processOrganizeFiles', () => { maxImportRetries: 3, deletedAt: null, }); + configMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobook_path_template') return '{author}/{title} {asin}'; + return null; + }); const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); const result = await processOrganizeFiles({ diff --git a/tests/services/ebook-scraper.test.ts b/tests/services/ebook-scraper.test.ts index 0807892..34733aa 100644 --- a/tests/services/ebook-scraper.test.ts +++ b/tests/services/ebook-scraper.test.ts @@ -144,7 +144,8 @@ describe('E-book sidecar', () => { return { data: { pipe: (dest: EventEmitter) => { - setTimeout(() => dest.emit('finish'), 0); + // Emit synchronously to avoid race condition with download timeout + setImmediate(() => dest.emit('finish')); return dest; }, }, @@ -192,7 +193,8 @@ describe('E-book sidecar', () => { return { data: { pipe: (dest: EventEmitter) => { - setTimeout(() => dest.emit('finish'), 0); + // Emit synchronously to avoid race condition with download timeout + setImmediate(() => dest.emit('finish')); return dest; }, }, @@ -335,7 +337,8 @@ describe('E-book sidecar', () => { return { data: { pipe: (dest: EventEmitter) => { - setTimeout(() => dest.emit('finish'), 0); + // Emit synchronously to avoid race condition with download timeout + setImmediate(() => dest.emit('finish')); return dest; }, }, @@ -619,7 +622,8 @@ describe('E-book sidecar', () => { return { data: { pipe: (dest: EventEmitter) => { - setTimeout(() => dest.emit('finish'), 0); + // Emit synchronously to avoid race condition with download timeout + setImmediate(() => dest.emit('finish')); return dest; }, }, diff --git a/tests/services/request-delete.service.test.ts b/tests/services/request-delete.service.test.ts index 2a27773..a7d5e41 100644 --- a/tests/services/request-delete.service.test.ts +++ b/tests/services/request-delete.service.test.ts @@ -42,6 +42,17 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({ getSABnzbdService: async () => sabMock, })); +vi.mock('@/lib/services/audiobookshelf/api', () => ({ + deleteABSItem: vi.fn(), +})); + +vi.mock('@/lib/utils/file-organizer', () => ({ + buildAudiobookPath: vi.fn((mediaDir: string, template: string, data: any) => { + // Simple mock implementation that mimics the real behavior for tests + return path.join(mediaDir, data.author, `${data.title} ${data.asin}`); + }), +})); + describe('deleteRequest', () => { beforeEach(() => { vi.clearAllMocks(); @@ -83,6 +94,9 @@ describe('deleteRequest', () => { if (key === 'media_dir') { return '/media'; } + if (key === 'audiobook_path_template') { + return '{author}/{title} {asin}'; + } return null; }); configServiceMock.getBackendMode.mockResolvedValue('plex'); @@ -90,7 +104,7 @@ describe('deleteRequest', () => { name: 'Book', seeding_time: 120, }); - prismaMock.audibleCache.findUnique.mockResolvedValue({ + prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2021-01-01T00:00:00.000Z', }); prismaMock.plexLibrary.findMany.mockResolvedValue([ @@ -109,7 +123,7 @@ describe('deleteRequest', () => { expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true); expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } }); - const expectedPath = path.join('/media', 'Author', 'Book (2021) ASIN1'); + const expectedPath = path.join('/media', 'Author', 'Book ASIN1'); expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true }); }); @@ -162,7 +176,7 @@ describe('deleteRequest', () => { ); }); - it('keeps torrents seeding when requirement is not met and deletes fallback path', async () => { + it('keeps torrents seeding when requirement is not met', async () => { prismaMock.request.findFirst.mockResolvedValue({ id: 'req-3', audiobook: { @@ -188,6 +202,9 @@ describe('deleteRequest', () => { if (key === 'media_dir') { return '/media'; } + if (key === 'audiobook_path_template') { + return '{author}/{title} {asin}'; + } return null; }); configServiceMock.getBackendMode.mockResolvedValue('plex'); @@ -195,7 +212,7 @@ describe('deleteRequest', () => { name: 'Book Three', seeding_time: 60, }); - prismaMock.audibleCache.findUnique.mockResolvedValue({ + prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2020-01-01T00:00:00.000Z', }); prismaMock.plexLibrary.findMany.mockResolvedValue([ @@ -214,8 +231,8 @@ describe('deleteRequest', () => { expect(result.torrentsKeptSeeding).toBe(1); expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); - const fallbackPath = path.join('/media', 'Author Name', 'Book Three'); - expect(fsMock.rm).toHaveBeenCalledWith(fallbackPath, { recursive: true, force: true }); + // Path doesn't exist, so rm should not be called (first access fails) + expect(fsMock.rm).not.toHaveBeenCalled(); }); it('keeps torrents for unlimited seeding when no config is present', async () => { @@ -307,4 +324,90 @@ describe('deleteRequest', () => { data: expect.objectContaining({ absItemId: null }), }); }); + + it('deletes library item from Audiobookshelf when backend is audiobookshelf', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-6', + audiobook: { + id: 'ab-6', + title: 'Book Six', + author: 'Author Six', + audibleAsin: 'ASIN6', + plexGuid: null, + absItemId: 'abs-item-123', + }, + downloadHistory: [], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'media_dir') { + return '/media'; + } + if (key === 'audiobook_path_template') { + return '{author}/{title} {asin}'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf'); + prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ + releaseDate: '2022-01-01T00:00:00.000Z', + }); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + fsMock.access.mockResolvedValue(undefined); + fsMock.rm.mockResolvedValue(undefined); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteABSItem } = await import('@/lib/services/audiobookshelf/api'); + vi.mocked(deleteABSItem).mockResolvedValue(undefined); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-6', 'admin-6'); + + expect(result.success).toBe(true); + expect(deleteABSItem).toHaveBeenCalledWith('abs-item-123'); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith({ + where: { id: 'ab-6' }, + data: expect.objectContaining({ absItemId: null }), + }); + }); + + it('continues deletion even if Audiobookshelf item deletion fails', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-7', + audiobook: { + id: 'ab-7', + title: 'Book Seven', + author: 'Author Seven', + audibleAsin: null, + plexGuid: null, + absItemId: 'abs-item-456', + }, + downloadHistory: [], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'media_dir') { + return '/media'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf'); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + fsMock.access.mockRejectedValue(new Error('missing')); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteABSItem } = await import('@/lib/services/audiobookshelf/api'); + vi.mocked(deleteABSItem).mockRejectedValue(new Error('ABS API error')); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-7', 'admin-7'); + + expect(result.success).toBe(true); + expect(deleteABSItem).toHaveBeenCalledWith('abs-item-456'); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ deletedBy: 'admin-7' }), + }) + ); + }); }); diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index cf0e35b..6e69d6f 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -128,6 +128,7 @@ describe('file organizer', () => { asin: 'ASIN123', coverArtUrl: '/api/cache/thumbnails/cover.jpg', }, + '{author}/{title} ({year}) {asin}', { jobId: 'job-1', context: 'organize' } ); @@ -154,7 +155,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/empty', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(false); expect(result.errors).toContain('No audiobook files found in download'); @@ -192,7 +193,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.filesMovedCount).toBe(2); @@ -235,7 +236,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); const expectedDir = path.join('/media', 'Author', 'Book'); expect(result.success).toBe(true); @@ -272,7 +273,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available'); @@ -319,7 +320,7 @@ describe('file organizer', () => { author: 'Author', asin: 'ASIN123', coverArtUrl: 'https://images.example/cover.jpg', - }); + }, '{author}/{title} {asin}'); expect(result.success).toBe(true); expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg')); @@ -369,7 +370,7 @@ describe('file organizer', () => { title: 'Book', author: 'Author', coverArtUrl: 'https://images.example/cover.jpg', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.errors.join(' ')).toContain('Failed to download cover art'); @@ -405,7 +406,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.filesMovedCount).toBe(2); @@ -443,7 +444,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata'); @@ -474,7 +475,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.errors).toContain('E-book sidecar failed'); @@ -544,7 +545,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.errors.join(' ')).toContain('Source file not found'); @@ -576,7 +577,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.audioFiles).toEqual([targetPath]); @@ -609,7 +610,7 @@ describe('file organizer', () => { const result = await organizer.organize('/downloads/book', { title: 'Book', author: 'Author', - }); + }, '{author}/{title}'); expect(result.success).toBe(true); expect(result.errors.join(' ')).toContain('Metadata tagging failed');