Files
ReadMeABook/prisma/schema.prisma
T
2026-01-28 11:41:24 -05:00

398 lines
16 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'
bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text
bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete")
// Relations
requests Request[]
bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[]
@@index([plexId])
@@index([role])
@@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")
}
// ============================================================================
// PLEX LIBRARY TABLE
// Pure Plex library content - What's actually in your Plex server
// No Audible data - just Plex 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)
// 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])
@@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
// 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, 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")
// 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)])
@@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")
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, transmission
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([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'
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")
}
// 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")
}