Files
ReadMeABook/prisma/schema.prisma
T
kikootwo 590f089733 Add first-class ebook request support and UI
Implements first-class ebook requests with their own type, parent-child relationship to audiobook requests, and separate status flow. Updates database schema and migrations to support 'type' and 'parentRequestId' fields on requests. Adds processors and job types for ebook search and direct HTTP download from Anna's Archive, with FlareSolverr integration for Cloudflare bypass. Enhances admin UI tables and request actions to display and manage ebook requests, including orange badge and source links. Updates documentation to reflect new ebook support, configuration, and behavior.
2026-01-30 15:59:25 -05:00

455 lines
19 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
// 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
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")
// 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[]
@@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")
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
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' | '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")
}