mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
590f089733
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.
455 lines
19 KiB
Plaintext
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")
|
|
}
|