mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
534 lines
22 KiB
Plaintext
534 lines
22 KiB
Plaintext
/**
|
|
* 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[]
|
|
reportedIssues ReportedIssue[] @relation("Reporter")
|
|
resolvedIssues ReportedIssue[] @relation("Resolver")
|
|
|
|
@@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
|
|
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
|
|
// ============================================================================
|
|
|
|
model GoodreadsShelf {
|
|
id String @id @default(uuid())
|
|
userId String @map("user_id")
|
|
name String // Extracted from RSS <title>
|
|
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")
|
|
}
|
|
|
|
model GoodreadsBookMapping {
|
|
id String @id @default(uuid())
|
|
goodreadsBookId String @unique @map("goodreads_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")
|
|
|
|
@@index([goodreadsBookId])
|
|
@@index([audibleAsin])
|
|
@@map("goodreads_book_mappings")
|
|
}
|