/** * 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' | 'favorites' bookDateFavoriteBookIds String? @map("bookdate_favorite_book_ids") @db.Text // JSON array of PlexLibrary IDs (max 25) 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 // Fine-grained permissions interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny // 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[] goodreadsShelves GoodreadsShelf[] hardcoverShelves HardcoverShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") createdApiTokens ApiToken[] @relation("CreatedApiTokens") apiTokens ApiToken[] @relation("UserApiTokens") watchedSeries WatchedSeries[] watchedAuthors WatchedAuthor[] homeSections UserHomeSection[] @@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("[]") 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]) @@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 cachedLibraryCoverPath String? @map("cached_library_cover_path") @db.Text // Local path to cached library cover image // 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 series String? // Book series name (e.g., "The Mistborn Saga") seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1") seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page // 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 // File hash for accurate library matching (SHA256 of sorted audio filenames) filesHash String? @map("files_hash") @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") // Relations requests Request[] reportedIssues ReportedIssue[] @@index([audibleAsin]) @@index([plexGuid]) @@index([absItemId]) @@index([title]) @@index([author]) @@index([status]) @@index([filesHash]) @@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 (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex) // Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state) progress Int @default(0) // 0-100 priority Int @default(0) errorMessage String? @map("error_message") @db.Text selectedTorrent Json? @map("selected_torrent") // Pre-selected torrent from interactive search (stored when awaiting approval) 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") customSearchTerms String? @map("custom_search_terms") @db.Text lastImportAt DateTime? @map("last_import_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") // Request type: 'audiobook' (default) or 'ebook' // Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled) type String @default("audiobook") // 'audiobook' | 'ebook' parentRequestId String? @map("parent_request_id") // Links ebook request to originating audiobook request // 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[] parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull) childRequests Request[] @relation("EbookParent") @@index([userId]) @@index([audiobookId]) @@index([status]) @@index([createdAt(sort: Desc)]) @@index([deletedAt]) @@index([type]) @@index([parentRequestId]) @@map("requests") } model DownloadHistory { id String @id @default(uuid()) requestId String @map("request_id") indexerName String @map("indexer_name") indexerId Int? @map("indexer_id") // Prowlarr indexer ID for configuration lookup 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, direct (HTTP download for ebooks) 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 downloadPath String? @map("download_path") @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([indexerId]) @@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 // Ebook job types: search_ebook, start_direct_download, monitor_direct_download 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' | 'gemini' | '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) 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") } model NotificationBackend { id String @id @default(uuid()) type String // 'discord' | 'pushover' | 'email' | 'slack' | 'telegram' | 'webhook' name String // User-friendly label config Json // Type-specific config (encrypted sensitive values) events Json @default("[]") // Array of event strings enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([enabled]) @@map("notification_backends") } // ============================================================================ // REPORTED ISSUES TABLE // User-reported problems with available audiobooks (corrupted, wrong book, etc.) // ============================================================================ model ReportedIssue { id String @id @default(uuid()) audiobookId String @map("audiobook_id") reporterId String @map("reporter_id") reason String @db.VarChar(250) status String @default("open") // open, dismissed, replaced resolvedAt DateTime? @map("resolved_at") resolvedById String? @map("resolved_by_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade) reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade) resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull) @@index([audiobookId]) @@index([reporterId]) @@index([status]) @@map("reported_issues") } // ============================================================================ // GOODREADS SYNC TABLES // Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache // ============================================================================ // ============================================================================ // API TOKEN TABLE // Static API tokens for programmatic access (alternative to JWT) // Documentation: documentation/backend/services/api-tokens.md // ============================================================================ model ApiToken { id String @id @default(uuid()) name String // User-friendly label (e.g., "Home Assistant", "Webhook") tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext) tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2") role String @default("user") // Token role: 'admin' or 'user' createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens) userId String @map("user_id") // The user identity this token acts as lastUsedAt DateTime? @map("last_used_at") expiresAt DateTime? @map("expires_at") // null = never expires createdAt DateTime @default(now()) @map("created_at") // Relations createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade) tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade) @@index([tokenHash]) @@index([createdById]) @@index([userId]) @@map("api_tokens") } model GoodreadsShelf { id String @id @default(uuid()) userId String @map("user_id") name String // Extracted from RSS rssUrl String @map("rss_url") @db.Text lastSyncAt DateTime? @map("last_sync_at") bookCount Int? @map("book_count") coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, rssUrl]) @@index([userId]) @@map("goodreads_shelves") } // ============================================================================ // UNIFIED BOOK MAPPING TABLE // Global book-to-ASIN mapping cache shared across all shelf providers. // Uses provider + externalBookId composite key for cross-provider dedup. // ============================================================================ model BookMapping { id String @id @default(uuid()) provider String // "goodreads", "hardcover", etc. externalBookId String @map("external_book_id") title String author String audibleAsin String? @map("audible_asin") coverUrl String? @map("cover_url") @db.Text noMatch Boolean @default(false) @map("no_match") lastSearchAt DateTime? @map("last_search_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@unique([provider, externalBookId]) @@index([provider, externalBookId]) @@index([audibleAsin]) @@map("book_mappings") } // ============================================================================ // HARDCOVER SYNC TABLES // Per-user Hardcover list subscriptions // ============================================================================ model HardcoverShelf { id String @id @default(uuid()) userId String @map("user_id") name String // Extracted from Hardcover API list name or status listId String @map("list_id") // Hardcover List ID or Status ID apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api lastSyncAt DateTime? @map("last_sync_at") bookCount Int? @map("book_count") coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, listId]) @@index([userId]) @@map("hardcover_shelves") } // ============================================================================ // WORKS TABLE // Cross-ASIN audiobook identity mapping — links multiple Audible ASINs // to a single logical work for library matching across editions. // Documentation: documentation/integrations/audible.md // ============================================================================ model Work { id String @id @default(uuid()) title String author String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations asins WorkAsin[] @@index([title]) @@index([author]) @@map("works") } model WorkAsin { id String @id @default(uuid()) workId String @map("work_id") asin String @unique narrator String? durationMinutes Int? @map("duration_minutes") isCanonical Boolean @default(false) @map("is_canonical") source String // 'dedup_auto' | 'admin_manual' createdAt DateTime @default(now()) @map("created_at") // Relations work Work @relation(fields: [workId], references: [id], onDelete: Cascade) @@index([workId]) @@index([asin]) @@map("work_asins") } // ============================================================================ // WATCHED LISTS TABLES // Per-user series and author subscriptions for automatic new-release requests. // Documentation: documentation/features/watched-lists.md // ============================================================================ model WatchedSeries { id String @id @default(uuid()) userId String @map("user_id") seriesAsin String @map("series_asin") seriesTitle String @map("series_title") coverArtUrl String? @map("cover_art_url") @db.Text lastCheckedAt DateTime? @map("last_checked_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, seriesAsin]) @@index([userId]) @@index([seriesAsin]) @@map("watched_series") } model WatchedAuthor { id String @id @default(uuid()) userId String @map("user_id") authorAsin String @map("author_asin") authorName String @map("author_name") coverArtUrl String? @map("cover_art_url") @db.Text lastCheckedAt DateTime? @map("last_checked_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, authorAsin]) @@index([userId]) @@index([authorAsin]) @@map("watched_authors") } // ============================================================================ // USER HOME SECTION TABLE // Per-user configurable home page sections (popular, new_releases, category) // Documentation: documentation/features/home-sections.md // ============================================================================ model UserHomeSection { id String @id @default(uuid()) userId String @map("user_id") sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category' categoryId String? @map("category_id") // Audible category node ID (only for type 'category') categoryName String? @map("category_name") // Display name (only for type 'category') sortOrder Int @map("sort_order") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, sectionType, categoryId]) @@index([userId]) @@index([sortOrder]) @@map("user_home_sections") } // ============================================================================ // AUDIBLE CACHE CATEGORY TABLE // Join table linking AudibleCache entries to Audible categories with ranking // Documentation: documentation/features/home-sections.md // ============================================================================ model AudibleCacheCategory { id String @id @default(uuid()) asin String categoryId String @map("category_id") rank Int lastSyncedAt DateTime @default(now()) @map("last_synced_at") createdAt DateTime @default(now()) @map("created_at") @@unique([asin, categoryId]) @@index([categoryId]) @@index([asin]) @@index([categoryId, rank]) @@map("audible_cache_categories") }