/** * 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) * - audiobooks: User-requested audiobooks only (created on request) * - Matching happens at QUERY TIME by comparing audible_cache against plex_library */ generator client { provider = "prisma-client-js" output = "../src/generated/prisma" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ============================================================================ // MODELS // ============================================================================ 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") // Plex Home profile tracking 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' // 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") // Request approval preferences autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user // Relations requests Request[] bookDateRecommendations BookDateRecommendation[] bookDateSwipes BookDateSwipe[] @@index([plexId]) @@index([role]) @@index([deletedAt]) @@map("users") } // ============================================================================ // AUDIBLE CACHE TABLE // Pure Audible metadata - Popular/New Releases cached from Audible.com // No Plex data, no availability status - just Audible metadata // ============================================================================ model AudibleCache { id String @id @default(uuid()) asin String @unique // Audible Standard Identification Number title String 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") 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") 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]) @@index([author]) @@index([isPopular]) @@index([isNewRelease]) @@index([popularRank]) @@index([newReleaseRank]) @@map("audible_cache") } // ============================================================================ // LIBRARY CACHE TABLE (plex_library for backward compatibility) // Universal library content - Works with Plex or Audiobookshelf backends // Stores complete metadata including ASIN/ISBN for accurate matching // 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 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 // File information 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") @@index([plexGuid]) @@index([title]) @@index([author]) @@index([plexLibraryId]) @@index([asin]) @@index([isbn]) @@map("plex_library") } // ============================================================================ // AUDIOBOOK TABLE (Simplified) // Only created when user requests an audiobook // Links to AudibleCache for metadata (optional - search results may not be cached) // ============================================================================ model Audiobook { 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 year Int? // Release year extracted from releaseDate // Request tracking 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") // Plex integration (populated after successful import) 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 createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") // Relations requests Request[] @@index([audibleAsin]) @@index([plexGuid]) @@index([absItemId]) @@index([title]) @@index([author]) @@index([status]) @@map("audiobooks") } model Request { id String @id @default(uuid()) userId String @map("user_id") audiobookId String @map("audiobook_id") status String @default("pending") // Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn // Flow: pending → searching → downloading → processing → downloaded → available (when matched in Plex) progress Int @default(0) // 0-100 priority Int @default(0) errorMessage String? @map("error_message") @db.Text searchAttempts Int @default(0) @map("search_attempts") downloadAttempts Int @default(0) @map("download_attempts") importAttempts Int @default(0) @map("import_attempts") maxImportRetries Int @default(5) @map("max_import_retries") lastSearchAt DateTime? @map("last_search_at") lastImportAt DateTime? @map("last_import_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") // 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[] @@index([userId]) @@index([audiobookId]) @@index([status]) @@index([createdAt(sort: Desc)]) @@index([deletedAt]) @@map("requests") } model DownloadHistory { id String @id @default(uuid()) requestId String @map("request_id") indexerName String @map("indexer_name") torrentName String? @map("torrent_name") torrentHash String? @map("torrent_hash") nzbId String? @map("nzb_id") // SABnzbd NZB ID (mutually exclusive with torrentHash) torrentSizeBytes BigInt? @map("torrent_size_bytes") magnetLink String? @map("magnet_link") @db.Text torrentUrl String? @map("torrent_url") @db.Text seeders Int? leechers Int? qualityScore Int? @map("quality_score") selected Boolean @default(false) downloadClient String? @map("download_client") // qbittorrent, sabnzbd downloadClientId String? @map("download_client_id") downloadStatus String? @map("download_status") // Status values: queued, downloading, completed, failed, stalled downloadError String? @map("download_error") @db.Text startedAt DateTime? @map("started_at") completedAt DateTime? @map("completed_at") createdAt DateTime @default(now()) @map("created_at") // Relations request Request @relation(fields: [requestId], references: [id], onDelete: Cascade) @@index([requestId]) @@index([selected]) @@index([torrentHash]) @@index([nzbId]) @@index([createdAt(sort: Desc)]) @@map("download_history") } model Configuration { id String @id @default(uuid()) key String @unique value String? @db.Text encrypted Boolean @default(false) category String? // plex, indexer, download_client, system, automation description String? @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([key]) @@index([category]) @@map("configuration") } model Job { 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 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? 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") // Relations request Request? @relation(fields: [requestId], references: [id], onDelete: SetNull) events JobEvent[] @@index([requestId]) @@index([type]) @@index([status]) @@index([createdAt(sort: Desc)]) @@map("jobs") } model JobEvent { id String @id @default(uuid()) jobId String @map("job_id") level String // info, warn, error context String // e.g., OrganizeFiles, FileOrganizer, MonitorDownload message String @db.Text metadata Json? // Additional structured data createdAt DateTime @default(now()) @map("created_at") // Relations job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) @@index([jobId]) @@index([createdAt]) @@map("job_events") } 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") @@index([type]) @@index([enabled]) @@map("scheduled_jobs") } // ============================================================================ // BOOKDATE TABLES // AI-powered audiobook recommendation system // Documentation: documentation/features/bookdate-prd.md // ============================================================================ model BookDateConfig { id String @id @default(uuid()) provider String // 'openai' | 'claude' | 'custom' apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256) model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929' baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints) 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") } // Note: BookDateConfig is now a singleton - only ONE record exists globally. // Admin configures this in settings, and all users share the same API key. // 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") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) swipes BookDateSwipe[] @@index([userId, batchId]) @@index([userId, createdAt]) @@map("bookdate_recommendations") } 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") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) recommendation BookDateRecommendation? @relation(fields: [recommendationId], references: [id], onDelete: SetNull) @@index([userId, createdAt]) @@index([recommendationId]) @@map("bookdate_swipes") }