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 */} + + ); +} 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: +

+ +

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() + ); +}