mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add admin request deletion with soft delete and cleanup
Implements admin ability to delete requests with soft delete, media file cleanup, and seeding-aware torrent management. Adds new API endpoint, frontend confirmation dialog, and request actions dropdown. Updates database schema with deletedAt and deletedBy fields, and ensures all queries filter out deleted requests. Documentation added for feature and user flow.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
+133
-130
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
} sm:mx-0 sm:h-10 sm:w-10`}
|
||||
>
|
||||
<svg
|
||||
className={`h-6 w-6 ${
|
||||
confirmVariant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{confirmVariant === 'danger' ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left flex-1">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex w-full justify-center rounded-lg px-4 py-2 text-sm font-semibold shadow-sm sm:w-auto transition-colors ${confirmButtonClasses}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-lg bg-white dark:bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
|
||||
@@ -107,6 +226,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Completed
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@@ -144,11 +266,53 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
})
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<RequestActionsDropdown
|
||||
request={{
|
||||
requestId: request.requestId,
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
status: request.status,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
onCancel={handleCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
title="Delete Request?"
|
||||
message={
|
||||
selectedRequest ? (
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
This will delete the request for "{selectedRequest.title}" and:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>Remove the request (allowing it to be re-requested)</li>
|
||||
<li>Delete files from the media directory</li>
|
||||
<li>Keep torrent seeding if time remaining</li>
|
||||
</ul>
|
||||
<p className="mt-3 font-semibold">Are you sure?</p>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
confirmLabel={isDeleting ? 'Deleting...' : 'Delete'}
|
||||
cancelLabel="Cancel"
|
||||
confirmVariant="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
onCancel: (requestId: string) => Promise<void>;
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Three-dot menu button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Actions"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
|
||||
<div className="py-1" role="menu">
|
||||
{/* Manual Search */}
|
||||
{canSearch && (
|
||||
<button
|
||||
onClick={handleManualSearch}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Manual Search
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Interactive Search */}
|
||||
{canSearch && (
|
||||
<button
|
||||
onClick={handleInteractiveSearch}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
Interactive Search
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search actions and other actions */}
|
||||
{canSearch && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="w-full text-left px-4 py-2 text-sm text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancel Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider before delete */}
|
||||
{canDelete && (canSearch || canCancel) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Delete Request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={() => setShowInteractiveSearch(false)}
|
||||
requestId={request.requestId}
|
||||
audiobook={{
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export async function GET(request: NextRequest) {
|
||||
const activeDownloads = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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' && (
|
||||
<button
|
||||
onClick={handleInteractiveSearch}
|
||||
disabled={!user}
|
||||
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 border-purple-600 bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Interactive Search"
|
||||
aria-label="Interactive Search"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/* Tooltip */}
|
||||
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
Interactive Search
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Button onClick={onClose} variant="outline" size="lg">
|
||||
Close
|
||||
</Button>
|
||||
@@ -407,5 +461,27 @@ export function AudiobookDetailsModal({
|
||||
</div>
|
||||
);
|
||||
|
||||
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(
|
||||
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
|
||||
<div style={{ pointerEvents: 'auto' }}>
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={handleInteractiveSearchClose}
|
||||
onSuccess={handleInteractiveSearchSuccess}
|
||||
audiobook={{
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}}
|
||||
fullAudiobook={audiobook}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TorrentResult | null>(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();
|
||||
|
||||
@@ -84,7 +84,7 @@ export function useCreateRequest() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string | null>(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<string | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -140,7 +140,10 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
// 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
|
||||
});
|
||||
|
||||
@@ -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<DeleteRequestResult> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user