diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md
index bc83465..53fd55b 100644
--- a/documentation/TABLEOFCONTENTS.md
+++ b/documentation/TABLEOFCONTENTS.md
@@ -62,6 +62,7 @@
## Admin Features
- **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)
## Deployment
- **Docker Compose setup (multi-container)** → [deployment/docker.md](deployment/docker.md)
@@ -78,6 +79,7 @@
**"What's the database schema?"** → [backend/database.md](backend/database.md)
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
**"How do I change the admin password?"** → [settings-pages.md](settings-pages.md), [backend/services/auth.md](backend/services/auth.md)
+**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"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-features/request-deletion.md b/documentation/admin-features/request-deletion.md
new file mode 100644
index 0000000..fab1e2b
--- /dev/null
+++ b/documentation/admin-features/request-deletion.md
@@ -0,0 +1,253 @@
+# Request Deletion (Admin Feature)
+
+**Status:** ✅ Implemented
+
+Admin feature for deleting requests with intelligent cleanup of media files and torrents.
+
+## Overview
+
+Allows admins to delete requests from the admin dashboard with smart handling of:
+- Soft deletion (allows re-requesting)
+- Media file cleanup
+- Torrent seeding management
+- Orphaned download tracking
+
+## Key Features
+
+1. **Soft Delete** - Preserves request history, allows re-requesting
+2. **1:1 Request-to-Files** - No duplicate requests for same audiobook
+3. **Seeding Awareness** - Keeps torrents seeding until requirements met
+4. **Confirmation Dialog** - Prevents accidental deletions
+5. **Automatic Cleanup** - Scheduled job handles orphaned downloads
+
+## User Flow
+
+### Admin Dashboard
+
+1. Navigate to Admin Dashboard → Recent Requests table
+2. Click "Delete" button next to request
+3. Review confirmation dialog with details:
+ - Request title
+ - Actions that will be taken
+ - Warning about re-requesting
+4. Click "Delete" to confirm or "Cancel" to abort
+5. Request deleted, UI updates automatically
+
+## Technical Implementation
+
+### Database Schema
+
+**Soft Delete Fields:**
+```prisma
+model Request {
+ // ... existing fields ...
+ deletedAt DateTime? @map("deleted_at")
+ deletedBy String? @map("deleted_by")
+}
+```
+
+**Unique Constraint:** Removed from schema, enforced in application code
+
+### Deletion Logic Flow
+
+**Service:** `src/lib/services/request-delete.service.ts`
+
+**Steps:**
+
+1. **Find Request**
+ - Query: `deletedAt: null`
+ - Return 404 if not found or already deleted
+
+2. **Handle Downloads & Seeding**
+
+ For each selected download:
+
+ ```
+ IF torrent not in qBittorrent:
+ → Skip (already removed)
+
+ ELSE IF unlimited seeding (0):
+ → Log: "Keeping for unlimited seeding"
+ → Do nothing (stop monitoring)
+ → torrentsKeptUnlimited++
+
+ ELSE IF download not completed:
+ → Delete torrent + files
+ → torrentsRemoved++
+
+ ELSE:
+ → Query actual seeding time
+ → Calculate remaining = (target - actual)
+
+ IF remaining > 0:
+ → Log: "Keeping for X more minutes"
+ → torrentsKeptSeeding++
+ ELSE:
+ → Delete torrent + files
+ → torrentsRemoved++
+ ```
+
+3. **Delete Media Files**
+ - Path: `[media_dir]/[author]/[title]/`
+ - **ONLY deletes title folder** (not author folder)
+ - Handles missing folders gracefully
+
+4. **Soft Delete Request**
+ - UPDATE: `deletedAt = NOW(), deletedBy = adminUserId`
+ - Preserves for audit trail and orphaned download tracking
+
+### Cleanup Job Enhancement
+
+**Processor:** `src/lib/processors/cleanup-seeded-torrents.processor.ts`
+
+**Query:** Finds both active + soft-deleted requests
+
+```typescript
+where: {
+ OR: [
+ { status: ['available', 'downloaded'], deletedAt: null },
+ { deletedAt: { not: null } }
+ ]
+}
+```
+
+**Behavior:**
+- **Active requests:** Delete torrent when seeding complete
+- **Soft-deleted requests:** Delete torrent + hard-delete request when seeding complete
+- **Unlimited seeding:** Hard-delete orphaned request immediately (no monitoring)
+
+### API Endpoint
+
+**DELETE** `/api/admin/requests/:id`
+
+**Authorization:** Admin only
+
+**Request:** No body
+
+**Response:**
+```json
+{
+ "success": true,
+ "message": "Request deleted successfully",
+ "details": {
+ "filesDeleted": true,
+ "torrentsRemoved": 2,
+ "torrentsKeptSeeding": 1,
+ "torrentsKeptUnlimited": 0
+ }
+}
+```
+
+**Errors:**
+- 401: Unauthorized (not logged in)
+- 403: Forbidden (not admin)
+- 404: Request not found or already deleted
+- 500: Internal server error
+
+### Frontend Components
+
+**ConfirmDialog** (`src/app/admin/components/ConfirmDialog.tsx`)
+- Reusable confirmation modal
+- Props: title, message, confirmLabel, confirmVariant
+- Supports danger (red) and primary (blue) variants
+
+**RecentRequestsTable** (`src/app/admin/components/RecentRequestsTable.tsx`)
+- Added "Actions" column with Delete button
+- State management for confirmation dialog
+- SWR cache invalidation after deletion
+- Loading states during deletion
+
+## Re-Requesting After Deletion
+
+**Application-Level Uniqueness:**
+
+All `prisma.request.findMany/findFirst` queries include:
+```typescript
+where: {
+ // ... other conditions
+ deletedAt: null // Only active requests
+}
+```
+
+**Re-Request Flow:**
+
+1. User requests audiobook previously deleted
+2. Query checks for existing request: `deletedAt: null`
+3. No active request found → allowed to create new request
+4. Old soft-deleted request remains in DB for audit
+
+## Edge Cases Handled
+
+1. ✅ **Torrent not in qBittorrent** - Skip deletion, continue with files
+2. ✅ **Unlimited seeding (0)** - Keep in qBittorrent, hard-delete orphaned request
+3. ✅ **Incomplete download** - Delete torrent + files immediately
+4. ✅ **Seeding requirement met** - Delete torrent + files
+5. ✅ **Still seeding** - Keep torrent, soft-delete request, cleanup job handles later
+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
+
+## File Structure
+
+```
+Backend:
+- prisma/schema.prisma (deletedAt, deletedBy fields)
+- src/lib/services/request-delete.service.ts (deletion logic)
+- src/app/api/admin/requests/[id]/route.ts (DELETE endpoint)
+- src/lib/processors/cleanup-seeded-torrents.processor.ts (orphaned cleanup)
+
+Frontend:
+- src/app/admin/components/ConfirmDialog.tsx (confirmation modal)
+- src/app/admin/components/RecentRequestsTable.tsx (Delete button + logic)
+
+Queries Updated (deletedAt: null filters):
+- src/app/api/requests/route.ts (GET, POST)
+- src/app/api/requests/[id]/route.ts (GET, PATCH)
+- src/app/api/admin/requests/recent/route.ts (GET)
+- src/app/api/admin/metrics/route.ts (GET)
+- src/app/api/admin/downloads/active/route.ts (GET)
+- src/lib/processors/*.ts (all processors)
+```
+
+## Configuration
+
+**No new config required** - uses existing:
+- `prowlarr_indexers` (seeding time per indexer)
+- `media_dir` (file deletion path)
+
+## Security
+
+- **Authorization:** Admin role required
+- **Audit Trail:** `deletedBy` tracks admin user ID
+- **Soft Delete:** Preserves history, prevents permanent data loss
+- **Confirmation Required:** Prevents accidental deletion
+
+## Monitoring & Logging
+
+**Logs:**
+- `[RequestDelete]` prefix for deletion service
+- `[CleanupSeededTorrents]` prefix for cleanup job
+- Torrent status (removed/kept/unlimited)
+- File deletion success/failure
+- Orphaned request hard deletion
+
+**Admin Dashboard:**
+- Request count updates after deletion
+- Recent requests table refreshes automatically
+- Toast notifications (via console.log - can be enhanced)
+
+## Future Enhancements
+
+- Toast notifications instead of console.log
+- Deletion history view (soft-deleted requests)
+- Bulk delete operations
+- Restore deleted requests (undo)
+- Email notifications for deletions
+- Deletion reason/notes field
+
+## Related
+
+- [Admin Dashboard](../admin-dashboard.md) - Dashboard overview
+- [Scheduler](../backend/services/scheduler.md) - Cleanup job details
+- [File Organization](../phase3/file-organization.md) - Media directory structure
+- [qBittorrent](../phase3/qbittorrent.md) - Torrent management
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f04f044..963c9f4 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1,7 +1,6 @@
/**
* ReadMeABook Database Schema
* Documentation: documentation/backend/database.md
- *
* ARCHITECTURE:
* - audible_cache: Pure Audible metadata (popular/new releases from Audible.com)
* - plex_library: Pure Plex library content (what's in your Plex server)
@@ -24,31 +23,31 @@ datasource db {
// ============================================================================
model User {
- id String @id @default(uuid())
- plexId String @unique @map("plex_id")
- plexUsername String @map("plex_username")
- plexEmail String? @map("plex_email")
- role String @default("user") // 'user' or 'admin'
- isSetupAdmin Boolean @default(false) @map("is_setup_admin") // First admin created during setup, cannot be demoted
- avatarUrl String? @map("avatar_url")
- authToken String? @map("auth_token") // Encrypted
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
- lastLoginAt DateTime? @map("last_login_at")
+ id String @id @default(uuid())
+ plexId String @unique @map("plex_id")
+ plexUsername String @map("plex_username")
+ plexEmail String? @map("plex_email")
+ role String @default("user") // 'user' or 'admin'
+ isSetupAdmin Boolean @default(false) @map("is_setup_admin") // First admin created during setup, cannot be demoted
+ avatarUrl String? @map("avatar_url")
+ authToken String? @map("auth_token") // Encrypted
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ lastLoginAt DateTime? @map("last_login_at")
// Plex Home profile tracking
- plexHomeUserId String? @map("plex_home_user_id") // Profile ID from Plex Home (null = main account, set = home profile)
+ plexHomeUserId String? @map("plex_home_user_id") // Profile ID from Plex Home (null = main account, set = home profile)
// Multi-auth support (for Audiobookshelf integration)
- authProvider String? @map("auth_provider") // 'plex' | 'oidc' | 'local'
- oidcSubject String? @map("oidc_subject") // OIDC subject ID (unique per provider)
- oidcProvider String? @map("oidc_provider") // OIDC provider name (e.g., 'authentik', 'keycloak')
- registrationStatus String? @default("approved") @map("registration_status") // 'pending_approval' | 'approved' | 'rejected'
+ authProvider String? @map("auth_provider") // 'plex' | 'oidc' | 'local'
+ oidcSubject String? @map("oidc_subject") // OIDC subject ID (unique per provider)
+ oidcProvider String? @map("oidc_provider") // OIDC provider name (e.g., 'authentik', 'keycloak')
+ registrationStatus String? @default("approved") @map("registration_status") // 'pending_approval' | 'approved' | 'rejected'
// BookDate per-user preferences
- bookDateLibraryScope String? @default("full") @map("bookdate_library_scope") // 'full' | 'rated'
- bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text
- bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete")
+ bookDateLibraryScope String? @default("full") @map("bookdate_library_scope") // 'full' | 'rated'
+ bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text
+ bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete")
// Relations
requests Request[]
@@ -72,22 +71,22 @@ model AudibleCache {
author String
narrator String?
description String? @db.Text
- coverArtUrl String? @map("cover_art_url") @db.Text
- cachedCoverPath String? @map("cached_cover_path") @db.Text // Local path to cached cover image
- durationMinutes Int? @map("duration_minutes")
+ coverArtUrl String? @map("cover_art_url") @db.Text
+ cachedCoverPath String? @map("cached_cover_path") @db.Text // Local path to cached cover image
+ durationMinutes Int? @map("duration_minutes")
releaseDate DateTime? @map("release_date") @db.Date
rating Decimal? @db.Decimal(3, 2)
genres Json @default("[]")
// Discovery categories
- isPopular Boolean @default(false) @map("is_popular")
- isNewRelease Boolean @default(false) @map("is_new_release")
- popularRank Int? @map("popular_rank")
- newReleaseRank Int? @map("new_release_rank")
+ isPopular Boolean @default(false) @map("is_popular")
+ isNewRelease Boolean @default(false) @map("is_new_release")
+ popularRank Int? @map("popular_rank")
+ newReleaseRank Int? @map("new_release_rank")
- lastSyncedAt DateTime @default(now()) @map("last_synced_at")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ lastSyncedAt DateTime @default(now()) @map("last_synced_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
@@index([asin])
@@index([title])
@@ -106,33 +105,33 @@ model AudibleCache {
// No Audible data - just library backend metadata and file info
// ============================================================================
model PlexLibrary {
- id String @id @default(uuid())
- plexGuid String @unique @map("plex_guid") // Plex's unique identifier
- plexRatingKey String? @map("plex_rating_key") // Plex's rating key
+ id String @id @default(uuid())
+ plexGuid String @unique @map("plex_guid") // Plex's unique identifier
+ plexRatingKey String? @map("plex_rating_key") // Plex's rating key
- title String
- author String
- narrator String?
- summary String? @db.Text
- duration Int? // Duration in milliseconds (Plex format)
- year Int?
- userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
+ title String
+ author String
+ narrator String?
+ summary String? @db.Text
+ duration Int? // Duration in milliseconds (Plex format)
+ year Int?
+ userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
// Universal identifiers (works for both Plex and Audiobookshelf)
- asin String? // Audible ASIN - extracted from Plex GUID or stored directly from ABS
- isbn String? // ISBN (10 or 13) - for additional matching capability
+ asin String? // Audible ASIN - extracted from Plex GUID or stored directly from ABS
+ isbn String? // ISBN (10 or 13) - for additional matching capability
// File information
- filePath String? @map("file_path") @db.Text
- thumbUrl String? @map("thumb_url") @db.Text // Plex thumbnail URL
+ filePath String? @map("file_path") @db.Text
+ thumbUrl String? @map("thumb_url") @db.Text // Plex thumbnail URL
// Plex metadata
plexLibraryId String @map("plex_library_id") // Which Plex library contains this
addedAt DateTime? @map("added_at") // When added to Plex
- lastScannedAt DateTime @default(now()) @map("last_scanned_at")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ lastScannedAt DateTime @default(now()) @map("last_scanned_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
@@index([plexGuid])
@@index([title])
@@ -149,34 +148,34 @@ model PlexLibrary {
// Links to AudibleCache for metadata (optional - search results may not be cached)
// ============================================================================
model Audiobook {
- id String @id @default(uuid())
+ id String @id @default(uuid())
// Core metadata (may come from Audible search, not necessarily cached)
- audibleAsin String? @map("audible_asin") // ASIN if from Audible
- title String
- author String
- narrator String?
- description String? @db.Text
- coverArtUrl String? @map("cover_art_url") @db.Text
+ audibleAsin String? @map("audible_asin") // ASIN if from Audible
+ title String
+ author String
+ narrator String?
+ description String? @db.Text
+ coverArtUrl String? @map("cover_art_url") @db.Text
// Request tracking
- status String @default("requested") // requested, downloading, processing, completed, failed
+ status String @default("requested") // requested, downloading, processing, completed, failed
// File information (populated after download/organization)
- filePath String? @map("file_path") @db.Text
- fileFormat String? @map("file_format") // m4b, m4a, mp3
- fileSizeBytes BigInt? @map("file_size_bytes")
+ filePath String? @map("file_path") @db.Text
+ fileFormat String? @map("file_format") // m4b, m4a, mp3
+ fileSizeBytes BigInt? @map("file_size_bytes")
// Plex integration (populated after successful import)
- plexGuid String? @map("plex_guid") // Set when imported into Plex
- plexLibraryId String? @map("plex_library_id")
+ plexGuid String? @map("plex_guid") // Set when imported into Plex
+ plexLibraryId String? @map("plex_library_id")
// Audiobookshelf integration (alternative to Plex)
- absItemId String? @map("abs_item_id") // Audiobookshelf item ID
+ absItemId String? @map("abs_item_id") // Audiobookshelf item ID
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
- completedAt DateTime? @map("completed_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ completedAt DateTime? @map("completed_at")
// Relations
requests Request[]
@@ -210,17 +209,21 @@ model Request {
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
- // Relations
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
- downloadHistory DownloadHistory[]
- jobs Job[]
+ // Soft delete support
+ deletedAt DateTime? @map("deleted_at")
+ deletedBy String? @map("deleted_by") // Admin user ID
+
+ // Relations
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
+ downloadHistory DownloadHistory[]
+ jobs Job[]
- @@unique([userId, audiobookId])
@@index([userId])
@@index([audiobookId])
@@index([status])
@@index([createdAt(sort: Desc)])
+ @@index([deletedAt])
@@map("requests")
}
@@ -271,27 +274,27 @@ model Configuration {
}
model Job {
- id String @id @default(uuid())
- bullJobId String? @map("bull_job_id")
- requestId String? @map("request_id")
- type String
+ id String @id @default(uuid())
+ bullJobId String? @map("bull_job_id")
+ requestId String? @map("request_id")
+ type String
// Job types: search_indexers, monitor_download, organize_files, scan_plex, plex_recently_added_check, match_plex
- status String @default("pending")
+ status String @default("pending")
// Status values: pending, active, completed, failed, delayed, stuck
- priority Int @default(0)
- attempts Int @default(0)
- maxAttempts Int @default(3) @map("max_attempts")
- payload Json?
- result Json?
+ priority Int @default(0)
+ attempts Int @default(0)
+ maxAttempts Int @default(3) @map("max_attempts")
+ payload Json?
+ result Json?
errorMessage String? @map("error_message") @db.Text
- stackTrace String? @map("stack_trace") @db.Text
- startedAt DateTime? @map("started_at")
- completedAt DateTime? @map("completed_at")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ stackTrace String? @map("stack_trace") @db.Text
+ startedAt DateTime? @map("started_at")
+ completedAt DateTime? @map("completed_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
// Relations
- request Request? @relation(fields: [requestId], references: [id], onDelete: SetNull)
+ request Request? @relation(fields: [requestId], references: [id], onDelete: SetNull)
events JobEvent[]
@@index([requestId])
@@ -304,10 +307,10 @@ model Job {
model JobEvent {
id String @id @default(uuid())
jobId String @map("job_id")
- level String // info, warn, error
- context String // e.g., OrganizeFiles, FileOrganizer, MonitorDownload
+ level String // info, warn, error
+ context String // e.g., OrganizeFiles, FileOrganizer, MonitorDownload
message String @db.Text
- metadata Json? // Additional structured data
+ metadata Json? // Additional structured data
createdAt DateTime @default(now()) @map("created_at")
// Relations
@@ -319,17 +322,17 @@ model JobEvent {
}
model ScheduledJob {
- id String @id @default(uuid())
- name String
- type String // 'plex_library_scan', 'plex_recently_added_check', 'audible_refresh', 'retry_missing_torrents', 'retry_failed_imports', 'cleanup_seeded_torrents', 'monitor_rss_feeds'
- schedule String // Cron expression
- enabled Boolean @default(true)
- payload Json @default("{}")
- lastRun DateTime? @map("last_run")
- lastRunJobId String? @map("last_run_job_id") // Bull queue job ID of most recent execution
- nextRun DateTime? @map("next_run")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ id String @id @default(uuid())
+ name String
+ type String // 'plex_library_scan', 'plex_recently_added_check', 'audible_refresh', 'retry_missing_torrents', 'retry_failed_imports', 'cleanup_seeded_torrents', 'monitor_rss_feeds'
+ schedule String // Cron expression
+ enabled Boolean @default(true)
+ payload Json @default("{}")
+ lastRun DateTime? @map("last_run")
+ lastRunJobId String? @map("last_run_job_id") // Bull queue job ID of most recent execution
+ nextRun DateTime? @map("next_run")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
@@index([type])
@@index([enabled])
@@ -343,16 +346,16 @@ model ScheduledJob {
// ============================================================================
model BookDateConfig {
- id String @id @default(uuid())
- provider String // 'openai' | 'claude'
- apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
- model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
- libraryScope String? @map("library_scope") // DEPRECATED: Now per-user (User.bookDateLibraryScope)
- customPrompt String? @map("custom_prompt") @db.Text // DEPRECATED: Now per-user (User.bookDateCustomPrompt)
- isVerified Boolean @default(false) @map("is_verified")
- isEnabled Boolean @default(true) @map("is_enabled") // Admin toggle (global feature)
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ id String @id @default(uuid())
+ provider String // 'openai' | 'claude'
+ apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
+ model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
+ libraryScope String? @map("library_scope") // DEPRECATED: Now per-user (User.bookDateLibraryScope)
+ customPrompt String? @map("custom_prompt") @db.Text // DEPRECATED: Now per-user (User.bookDateCustomPrompt)
+ isVerified Boolean @default(false) @map("is_verified")
+ isEnabled Boolean @default(true) @map("is_enabled") // Admin toggle (global feature)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
@@map("bookdate_config")
}
@@ -362,18 +365,18 @@ model BookDateConfig {
// Individual users still have their own recommendations and swipe history.
model BookDateRecommendation {
- id String @id @default(uuid())
- userId String @map("user_id")
- batchId String @map("batch_id") // Group recommendations from same AI call
- title String
- author String
- narrator String?
- rating Decimal? @db.Decimal(3, 2)
- description String? @db.Text
- coverUrl String? @map("cover_url") @db.Text
- audnexusAsin String? @map("audnexus_asin") // For matching
- aiReason String @map("ai_reason") @db.Text // Why AI recommended this
- createdAt DateTime @default(now()) @map("created_at")
+ id String @id @default(uuid())
+ userId String @map("user_id")
+ batchId String @map("batch_id") // Group recommendations from same AI call
+ title String
+ author String
+ narrator String?
+ rating Decimal? @db.Decimal(3, 2)
+ description String? @db.Text
+ coverUrl String? @map("cover_url") @db.Text
+ audnexusAsin String? @map("audnexus_asin") // For matching
+ aiReason String @map("ai_reason") @db.Text // Why AI recommended this
+ createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -385,14 +388,14 @@ model BookDateRecommendation {
}
model BookDateSwipe {
- id String @id @default(uuid())
- userId String @map("user_id")
- recommendationId String? @map("recommendation_id") // NULL if book not from BookDate
- bookTitle String @map("book_title")
- bookAuthor String @map("book_author")
- action String // 'left' | 'right' | 'up'
- markedAsKnown Boolean @default(false) @map("marked_as_known") // True if "Mark as Known"
- createdAt DateTime @default(now()) @map("created_at")
+ id String @id @default(uuid())
+ userId String @map("user_id")
+ recommendationId String? @map("recommendation_id") // NULL if book not from BookDate
+ bookTitle String @map("book_title")
+ bookAuthor String @map("book_author")
+ action String // 'left' | 'right' | 'up'
+ markedAsKnown Boolean @default(false) @map("marked_as_known") // True if "Mark as Known"
+ createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diff --git a/src/app/admin/components/ConfirmDialog.tsx b/src/app/admin/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..ef71e08
--- /dev/null
+++ b/src/app/admin/components/ConfirmDialog.tsx
@@ -0,0 +1,130 @@
+/**
+ * Component: Confirm Dialog
+ * Documentation: documentation/frontend/components.md
+ *
+ * Reusable confirmation dialog for destructive actions
+ */
+
+'use client';
+
+import { Fragment } from 'react';
+
+export interface ConfirmDialogProps {
+ isOpen: boolean;
+ title: string;
+ message: string | React.ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ confirmVariant?: 'danger' | 'primary';
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function ConfirmDialog({
+ isOpen,
+ title,
+ message,
+ confirmLabel = 'Confirm',
+ cancelLabel = 'Cancel',
+ confirmVariant = 'danger',
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ if (!isOpen) return null;
+
+ const confirmButtonClasses =
+ confirmVariant === 'danger'
+ ? 'bg-red-600 hover:bg-red-700 text-white'
+ : 'bg-blue-600 hover:bg-blue-700 text-white';
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Dialog */}
+
+
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Content */}
+
+
+ {title}
+
+
+ {typeof message === 'string' ? (
+
+ {message}
+
+ ) : (
+
+ {message}
+
+ )}
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx
index d0732aa..3176ca3 100644
--- a/src/app/admin/components/RecentRequestsTable.tsx
+++ b/src/app/admin/components/RecentRequestsTable.tsx
@@ -5,7 +5,12 @@
'use client';
+import { useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
+import { ConfirmDialog } from './ConfirmDialog';
+import { RequestActionsDropdown } from './RequestActionsDropdown';
+import { mutate } from 'swr';
+import { fetchWithAuth } from '@/lib/utils/api';
interface RecentRequest {
requestId: string;
@@ -57,6 +62,120 @@ function getStatusBadge(status: string) {
}
export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [selectedRequest, setSelectedRequest] = useState<{
+ id: string;
+ title: string;
+ } | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const handleDeleteClick = (requestId: string, title: string) => {
+ setSelectedRequest({ id: requestId, title });
+ setShowDeleteConfirm(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!selectedRequest) return;
+
+ setIsDeleting(true);
+
+ try {
+ const response = await fetchWithAuth(`/api/admin/requests/${selectedRequest.id}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.message || 'Failed to delete request');
+ }
+
+ const result = await response.json();
+
+ // Show success message
+ console.log('[Admin] Request deleted:', result);
+
+ // Refresh the requests list
+ await mutate('/api/admin/requests/recent');
+ await mutate('/api/admin/metrics');
+
+ // Close dialog
+ setShowDeleteConfirm(false);
+ setSelectedRequest(null);
+ } catch (error) {
+ console.error('[Admin] Failed to delete request:', error);
+ alert(
+ `Failed to delete request: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleDeleteCancel = () => {
+ setShowDeleteConfirm(false);
+ setSelectedRequest(null);
+ };
+
+ const handleManualSearch = async (requestId: string) => {
+ try {
+ const response = await fetchWithAuth(`/api/requests/${requestId}/manual-search`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.message || 'Failed to trigger manual search');
+ }
+
+ console.log('[Admin] Manual search triggered for request:', requestId);
+ // Refresh the requests list
+ await mutate('/api/admin/requests/recent');
+ } catch (error) {
+ console.error('[Admin] Failed to trigger manual search:', error);
+ alert(
+ `Failed to trigger manual search: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ }
+ };
+
+ const handleCancel = async (requestId: string) => {
+ try {
+ const response = await fetchWithAuth(`/api/requests/${requestId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ action: 'cancel' }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.message || 'Failed to cancel request');
+ }
+
+ console.log('[Admin] Request cancelled:', requestId);
+ // Refresh the requests list
+ await mutate('/api/admin/requests/recent');
+ } catch (error) {
+ console.error('[Admin] Failed to cancel request:', error);
+ alert(
+ `Failed to cancel request: ${
+ error instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ }
+ };
+
if (requests.length === 0) {
return (
@@ -107,6 +226,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
Completed
|
+
+ Actions
+ |
@@ -144,11 +266,53 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
})
: '-'}
+
+
+ |
))}
+
+ {/* Confirm Dialog */}
+
+
+ This will delete the request for "{selectedRequest.title}" and:
+
+
+ - Remove the request (allowing it to be re-requested)
+ - Delete files from the media directory
+ - Keep torrent seeding if time remaining
+
+ Are you sure?
+
+ ) : (
+ ''
+ )
+ }
+ confirmLabel={isDeleting ? 'Deleting...' : 'Delete'}
+ cancelLabel="Cancel"
+ confirmVariant="danger"
+ onConfirm={handleDeleteConfirm}
+ onCancel={handleDeleteCancel}
+ />
);
}
diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx
new file mode 100644
index 0000000..a8ae016
--- /dev/null
+++ b/src/app/admin/components/RequestActionsDropdown.tsx
@@ -0,0 +1,232 @@
+/**
+ * Component: Request Actions Dropdown
+ * Documentation: documentation/admin-features/request-deletion.md
+ *
+ * Dropdown menu for admin actions on requests
+ */
+
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
+
+export interface RequestActionsDropdownProps {
+ request: {
+ requestId: string;
+ title: string;
+ author: string;
+ status: string;
+ };
+ onDelete: (requestId: string, title: string) => void;
+ onManualSearch: (requestId: string) => Promise;
+ onCancel: (requestId: string) => Promise;
+ isLoading?: boolean;
+}
+
+export function RequestActionsDropdown({
+ request,
+ onDelete,
+ onManualSearch,
+ onCancel,
+ isLoading = false,
+}: RequestActionsDropdownProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // Determine available actions based on status
+ const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
+ const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
+ const canDelete = true; // Admins can always delete
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ const handleManualSearch = async () => {
+ setIsOpen(false);
+ try {
+ await onManualSearch(request.requestId);
+ } catch (error) {
+ console.error('Failed to trigger manual search:', error);
+ }
+ };
+
+ const handleInteractiveSearch = () => {
+ setIsOpen(false);
+ setShowInteractiveSearch(true);
+ };
+
+ const handleCancel = async () => {
+ setIsOpen(false);
+ if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
+ try {
+ await onCancel(request.requestId);
+ } catch (error) {
+ console.error('Failed to cancel request:', error);
+ }
+ }
+ };
+
+ const handleDelete = () => {
+ setIsOpen(false);
+ onDelete(request.requestId, request.title);
+ };
+
+ return (
+
+ {/* Three-dot menu button */}
+
+
+ {/* Dropdown menu */}
+ {isOpen && (
+
+
+ {/* Manual Search */}
+ {canSearch && (
+
+ )}
+
+ {/* Interactive Search */}
+ {canSearch && (
+
+ )}
+
+ {/* Divider if we have search actions and other actions */}
+ {canSearch && (canCancel || canDelete) && (
+
+ )}
+
+ {/* Cancel */}
+ {canCancel && (
+
+ )}
+
+ {/* Divider before delete */}
+ {canDelete && (canSearch || canCancel) && (
+
+ )}
+
+ {/* Delete */}
+ {canDelete && (
+
+ )}
+
+
+ )}
+
+ {/* Interactive Search Modal */}
+
setShowInteractiveSearch(false)}
+ requestId={request.requestId}
+ audiobook={{
+ title: request.title,
+ author: request.author,
+ }}
+ />
+
+ );
+}
diff --git a/src/app/api/admin/downloads/active/route.ts b/src/app/api/admin/downloads/active/route.ts
index 7b57d47..727cd5c 100644
--- a/src/app/api/admin/downloads/active/route.ts
+++ b/src/app/api/admin/downloads/active/route.ts
@@ -15,6 +15,7 @@ export async function GET(request: NextRequest) {
const activeDownloads = await prisma.request.findMany({
where: {
status: 'downloading',
+ deletedAt: null,
},
include: {
audiobook: {
diff --git a/src/app/api/admin/metrics/route.ts b/src/app/api/admin/metrics/route.ts
index f4f24a9..04b20e6 100644
--- a/src/app/api/admin/metrics/route.ts
+++ b/src/app/api/admin/metrics/route.ts
@@ -22,13 +22,18 @@ export async function GET(request: NextRequest) {
failedLast30Days,
totalUsers,
] = await Promise.all([
- // Total requests (all time)
- prisma.request.count(),
+ // Total requests (all time, only active)
+ prisma.request.count({
+ where: {
+ deletedAt: null,
+ },
+ }),
// Active downloads (downloading status)
prisma.request.count({
where: {
status: 'downloading',
+ deletedAt: null,
},
}),
@@ -41,6 +46,7 @@ export async function GET(request: NextRequest) {
completedAt: {
gte: thirtyDaysAgo,
},
+ deletedAt: null,
},
}),
@@ -51,6 +57,7 @@ export async function GET(request: NextRequest) {
updatedAt: {
gte: thirtyDaysAgo,
},
+ deletedAt: null,
},
}),
@@ -103,6 +110,7 @@ async function checkSystemHealth(): Promise<{
updatedAt: {
lt: oneDayAgo,
},
+ deletedAt: null,
},
});
diff --git a/src/app/api/admin/requests/[id]/route.ts b/src/app/api/admin/requests/[id]/route.ts
new file mode 100644
index 0000000..f46086f
--- /dev/null
+++ b/src/app/api/admin/requests/[id]/route.ts
@@ -0,0 +1,77 @@
+/**
+ * Component: Admin Request Management API
+ * Documentation: documentation/admin-features/request-deletion.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { deleteRequest } from '@/lib/services/request-delete.service';
+
+/**
+ * DELETE /api/admin/requests/[id]
+ * Soft delete a request with intelligent cleanup (admin only)
+ *
+ * This endpoint:
+ * 1. Validates admin authorization
+ * 2. Soft deletes the request (sets deletedAt timestamp)
+ * 3. Deletes media files from the title folder
+ * 4. Handles torrents based on seeding configuration:
+ * - Unlimited seeding (0): Keeps torrent, stops monitoring
+ * - Seeding complete: Deletes torrent + files
+ * - Still seeding: Keeps torrent for cleanup job
+ * 5. Allows re-requesting the same audiobook after deletion
+ */
+export async function DELETE(
+ 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;
+
+ // Perform soft delete with cleanup
+ const result = await deleteRequest(id, req.user.id);
+
+ if (!result.success) {
+ return NextResponse.json(
+ {
+ error: result.error || 'DeleteFailed',
+ message: result.message,
+ },
+ { status: result.error === 'NotFound' ? 404 : 500 }
+ );
+ }
+
+ // Return detailed result
+ return NextResponse.json({
+ success: true,
+ message: result.message,
+ details: {
+ filesDeleted: result.filesDeleted,
+ torrentsRemoved: result.torrentsRemoved,
+ torrentsKeptSeeding: result.torrentsKeptSeeding,
+ torrentsKeptUnlimited: result.torrentsKeptUnlimited,
+ },
+ });
+ } catch (error) {
+ console.error('[Admin] Failed to delete request:', error);
+ return NextResponse.json(
+ {
+ error: 'DeleteError',
+ message: 'Failed to delete request',
+ details: error instanceof Error ? error.message : 'Unknown error',
+ },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/requests/recent/route.ts b/src/app/api/admin/requests/recent/route.ts
index c4052fb..2cebf48 100644
--- a/src/app/api/admin/requests/recent/route.ts
+++ b/src/app/api/admin/requests/recent/route.ts
@@ -11,8 +11,11 @@ export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
- // Get recent requests
+ // Get recent requests (only active, non-deleted)
const recentRequests = await prisma.request.findMany({
+ where: {
+ deletedAt: null,
+ },
include: {
audiobook: {
select: {
diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts
new file mode 100644
index 0000000..ef0b73f
--- /dev/null
+++ b/src/app/api/audiobooks/request-with-torrent/route.ts
@@ -0,0 +1,187 @@
+/**
+ * Component: Request with Specific Torrent API
+ * Documentation: documentation/phase3/prowlarr.md
+ *
+ * Create a request and immediately download a specific torrent
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+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 { z } from 'zod';
+
+const RequestWithTorrentSchema = z.object({
+ audiobook: z.object({
+ asin: z.string(),
+ title: z.string(),
+ author: z.string(),
+ narrator: z.string().optional(),
+ description: z.string().optional(),
+ coverArtUrl: z.string().optional(),
+ durationMinutes: z.number().optional(),
+ releaseDate: z.string().optional(),
+ rating: z.number().optional(),
+ }),
+ torrent: z.object({
+ guid: z.string(),
+ title: z.string(),
+ size: z.number(),
+ seeders: z.number(),
+ leechers: z.number(),
+ indexer: z.string(),
+ downloadUrl: z.string(),
+ publishDate: z.string().transform((str) => new Date(str)),
+ infoHash: z.string().optional(),
+ format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
+ bitrate: z.string().optional(),
+ hasChapters: z.boolean().optional(),
+ }),
+});
+
+/**
+ * POST /api/audiobooks/request-with-torrent
+ * Create a request and download a specific torrent in one operation
+ */
+export async function POST(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ try {
+ if (!req.user) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'User not authenticated' },
+ { status: 401 }
+ );
+ }
+
+ const body = await req.json();
+ const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
+
+ // Check if audiobook is already available in Plex library
+ const plexMatch = await findPlexMatch({
+ asin: audiobook.asin,
+ title: audiobook.title,
+ author: audiobook.author,
+ narrator: audiobook.narrator,
+ });
+
+ if (plexMatch) {
+ return NextResponse.json(
+ {
+ error: 'AlreadyAvailable',
+ message: 'This audiobook is already available in your Plex library',
+ plexGuid: plexMatch.plexGuid,
+ },
+ { status: 409 }
+ );
+ }
+
+ // Try to find existing audiobook record by ASIN
+ let audiobookRecord = await prisma.audiobook.findFirst({
+ where: { audibleAsin: audiobook.asin },
+ });
+
+ // If not found, create new audiobook record
+ if (!audiobookRecord) {
+ audiobookRecord = await prisma.audiobook.create({
+ data: {
+ audibleAsin: audiobook.asin,
+ title: audiobook.title,
+ author: audiobook.author,
+ narrator: audiobook.narrator,
+ description: audiobook.description,
+ coverArtUrl: audiobook.coverArtUrl,
+ status: 'requested',
+ },
+ });
+ }
+
+ // Check if user already has an active request for this audiobook
+ const existingRequest = await prisma.request.findFirst({
+ where: {
+ userId: req.user.id,
+ audiobookId: audiobookRecord.id,
+ },
+ });
+
+ if (existingRequest) {
+ const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
+
+ if (!canReRequest) {
+ return NextResponse.json(
+ {
+ error: 'DuplicateRequest',
+ message: 'You have already requested this audiobook',
+ request: existingRequest,
+ },
+ { status: 409 }
+ );
+ }
+
+ // Delete the existing failed/warn/cancelled request
+ console.log(`[RequestWithTorrent] Deleting existing ${existingRequest.status} request ${existingRequest.id}`);
+ await prisma.request.delete({
+ where: { id: existingRequest.id },
+ });
+ }
+
+ // Create request with downloading status
+ const newRequest = await prisma.request.create({
+ data: {
+ userId: req.user.id,
+ audiobookId: audiobookRecord.id,
+ status: 'downloading',
+ progress: 0,
+ },
+ include: {
+ audiobook: true,
+ user: {
+ select: {
+ id: true,
+ plexUsername: true,
+ },
+ },
+ },
+ });
+
+ // Queue download job with the selected torrent
+ const jobQueue = getJobQueueService();
+ await jobQueue.addDownloadJob(
+ newRequest.id,
+ {
+ id: audiobookRecord.id,
+ title: audiobookRecord.title,
+ author: audiobookRecord.author,
+ },
+ torrent
+ );
+
+ console.log(`[RequestWithTorrent] Queued download monitor job for request ${newRequest.id}`);
+
+ return NextResponse.json({
+ success: true,
+ request: newRequest,
+ }, { status: 201 });
+ } catch (error) {
+ console.error('Failed to create request with torrent:', error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ {
+ error: 'ValidationError',
+ details: error.errors,
+ },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ error: 'RequestError',
+ message: error instanceof Error ? error.message : 'Failed to create request and download torrent',
+ },
+ { status: 500 }
+ );
+ }
+ });
+}
diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts
new file mode 100644
index 0000000..6d55e6c
--- /dev/null
+++ b/src/app/api/audiobooks/search-torrents/route.ts
@@ -0,0 +1,114 @@
+/**
+ * Component: Audiobook Torrent Search API
+ * Documentation: documentation/phase3/prowlarr.md
+ *
+ * Search for torrents without creating a request first
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
+import { rankTorrents } from '@/lib/utils/ranking-algorithm';
+import { z } from 'zod';
+
+const SearchSchema = z.object({
+ title: z.string(),
+ author: z.string(),
+});
+
+/**
+ * POST /api/audiobooks/search-torrents
+ * Search for torrents for an audiobook (no request required)
+ */
+export async function POST(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ try {
+ if (!req.user) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'User not authenticated' },
+ { status: 401 }
+ );
+ }
+
+ const body = await req.json();
+ const { title, author } = SearchSchema.parse(body);
+
+ // Get enabled indexers from configuration
+ const { getConfigService } = await import('@/lib/services/config.service');
+ const configService = getConfigService();
+ const indexersConfigStr = await configService.get('prowlarr_indexers');
+
+ if (!indexersConfigStr) {
+ return NextResponse.json(
+ { error: 'ConfigError', message: 'No indexers configured. Please configure indexers in settings.' },
+ { status: 400 }
+ );
+ }
+
+ const indexersConfig = JSON.parse(indexersConfigStr);
+ const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
+
+ if (enabledIndexerIds.length === 0) {
+ return NextResponse.json(
+ { error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
+ { status: 400 }
+ );
+ }
+
+ // Search Prowlarr for torrents - ONLY enabled indexers
+ const prowlarr = await getProwlarrService();
+ const searchQuery = `${title} ${author}`;
+
+ console.log(`[AudiobookSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`);
+
+ const results = await prowlarr.search(searchQuery, {
+ indexerIds: enabledIndexerIds,
+ });
+
+ if (results.length === 0) {
+ return NextResponse.json({
+ success: true,
+ results: [],
+ message: 'No torrents found',
+ });
+ }
+
+ // Rank torrents using the ranking algorithm
+ const rankedResults = rankTorrents(results, { title, author });
+
+ // Add rank position to each result
+ const resultsWithRank = rankedResults.map((result, index) => ({
+ ...result,
+ rank: index + 1,
+ }));
+
+ console.log(`[AudiobookSearch] Found ${resultsWithRank.length} results for "${title}" by ${author}`);
+
+ return NextResponse.json({
+ success: true,
+ results: resultsWithRank,
+ message: `Found ${resultsWithRank.length} torrents`,
+ });
+ } catch (error) {
+ console.error('Failed to search for torrents:', error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ {
+ error: 'ValidationError',
+ details: error.errors,
+ },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ error: 'SearchError',
+ message: error instanceof Error ? error.message : 'Failed to search for torrents',
+ },
+ { status: 500 }
+ );
+ }
+ });
+}
diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts
index 43467be..4b2a136 100644
--- a/src/app/api/requests/[id]/route.ts
+++ b/src/app/api/requests/[id]/route.ts
@@ -26,8 +26,11 @@ export async function GET(
const { id } = await params;
- const requestRecord = await prisma.request.findUnique({
- where: { id },
+ const requestRecord = await prisma.request.findFirst({
+ where: {
+ id,
+ deletedAt: null, // Only show active requests
+ },
include: {
audiobook: true,
user: {
@@ -100,13 +103,16 @@ export async function PATCH(
const body = await req.json();
const { action } = body;
- const requestRecord = await prisma.request.findUnique({
- where: { id },
+ const requestRecord = await prisma.request.findFirst({
+ where: {
+ id,
+ deletedAt: null, // Only allow updates to active requests
+ },
});
if (!requestRecord) {
return NextResponse.json(
- { error: 'NotFound', message: 'Request not found' },
+ { error: 'NotFound', message: 'Request not found or already deleted' },
{ status: 404 }
);
}
@@ -161,8 +167,11 @@ export async function PATCH(
if (requestRecord.status === 'warn' || requestRecord.status === 'awaiting_import') {
// Retry import
- const requestWithData = await prisma.request.findUnique({
- where: { id },
+ const requestWithData = await prisma.request.findFirst({
+ where: {
+ id,
+ deletedAt: null,
+ },
include: {
audiobook: true,
downloadHistory: {
@@ -213,8 +222,11 @@ export async function PATCH(
jobType = 'import';
} else {
// Retry search
- const requestWithData = await prisma.request.findUnique({
- where: { id },
+ const requestWithData = await prisma.request.findFirst({
+ where: {
+ id,
+ deletedAt: null,
+ },
include: {
audiobook: true,
},
diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts
index ef6b076..7dfadc6 100644
--- a/src/app/api/requests/route.ts
+++ b/src/app/api/requests/route.ts
@@ -80,13 +80,12 @@ export async function POST(request: NextRequest) {
});
}
- // Check if user already has a request for this audiobook
- const existingRequest = await prisma.request.findUnique({
+ // Check if user already has an active (non-deleted) request for this audiobook
+ const existingRequest = await prisma.request.findFirst({
where: {
- userId_audiobookId: {
- userId: req.user.id,
- audiobookId: audiobookRecord.id,
- },
+ userId: req.user.id,
+ audiobookId: audiobookRecord.id,
+ deletedAt: null, // Only check active requests
},
});
@@ -112,12 +111,15 @@ export async function POST(request: NextRequest) {
});
}
- // Create request
+ // Check if we should skip auto-search (for interactive search)
+ const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
+
+ // Create request with appropriate status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
- status: 'pending',
+ status: skipAutoSearch ? 'awaiting_search' : 'pending',
progress: 0,
},
include: {
@@ -131,13 +133,15 @@ export async function POST(request: NextRequest) {
},
});
- // Trigger search job
- const jobQueue = getJobQueueService();
- await jobQueue.addSearchJob(newRequest.id, {
- id: audiobookRecord.id,
- title: audiobookRecord.title,
- author: audiobookRecord.author,
- });
+ // Trigger search job only if not skipped
+ if (!skipAutoSearch) {
+ const jobQueue = getJobQueueService();
+ await jobQueue.addSearchJob(newRequest.id, {
+ id: audiobookRecord.id,
+ title: audiobookRecord.title,
+ author: audiobookRecord.author,
+ });
+ }
return NextResponse.json({
success: true,
@@ -194,6 +198,8 @@ export async function GET(request: NextRequest) {
if (status) {
where.status = status;
}
+ // Only show active (non-deleted) requests
+ where.deletedAt = null;
const requests = await prisma.request.findMany({
where,
diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx
index 3f834b0..2891f0a 100644
--- a/src/components/audiobooks/AudiobookDetailsModal.tsx
+++ b/src/components/audiobooks/AudiobookDetailsModal.tsx
@@ -13,6 +13,7 @@ import { StatusBadge } from '@/components/requests/StatusBadge';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
+import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
interface AudiobookDetailsModalProps {
asin: string;
@@ -41,6 +42,7 @@ export function AudiobookDetailsModal({
const [showToast, setShowToast] = useState(false);
const [requestError, setRequestError] = useState(null);
const [mounted, setMounted] = useState(false);
+ const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
useEffect(() => {
setMounted(true);
@@ -77,6 +79,29 @@ export function AudiobookDetailsModal({
}
};
+ const handleInteractiveSearch = () => {
+ if (!user || !audiobook) {
+ setRequestError('Please log in to request audiobooks');
+ return;
+ }
+
+ // Just show the interactive search modal - no request created yet
+ setShowInteractiveSearch(true);
+ };
+
+ const handleInteractiveSearchClose = () => {
+ // Clean up state
+ setShowInteractiveSearch(false);
+
+ // Close the details modal too
+ onClose();
+ };
+
+ const handleInteractiveSearchSuccess = () => {
+ // Request was created and torrent was selected successfully
+ onRequestSuccess?.();
+ };
+
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -381,6 +406,35 @@ export function AudiobookDetailsModal({
);
})()}
+ {/* Interactive Search Button - only show if not already available */}
+ {!isAvailable && requestStatus !== 'completed' && (
+
+ )}
+
@@ -407,5 +461,27 @@ export function AudiobookDetailsModal({
);
- return createPortal(modalContent, document.body);
+ return (
+ <>
+ {createPortal(modalContent, document.body)}
+ {/* Interactive Search Modal - render with higher z-index to appear above details modal */}
+ {showInteractiveSearch && audiobook && createPortal(
+ ,
+ document.body
+ )}
+ >
+ );
}
diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx
index 5c5e61e..e4c0ba3 100644
--- a/src/components/requests/InteractiveTorrentSearchModal.tsx
+++ b/src/components/requests/InteractiveTorrentSearchModal.tsx
@@ -10,16 +10,19 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
-import { useInteractiveSearch, useSelectTorrent } from '@/lib/hooks/useRequests';
+import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
+import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface InteractiveTorrentSearchModalProps {
isOpen: boolean;
onClose: () => void;
- requestId: string;
+ requestId?: string; // Optional - only provided when called from existing request
audiobook: {
title: string;
author: string;
};
+ fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
+ onSuccess?: () => void;
}
export function InteractiveTorrentSearchModal({
@@ -27,13 +30,27 @@ export function InteractiveTorrentSearchModal({
onClose,
requestId,
audiobook,
+ fullAudiobook,
+ onSuccess,
}: InteractiveTorrentSearchModalProps) {
- const { searchTorrents, isLoading: isSearching, error: searchError } = useInteractiveSearch();
- const { selectTorrent, isLoading: isDownloading, error: downloadError } = useSelectTorrent();
+ // Hooks for existing request flow
+ const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
+ const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
+
+ // Hooks for new audiobook flow
+ const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
+ const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
+
const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState(null);
- const error = searchError || downloadError;
+ // Determine which mode we're in
+ const hasRequestId = !!requestId;
+ const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook;
+ const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent;
+ const error = hasRequestId
+ ? (searchByRequestError || selectTorrentError)
+ : (searchByAudiobookError || requestWithTorrentError);
// Perform search when modal opens
React.useEffect(() => {
@@ -44,7 +61,14 @@ export function InteractiveTorrentSearchModal({
const performSearch = async () => {
try {
- const data = await searchTorrents(requestId);
+ let data;
+ if (hasRequestId) {
+ // Existing flow: search by requestId
+ data = await searchByRequestId(requestId);
+ } else {
+ // New flow: search by audiobook title/author
+ data = await searchByAudiobook(audiobook.title, audiobook.author);
+ }
setResults(data || []);
} catch (err) {
// Error already handled by hook
@@ -60,7 +84,18 @@ export function InteractiveTorrentSearchModal({
if (!confirmTorrent) return;
try {
- await selectTorrent(requestId, confirmTorrent);
+ if (hasRequestId) {
+ // Existing flow: select torrent for existing request
+ await selectTorrent(requestId, confirmTorrent);
+ } else {
+ // New flow: create request with torrent
+ if (!fullAudiobook) {
+ throw new Error('Audiobook data required to create request');
+ }
+ await requestWithTorrent(fullAudiobook, confirmTorrent);
+ }
+ // Notify parent of successful selection
+ onSuccess?.();
// Close modals on success
setConfirmTorrent(null);
onClose();
diff --git a/src/lib/hooks/useRequests.ts b/src/lib/hooks/useRequests.ts
index 3c32942..294397a 100644
--- a/src/lib/hooks/useRequests.ts
+++ b/src/lib/hooks/useRequests.ts
@@ -84,7 +84,7 @@ export function useCreateRequest() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
- const createRequest = async (audiobook: Audiobook) => {
+ const createRequest = async (audiobook: Audiobook, options?: { skipAutoSearch?: boolean }) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
@@ -93,7 +93,8 @@ export function useCreateRequest() {
setError(null);
try {
- const response = await fetchWithAuth('/api/requests', {
+ const queryParams = options?.skipAutoSearch ? '?skipAutoSearch=true' : '';
+ const response = await fetchWithAuth(`/api/requests${queryParams}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -290,3 +291,91 @@ export function useSelectTorrent() {
return { selectTorrent, isLoading, error };
}
+
+export function useSearchTorrents() {
+ const { accessToken } = useAuth();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const searchTorrents = async (title: string, author: string) => {
+ if (!accessToken) {
+ throw new Error('Not authenticated');
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetchWithAuth('/api/audiobooks/search-torrents', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ title, author }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || 'Failed to search for torrents');
+ }
+
+ return data.results || [];
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ setError(message);
+ throw err;
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return { searchTorrents, isLoading, error };
+}
+
+export function useRequestWithTorrent() {
+ const { accessToken } = useAuth();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const requestWithTorrent = async (audiobook: Audiobook, torrent: any) => {
+ if (!accessToken) {
+ throw new Error('Not authenticated');
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetchWithAuth('/api/audiobooks/request-with-torrent', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ audiobook, torrent }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || 'Failed to create request and download torrent');
+ }
+
+ // Revalidate requests
+ mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
+
+ // Revalidate audiobook lists
+ mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
+
+ return data.request;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ setError(message);
+ throw err;
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return { requestWithTorrent, isLoading, error };
+}
diff --git a/src/lib/processors/cleanup-seeded-torrents.processor.ts b/src/lib/processors/cleanup-seeded-torrents.processor.ts
index b335bda..17b80e7 100644
--- a/src/lib/processors/cleanup-seeded-torrents.processor.ts
+++ b/src/lib/processors/cleanup-seeded-torrents.processor.ts
@@ -44,10 +44,20 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
- // Find all completed requests that have download history
+ // Find all completed requests + soft-deleted requests (orphaned downloads)
const completedRequests = await prisma.request.findMany({
where: {
- status: { in: ['available', 'downloaded'] },
+ OR: [
+ // Active requests with completed downloads
+ {
+ status: { in: ['available', 'downloaded'] },
+ deletedAt: null,
+ },
+ // Soft-deleted requests (orphaned downloads still seeding)
+ {
+ deletedAt: { not: null },
+ },
+ ],
},
include: {
downloadHistory: {
@@ -82,13 +92,13 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
// Find matching indexer configuration by name
const seedingConfig = indexerConfigMap.get(indexerName);
- // If no config found or seeding time is 0 (unlimited), skip
- if (!seedingConfig) {
- noConfig++;
- continue;
- }
-
- if (seedingConfig.seedingTimeMinutes === 0) {
+ // If no config found or seeding time is 0 (unlimited)
+ if (!seedingConfig || seedingConfig.seedingTimeMinutes === 0) {
+ // For soft-deleted requests with unlimited seeding, hard delete immediately
+ if (request.deletedAt) {
+ await prisma.request.delete({ where: { id: request.id } });
+ await logger?.info(`Hard-deleted orphaned request ${request.id} with unlimited seeding`);
+ }
noConfig++;
continue;
}
@@ -122,7 +132,14 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
// Delete torrent and files from qBittorrent
await qbt.deleteTorrent(downloadHistory.downloadClientId, true); // true = delete files
- await logger?.info(`Deleted torrent and files for request ${request.id}`);
+ // If this is a soft-deleted request (orphaned download), hard delete it now
+ if (request.deletedAt) {
+ await prisma.request.delete({ where: { id: request.id } });
+ await logger?.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
+ } else {
+ await logger?.info(`Deleted torrent and files for active request ${request.id}`);
+ }
+
cleaned++;
} catch (error) {
await logger?.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts
index 874c595..be83f09 100644
--- a/src/lib/processors/monitor-download.processor.ts
+++ b/src/lib/processors/monitor-download.processor.ts
@@ -106,15 +106,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
});
// Get request with audiobook details
- const request = await prisma.request.findUnique({
- where: { id: requestId },
+ const request = await prisma.request.findFirst({
+ where: {
+ id: requestId,
+ deletedAt: null,
+ },
include: {
audiobook: true,
},
});
if (!request || !request.audiobook) {
- throw new Error('Request or audiobook not found');
+ throw new Error('Request or audiobook not found or deleted');
}
// Trigger organize files job (target path determined by database config)
diff --git a/src/lib/processors/monitor-rss-feeds.processor.ts b/src/lib/processors/monitor-rss-feeds.processor.ts
index 9655f8d..91ef0ac 100644
--- a/src/lib/processors/monitor-rss-feeds.processor.ts
+++ b/src/lib/processors/monitor-rss-feeds.processor.ts
@@ -57,9 +57,12 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
return { success: true, message: 'No RSS results', matched: 0 };
}
- // Get all requests awaiting search (missing audiobooks)
+ // Get all active requests awaiting search (missing audiobooks)
const missingRequests = await prisma.request.findMany({
- where: { status: 'awaiting_search' },
+ where: {
+ status: 'awaiting_search',
+ deletedAt: null,
+ },
include: { audiobook: true },
take: 100,
});
diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts
index 9f14f10..885f2db 100644
--- a/src/lib/processors/organize-files.processor.ts
+++ b/src/lib/processors/organize-files.processor.ts
@@ -114,25 +114,33 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
- // Check if this is a "no files found" error that should be retried
- const isNoFilesError = errorMessage.includes('No audiobook files found');
+ // Check if this is a retryable error (transient filesystem issues or no files found)
+ const isRetryableError =
+ errorMessage.includes('No audiobook files found') ||
+ errorMessage.includes('ENOENT') || // File/directory not found
+ errorMessage.includes('no such file or directory') ||
+ errorMessage.includes('EACCES') || // Permission denied (might be temporary)
+ errorMessage.includes('EPERM'); // Operation not permitted (might be temporary)
- if (isNoFilesError) {
+ if (isRetryableError) {
// Get current request to check retry count
- const currentRequest = await prisma.request.findUnique({
- where: { id: requestId },
+ const currentRequest = await prisma.request.findFirst({
+ where: {
+ id: requestId,
+ deletedAt: null,
+ },
select: { importAttempts: true, maxImportRetries: true },
});
if (!currentRequest) {
- throw new Error('Request not found');
+ throw new Error('Request not found or deleted');
}
const newAttempts = currentRequest.importAttempts + 1;
if (newAttempts < currentRequest.maxImportRetries) {
// Still have retries left - queue for re-import
- await logger?.warn(`No files found for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
+ await logger?.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
await prisma.request.update({
where: { id: requestId },
@@ -147,7 +155,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
return {
success: false,
- message: 'No audiobook files found, queued for re-import',
+ message: 'Retryable error detected, queued for re-import',
requestId,
attempts: newAttempts,
maxRetries: currentRequest.maxImportRetries,
diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts
index de072bd..2ae6283 100644
--- a/src/lib/processors/plex-recently-added.processor.ts
+++ b/src/lib/processors/plex-recently-added.processor.ts
@@ -135,7 +135,10 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
// Check for downloaded requests to match
const downloadedRequests = await prisma.request.findMany({
- where: { status: 'downloaded' },
+ where: {
+ status: 'downloaded',
+ deletedAt: null,
+ },
include: { audiobook: true },
take: 50,
});
diff --git a/src/lib/processors/retry-failed-imports.processor.ts b/src/lib/processors/retry-failed-imports.processor.ts
index 05aee43..494682f 100644
--- a/src/lib/processors/retry-failed-imports.processor.ts
+++ b/src/lib/processors/retry-failed-imports.processor.ts
@@ -8,6 +8,7 @@
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
+import { getConfigService } from '../services/config.service';
export interface RetryFailedImportsPayload {
jobId?: string;
@@ -21,10 +22,11 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
await logger?.info('Starting retry job for requests awaiting import...');
try {
- // Find all requests in awaiting_import status
+ // Find all active requests in awaiting_import status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_import',
+ deletedAt: null,
},
include: {
audiobook: true,
@@ -57,17 +59,64 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
// Get the download path from the most recent download history
const downloadHistory = request.downloadHistory[0];
- if (!downloadHistory || !downloadHistory.downloadClientId) {
+ if (!downloadHistory) {
await logger?.warn(`No download history found for request ${request.id}, skipping`);
skipped++;
continue;
}
- // Get download path from qBittorrent
- const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
- const qbt = await getQBittorrentService();
- const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
- const downloadPath = `${torrent.save_path}/${torrent.name}`;
+ let downloadPath: string;
+
+ // Try to get download path from qBittorrent if we have the torrent
+ if (downloadHistory.downloadClientId) {
+ try {
+ const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
+ const qbt = await getQBittorrentService();
+ const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
+ downloadPath = `${torrent.save_path}/${torrent.name}`;
+ await logger?.info(`Got download path from qBittorrent for request ${request.id}: ${downloadPath}`);
+ } catch (qbtError) {
+ // Torrent not found in qBittorrent - try to construct path from config
+ await logger?.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
+
+ if (!downloadHistory.torrentName) {
+ await logger?.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
+ skipped++;
+ continue;
+ }
+
+ const configService = getConfigService();
+ const downloadDir = await configService.get('download_dir');
+
+ if (!downloadDir) {
+ await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
+ skipped++;
+ continue;
+ }
+
+ downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
+ await logger?.info(`Using fallback download path for request ${request.id}: ${downloadPath}`);
+ }
+ } else {
+ // No download client ID - use fallback path
+ if (!downloadHistory.torrentName) {
+ await logger?.warn(`No download client ID or torrent name for request ${request.id}, skipping`);
+ skipped++;
+ continue;
+ }
+
+ const configService = getConfigService();
+ const downloadDir = await configService.get('download_dir');
+
+ if (!downloadDir) {
+ await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
+ skipped++;
+ continue;
+ }
+
+ downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
+ await logger?.info(`Using configured download path for request ${request.id}: ${downloadPath}`);
+ }
await jobQueue.addOrganizeJob(
request.id,
diff --git a/src/lib/processors/retry-missing-torrents.processor.ts b/src/lib/processors/retry-missing-torrents.processor.ts
index 0322bdb..9922727 100644
--- a/src/lib/processors/retry-missing-torrents.processor.ts
+++ b/src/lib/processors/retry-missing-torrents.processor.ts
@@ -21,10 +21,11 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
await logger?.info('Starting retry job for requests awaiting search...');
try {
- // Find all requests in awaiting_search status
+ // Find all active requests in awaiting_search status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_search',
+ deletedAt: null,
},
include: {
audiobook: true,
diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts
index 1d6f7a4..48dcc60 100644
--- a/src/lib/processors/scan-plex.processor.ts
+++ b/src/lib/processors/scan-plex.processor.ts
@@ -140,7 +140,10 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise {
// 5. Match downloaded requests against library
await logger?.info(`Checking for downloaded requests to match...`);
const downloadedRequests = await prisma.request.findMany({
- where: { status: 'downloaded' },
+ where: {
+ status: 'downloaded',
+ deletedAt: null,
+ },
include: { audiobook: true },
take: 50, // Limit to prevent overwhelming
});
diff --git a/src/lib/services/request-delete.service.ts b/src/lib/services/request-delete.service.ts
new file mode 100644
index 0000000..afcbfcd
--- /dev/null
+++ b/src/lib/services/request-delete.service.ts
@@ -0,0 +1,257 @@
+/**
+ * Component: Request Deletion Service
+ * Documentation: documentation/admin-features/request-deletion.md
+ *
+ * Handles soft deletion of requests with intelligent torrent/file cleanup
+ */
+
+import { prisma } from '../db';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+
+export interface DeleteRequestResult {
+ success: boolean;
+ message: string;
+ filesDeleted: boolean;
+ torrentsRemoved: number;
+ torrentsKeptSeeding: number;
+ torrentsKeptUnlimited: number;
+ error?: string;
+}
+
+/**
+ * Soft delete a request with intelligent cleanup of media files and torrents
+ *
+ * Logic:
+ * 1. Check if request exists and is not already deleted
+ * 2. For each download:
+ * - If unlimited seeding (0): Log and keep seeding, no monitoring
+ * - If incomplete download: Delete torrent + files
+ * - If seeding requirement met: Delete torrent + files
+ * - If still seeding: Keep in qBittorrent for cleanup job
+ * 3. Delete media files (title folder only)
+ * 4. Soft delete request (set deletedAt, deletedBy)
+ */
+export async function deleteRequest(
+ requestId: string,
+ adminUserId: string
+): Promise {
+ try {
+ // 1. Find request (only active, non-deleted)
+ const request = await prisma.request.findFirst({
+ where: {
+ id: requestId,
+ deletedAt: null,
+ },
+ include: {
+ audiobook: {
+ select: {
+ id: true,
+ title: true,
+ author: true,
+ },
+ },
+ downloadHistory: {
+ where: {
+ selected: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ take: 1,
+ },
+ },
+ });
+
+ if (!request) {
+ return {
+ success: false,
+ message: 'Request not found or already deleted',
+ filesDeleted: false,
+ torrentsRemoved: 0,
+ torrentsKeptSeeding: 0,
+ torrentsKeptUnlimited: 0,
+ error: 'NotFound',
+ };
+ }
+
+ let torrentsRemoved = 0;
+ let torrentsKeptSeeding = 0;
+ let torrentsKeptUnlimited = 0;
+
+ // 2. Handle downloads & seeding
+ const downloadHistory = request.downloadHistory[0];
+
+ if (downloadHistory && downloadHistory.downloadClientId && downloadHistory.indexerName) {
+ try {
+ // Get indexer seeding configuration
+ const { getConfigService } = await import('./config.service');
+ const configService = getConfigService();
+ const indexersConfigStr = await configService.get('prowlarr_indexers');
+
+ let seedingConfig: any = null;
+ if (indexersConfigStr) {
+ const indexersConfig = JSON.parse(indexersConfigStr);
+ seedingConfig = indexersConfig.find(
+ (idx: any) => idx.name === downloadHistory.indexerName
+ );
+ }
+
+ // Get torrent from qBittorrent
+ const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
+ const qbt = await getQBittorrentService();
+
+ let torrent;
+ try {
+ torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
+ } catch (error) {
+ // Torrent not found in qBittorrent (already removed)
+ console.log(`[RequestDelete] Torrent ${downloadHistory.downloadClientId} not found in qBittorrent, skipping`);
+ }
+
+ if (torrent) {
+ // Torrent exists in qBittorrent
+ const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
+ const isCompleted = downloadHistory.downloadStatus === 'completed';
+
+ if (isUnlimitedSeeding) {
+ // Unlimited seeding - keep in qBittorrent, stop monitoring
+ console.log(
+ `[RequestDelete] Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
+ );
+ torrentsKeptUnlimited++;
+ } else if (!isCompleted) {
+ // Download not completed - delete immediately
+ console.log(
+ `[RequestDelete] Deleting incomplete download: ${torrent.name}`
+ );
+ await qbt.deleteTorrent(downloadHistory.downloadClientId, true);
+ torrentsRemoved++;
+ } else {
+ // Check if seeding requirement is met
+ const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
+ const actualSeedingTime = torrent.seeding_time || 0;
+ const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
+
+ if (hasMetRequirement) {
+ // Seeding requirement met - delete now
+ console.log(
+ `[RequestDelete] Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
+ actualSeedingTime / 60
+ )}/${seedingConfig.seedingTimeMinutes} minutes)`
+ );
+ await qbt.deleteTorrent(downloadHistory.downloadClientId, true);
+ torrentsRemoved++;
+ } else {
+ // Still needs seeding - keep for cleanup job
+ const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
+ console.log(
+ `[RequestDelete] Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
+ );
+ torrentsKeptSeeding++;
+ }
+ }
+ }
+ } catch (error) {
+ console.error(
+ `[RequestDelete] Error handling torrent for request ${requestId}:`,
+ error instanceof Error ? error.message : 'Unknown error'
+ );
+ // Continue with deletion even if torrent handling fails
+ }
+ }
+
+ // 3. Delete media files (title folder only)
+ let filesDeleted = false;
+ try {
+ const { getConfigService } = await import('./config.service');
+ const configService = getConfigService();
+ const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
+
+ // Sanitize author and title for path
+ const sanitizedAuthor = sanitizePath(request.audiobook.author);
+ const sanitizedTitle = sanitizePath(request.audiobook.title);
+
+ // Build path: [media_dir]/[author]/[title]/
+ const titleFolderPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle);
+
+ // Check if folder exists
+ try {
+ await fs.access(titleFolderPath);
+
+ // Delete the title folder (not the author folder)
+ await fs.rm(titleFolderPath, { recursive: true, force: true });
+
+ console.log(`[RequestDelete] Deleted media directory: ${titleFolderPath}`);
+ filesDeleted = true;
+ } catch (accessError) {
+ // Folder doesn't exist - that's okay
+ console.log(
+ `[RequestDelete] Media directory not found (already deleted?): ${titleFolderPath}`
+ );
+ filesDeleted = false;
+ }
+ } catch (error) {
+ console.error(
+ `[RequestDelete] Error deleting media files for request ${requestId}:`,
+ error instanceof Error ? error.message : 'Unknown error'
+ );
+ // Continue with soft delete even if file deletion fails
+ }
+
+ // 4. Soft delete request
+ await prisma.request.update({
+ where: { id: requestId },
+ data: {
+ deletedAt: new Date(),
+ deletedBy: adminUserId,
+ },
+ });
+
+ console.log(
+ `[RequestDelete] Request ${requestId} soft-deleted by admin ${adminUserId}`
+ );
+
+ return {
+ success: true,
+ message: 'Request deleted successfully',
+ filesDeleted,
+ torrentsRemoved,
+ torrentsKeptSeeding,
+ torrentsKeptUnlimited,
+ };
+ } catch (error) {
+ console.error(
+ `[RequestDelete] Failed to delete request ${requestId}:`,
+ error instanceof Error ? error.message : 'Unknown error'
+ );
+
+ return {
+ success: false,
+ message: 'Failed to delete request',
+ filesDeleted: false,
+ torrentsRemoved: 0,
+ torrentsKeptSeeding: 0,
+ torrentsKeptUnlimited: 0,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
+
+/**
+ * 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()
+ );
+}