Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
-- Rename soulseekFallback to primaryFailureFallback (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'SystemSettings' AND column_name = 'soulseekFallback'
|
||||
) THEN
|
||||
ALTER TABLE "SystemSettings" RENAME COLUMN "soulseekFallback" TO "primaryFailureFallback";
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add tokenVersion to User table (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'User')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'User' AND column_name = 'tokenVersion'
|
||||
) THEN
|
||||
ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
END IF;
|
||||
END $$;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- Create targetMbid index on DownloadJob (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'DownloadJob')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'DownloadJob' AND indexname = 'DownloadJob_targetMbid_idx'
|
||||
) THEN
|
||||
CREATE INDEX "DownloadJob_targetMbid_idx" ON "DownloadJob"("targetMbid");
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -19,6 +19,7 @@ CREATE TABLE "User" (
|
||||
"twoFactorSecret" TEXT,
|
||||
"twoFactorRecoveryCodes" TEXT,
|
||||
"moodMixParams" JSONB,
|
||||
"tokenVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
@@ -78,7 +79,7 @@ CREATE TABLE "SystemSettings" (
|
||||
"downloadRetryAttempts" INTEGER NOT NULL DEFAULT 3,
|
||||
"transcodeCacheMaxGb" INTEGER NOT NULL DEFAULT 10,
|
||||
"downloadSource" TEXT NOT NULL DEFAULT 'soulseek',
|
||||
"soulseekFallback" TEXT NOT NULL DEFAULT 'none',
|
||||
"primaryFailureFallback" TEXT NOT NULL DEFAULT 'none',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -826,6 +827,9 @@ CREATE INDEX "DownloadJob_lidarrRef_idx" ON "DownloadJob"("lidarrRef");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DownloadJob_artistMbid_idx" ON "DownloadJob"("artistMbid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DownloadJob_targetMbid_idx" ON "DownloadJob"("targetMbid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ListeningState_userId_idx" ON "ListeningState"("userId");
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN "enrichmentConcurrency" INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Album" ADD COLUMN "displayTitle" TEXT,
|
||||
ADD COLUMN "displayYear" INTEGER,
|
||||
ADD COLUMN "hasUserOverrides" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "userCoverUrl" TEXT,
|
||||
ADD COLUMN "userGenres" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Artist" ADD COLUMN "displayName" TEXT,
|
||||
ADD COLUMN "hasUserOverrides" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "userGenres" JSONB,
|
||||
ADD COLUMN "userHeroUrl" TEXT,
|
||||
ADD COLUMN "userSummary" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Track" ADD COLUMN "displayTitle" TEXT,
|
||||
ADD COLUMN "displayTrackNo" INTEGER,
|
||||
ADD COLUMN "hasUserOverrides" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Album_hasUserOverrides_idx" ON "Album"("hasUserOverrides");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Artist_hasUserOverrides_idx" ON "Artist"("hasUserOverrides");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Track_hasUserOverrides_idx" ON "Track"("hasUserOverrides");
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
-- Migration: Add search vector triggers for podcasts and audiobooks
|
||||
-- This migration creates PostgreSQL functions and triggers to automatically
|
||||
-- populate and maintain search vectors for podcast and audiobook content
|
||||
|
||||
-- ============================================================================
|
||||
-- PODCAST SEARCH VECTOR FUNCTION
|
||||
-- ============================================================================
|
||||
-- Function to generate Podcast search vector from title, author, and description
|
||||
CREATE OR REPLACE FUNCTION podcast_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- Combine title, author, and description into search vector
|
||||
-- Using setweight: title (A), author (B), description (C)
|
||||
NEW."searchVector" :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.author, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'C');
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to auto-update Podcast search vector
|
||||
DROP TRIGGER IF EXISTS podcast_search_vector_update ON "Podcast";
|
||||
CREATE TRIGGER podcast_search_vector_update
|
||||
BEFORE INSERT OR UPDATE OF title, author, description
|
||||
ON "Podcast"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION podcast_search_vector_trigger();
|
||||
|
||||
-- ============================================================================
|
||||
-- PODCAST EPISODE SEARCH VECTOR FUNCTION
|
||||
-- ============================================================================
|
||||
-- Function to generate PodcastEpisode search vector from title and description
|
||||
CREATE OR REPLACE FUNCTION podcast_episode_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- Combine title and description into search vector
|
||||
-- Using setweight: title (A), description (B)
|
||||
NEW."searchVector" :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B');
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to auto-update PodcastEpisode search vector
|
||||
DROP TRIGGER IF EXISTS podcast_episode_search_vector_update ON "PodcastEpisode";
|
||||
CREATE TRIGGER podcast_episode_search_vector_update
|
||||
BEFORE INSERT OR UPDATE OF title, description
|
||||
ON "PodcastEpisode"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION podcast_episode_search_vector_trigger();
|
||||
|
||||
-- ============================================================================
|
||||
-- AUDIOBOOK SEARCH VECTOR FUNCTION
|
||||
-- ============================================================================
|
||||
-- Function to generate Audiobook search vector from title, author, narrator, series, and description
|
||||
CREATE OR REPLACE FUNCTION audiobook_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- Combine title, author/narrator/series, and description into search vector
|
||||
-- Using setweight: title (A), author/narrator/series (B), description (C)
|
||||
NEW."searchVector" :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.author, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.narrator, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.series, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'C');
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to auto-update Audiobook search vector
|
||||
DROP TRIGGER IF EXISTS audiobook_search_vector_update ON "Audiobook";
|
||||
CREATE TRIGGER audiobook_search_vector_update
|
||||
BEFORE INSERT OR UPDATE OF title, author, narrator, series, description
|
||||
ON "Audiobook"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION audiobook_search_vector_trigger();
|
||||
|
||||
-- ============================================================================
|
||||
-- ADD SEARCH VECTOR COLUMNS
|
||||
-- ============================================================================
|
||||
-- Add searchVector column to Podcast table
|
||||
ALTER TABLE "Podcast" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
|
||||
|
||||
-- Add searchVector column to PodcastEpisode table
|
||||
ALTER TABLE "PodcastEpisode" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
|
||||
|
||||
-- Add searchVector column to Audiobook table
|
||||
ALTER TABLE "Audiobook" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
|
||||
|
||||
-- ============================================================================
|
||||
-- CREATE GIN INDEXES
|
||||
-- ============================================================================
|
||||
-- Create GIN index on Podcast search vector
|
||||
CREATE INDEX IF NOT EXISTS "Podcast_searchVector_idx" ON "Podcast" USING GIN ("searchVector");
|
||||
|
||||
-- Create GIN index on PodcastEpisode search vector
|
||||
CREATE INDEX IF NOT EXISTS "PodcastEpisode_searchVector_idx" ON "PodcastEpisode" USING GIN ("searchVector");
|
||||
|
||||
-- Create GIN index on Audiobook search vector
|
||||
CREATE INDEX IF NOT EXISTS "Audiobook_searchVector_idx" ON "Audiobook" USING GIN ("searchVector");
|
||||
|
||||
-- ============================================================================
|
||||
-- POPULATE EXISTING RECORDS
|
||||
-- ============================================================================
|
||||
-- Update all existing Podcasts to populate their search vectors
|
||||
UPDATE "Podcast"
|
||||
SET "searchVector" =
|
||||
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(author, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(description, '')), 'C');
|
||||
|
||||
-- Update all existing PodcastEpisodes to populate their search vectors
|
||||
UPDATE "PodcastEpisode"
|
||||
SET "searchVector" =
|
||||
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(description, '')), 'B');
|
||||
|
||||
-- Update all existing Audiobooks to populate their search vectors
|
||||
UPDATE "Audiobook"
|
||||
SET "searchVector" =
|
||||
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(author, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(narrator, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(series, '')), 'B') ||
|
||||
setweight(to_tsvector('english', COALESCE(description, '')), 'C');
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "EnrichmentFailure" (
|
||||
"id" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"entityName" TEXT,
|
||||
"errorMessage" TEXT,
|
||||
"errorCode" TEXT,
|
||||
"retryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxRetries" INTEGER NOT NULL DEFAULT 3,
|
||||
"firstFailedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastFailedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"skipped" BOOLEAN NOT NULL DEFAULT false,
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"resolved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"metadata" JSONB,
|
||||
|
||||
CONSTRAINT "EnrichmentFailure_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EnrichmentFailure_entityType_resolved_idx" ON "EnrichmentFailure"("entityType", "resolved");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EnrichmentFailure_skipped_idx" ON "EnrichmentFailure"("skipped");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EnrichmentFailure_lastFailedAt_idx" ON "EnrichmentFailure"("lastFailedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EnrichmentFailure_entityType_entityId_key" ON "EnrichmentFailure"("entityType", "entityId");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Album" ADD COLUMN "originalYear" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN "lidarrWebhookSecret" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Track" ADD COLUMN "analysisStartedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN "audioAnalyzerWorkers" INTEGER NOT NULL DEFAULT 2;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN "lastfmApiKey" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN "soulseekConcurrentDownloads" INTEGER NOT NULL DEFAULT 4;
|
||||
+105
-37
@@ -18,6 +18,7 @@ model User {
|
||||
twoFactorSecret String? // TOTP secret (encrypted)
|
||||
twoFactorRecoveryCodes String? // Recovery codes (encrypted, comma-separated hashed codes)
|
||||
moodMixParams Json? // Saved mood mix parameters for "Your Mood Mix"
|
||||
tokenVersion Int @default(0) // Incremented on password change to invalidate tokens
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
plays Play[]
|
||||
@@ -77,9 +78,10 @@ model SystemSettings {
|
||||
|
||||
// === Download Services ===
|
||||
// Lidarr
|
||||
lidarrEnabled Boolean @default(true)
|
||||
lidarrUrl String? @default("http://localhost:8686")
|
||||
lidarrApiKey String? // Encrypted
|
||||
lidarrEnabled Boolean @default(true)
|
||||
lidarrUrl String? @default("http://localhost:8686")
|
||||
lidarrApiKey String? // Encrypted
|
||||
lidarrWebhookSecret String? // Encrypted - Shared secret for webhook verification
|
||||
|
||||
// === AI Services ===
|
||||
// OpenAI (for future AI features)
|
||||
@@ -92,6 +94,9 @@ model SystemSettings {
|
||||
fanartEnabled Boolean @default(false)
|
||||
fanartApiKey String? // Encrypted
|
||||
|
||||
// Last.fm (optional user override - app ships with default key)
|
||||
lastfmApiKey String? // Encrypted
|
||||
|
||||
// === Media Services ===
|
||||
// Audiobookshelf
|
||||
audiobookshelfEnabled Boolean @default(false)
|
||||
@@ -118,12 +123,15 @@ model SystemSettings {
|
||||
maxConcurrentDownloads Int @default(3)
|
||||
downloadRetryAttempts Int @default(3)
|
||||
transcodeCacheMaxGb Int @default(10) // Transcode cache size limit in GB
|
||||
enrichmentConcurrency Int @default(1) // 1-5, number of parallel enrichment workers
|
||||
audioAnalyzerWorkers Int @default(2) // 1-8, number of parallel audio analysis workers
|
||||
soulseekConcurrentDownloads Int @default(4) // 1-10, concurrent Soulseek downloads
|
||||
|
||||
// === Download Preferences ===
|
||||
// Primary download source: "soulseek" (per-track) or "lidarr" (full albums)
|
||||
downloadSource String @default("soulseek")
|
||||
// When soulseek is primary and fails: "none" (skip) or "lidarr" (download full album)
|
||||
soulseekFallback String @default("none")
|
||||
downloadSource String @default("soulseek")
|
||||
// Fallback when primary source fails: "none" (skip), "lidarr" (full album), or "soulseek" (track-based)
|
||||
primaryFailureFallback String @default("none")
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
@@ -143,6 +151,13 @@ model Artist {
|
||||
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
// User overrides (optional, takes display precedence)
|
||||
displayName String? // User-provided display name
|
||||
userSummary String? @db.Text // User-provided bio
|
||||
userHeroUrl String? // User-uploaded/linked image
|
||||
userGenres Json? // User-modified genres (array of strings)
|
||||
hasUserOverrides Boolean @default(false) // Quick check flag
|
||||
|
||||
albums Album[]
|
||||
similarFrom SimilarArtist[] @relation("FromArtist")
|
||||
similarTo SimilarArtist[] @relation("ToArtist")
|
||||
@@ -151,6 +166,7 @@ model Artist {
|
||||
@@index([name])
|
||||
@@index([normalizedName])
|
||||
@@index([searchVector], type: Gin)
|
||||
@@index([hasUserOverrides])
|
||||
}
|
||||
|
||||
model Album {
|
||||
@@ -158,7 +174,8 @@ model Album {
|
||||
rgMbid String @unique // release group MBID
|
||||
artistId String
|
||||
title String
|
||||
year Int?
|
||||
year Int? // File metadata date (may be remaster)
|
||||
originalYear Int? // Original release date from MusicBrainz
|
||||
coverUrl String?
|
||||
primaryType String // Album, EP, Single, Live, Compilation
|
||||
label String? // Record label (from MusicBrainz)
|
||||
@@ -167,6 +184,13 @@ model Album {
|
||||
location AlbumLocation @default(LIBRARY) // LIBRARY or DISCOVER
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
// User overrides (optional, takes display precedence)
|
||||
displayTitle String? // User-provided display title
|
||||
displayYear Int? // User-provided year
|
||||
userCoverUrl String? // User-uploaded/linked cover
|
||||
userGenres Json? // User-modified genres (array of strings)
|
||||
hasUserOverrides Boolean @default(false) // Quick check flag
|
||||
|
||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||
tracks Track[]
|
||||
|
||||
@@ -174,6 +198,7 @@ model Album {
|
||||
@@index([location])
|
||||
@@index([title])
|
||||
@@index([searchVector], type: Gin)
|
||||
@@index([hasUserOverrides])
|
||||
}
|
||||
|
||||
model Track {
|
||||
@@ -190,6 +215,11 @@ model Track {
|
||||
fileModified DateTime // mtime for change detection
|
||||
fileSize Int // File size in bytes
|
||||
|
||||
// User overrides (optional, takes display precedence)
|
||||
displayTitle String? // User-provided display title
|
||||
displayTrackNo Int? // User-provided track number
|
||||
hasUserOverrides Boolean @default(false) // Quick check flag
|
||||
|
||||
// === Audio Analysis (Essentia) ===
|
||||
// Rhythm
|
||||
bpm Float? // Beats per minute (e.g., 120.5)
|
||||
@@ -235,13 +265,14 @@ model Track {
|
||||
lastfmTags String[] // ["chill", "workout", "sad", "90s"]
|
||||
|
||||
// Analysis Metadata
|
||||
analysisStatus String @default("pending") // pending, processing, completed, failed
|
||||
analysisVersion String? // Essentia version used
|
||||
analysisMode String? // 'standard' or 'enhanced'
|
||||
analyzedAt DateTime?
|
||||
analysisError String? // Error message if failed
|
||||
analysisRetryCount Int @default(0) // Number of retry attempts
|
||||
updatedAt DateTime @updatedAt
|
||||
analysisStatus String @default("pending") // pending, processing, completed, failed
|
||||
analysisStartedAt DateTime? // When processing began (for timeout detection)
|
||||
analysisVersion String? // Essentia version used
|
||||
analysisMode String? // 'standard' or 'enhanced'
|
||||
analyzedAt DateTime?
|
||||
analysisError String? // Error message if failed
|
||||
analysisRetryCount Int @default(0) // Number of retry attempts
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||
plays Play[]
|
||||
@@ -272,6 +303,7 @@ model Track {
|
||||
@@index([arousal])
|
||||
@@index([acousticness])
|
||||
@@index([instrumentalness])
|
||||
@@index([hasUserOverrides])
|
||||
}
|
||||
|
||||
// Transcoded file cache for audio streaming
|
||||
@@ -479,6 +511,7 @@ model DownloadJob {
|
||||
@@index([startedAt])
|
||||
@@index([lidarrRef])
|
||||
@@index([artistMbid])
|
||||
@@index([targetMbid])
|
||||
}
|
||||
|
||||
model ListeningState {
|
||||
@@ -640,6 +673,9 @@ model Audiobook {
|
||||
audioUrl String // Audiobookshelf streaming URL
|
||||
libraryId String? // Audiobookshelf library ID
|
||||
|
||||
// Full-text search
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -649,6 +685,7 @@ model Audiobook {
|
||||
@@index([author])
|
||||
@@index([series])
|
||||
@@index([lastSyncedAt])
|
||||
@@index([searchVector], type: Gin)
|
||||
}
|
||||
|
||||
model PodcastRecommendation {
|
||||
@@ -676,46 +713,49 @@ model PodcastRecommendation {
|
||||
// ============================================
|
||||
|
||||
model Podcast {
|
||||
id String @id @default(cuid())
|
||||
feedUrl String @unique
|
||||
id String @id @default(cuid())
|
||||
feedUrl String @unique
|
||||
title String
|
||||
author String?
|
||||
description String? @db.Text
|
||||
imageUrl String? // Original feed image URL
|
||||
localCoverPath String? // Local cached cover image path
|
||||
itunesId String? @unique
|
||||
description String? @db.Text
|
||||
imageUrl String? // Original feed image URL
|
||||
localCoverPath String? // Local cached cover image path
|
||||
itunesId String? @unique
|
||||
language String?
|
||||
explicit Boolean @default(false)
|
||||
episodeCount Int @default(0)
|
||||
lastRefreshed DateTime @default(now())
|
||||
refreshInterval Int @default(3600) // seconds (1 hour default)
|
||||
autoRefresh Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
explicit Boolean @default(false)
|
||||
episodeCount Int @default(0)
|
||||
lastRefreshed DateTime @default(now())
|
||||
refreshInterval Int @default(3600) // seconds (1 hour default)
|
||||
autoRefresh Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
episodes PodcastEpisode[]
|
||||
subscriptions PodcastSubscription[]
|
||||
|
||||
@@index([itunesId])
|
||||
@@index([lastRefreshed])
|
||||
@@index([searchVector], type: Gin)
|
||||
}
|
||||
|
||||
model PodcastEpisode {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
podcastId String
|
||||
guid String // RSS GUID (unique per feed)
|
||||
guid String // RSS GUID (unique per feed)
|
||||
title String
|
||||
description String? @db.Text
|
||||
audioUrl String // Direct MP3/audio URL from RSS
|
||||
duration Int @default(0) // seconds
|
||||
description String? @db.Text
|
||||
audioUrl String // Direct MP3/audio URL from RSS
|
||||
duration Int @default(0) // seconds
|
||||
publishedAt DateTime
|
||||
episodeNumber Int?
|
||||
season Int?
|
||||
imageUrl String? // Episode-specific image URL
|
||||
localCoverPath String? // Local cached episode cover
|
||||
fileSize Int? // bytes
|
||||
mimeType String? @default("audio/mpeg")
|
||||
createdAt DateTime @default(now())
|
||||
imageUrl String? // Episode-specific image URL
|
||||
localCoverPath String? // Local cached episode cover
|
||||
fileSize Int? // bytes
|
||||
mimeType String? @default("audio/mpeg")
|
||||
createdAt DateTime @default(now())
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
|
||||
progress PodcastProgress[]
|
||||
@@ -723,6 +763,7 @@ model PodcastEpisode {
|
||||
|
||||
@@unique([podcastId, guid])
|
||||
@@index([podcastId, publishedAt])
|
||||
@@index([searchVector], type: Gin)
|
||||
}
|
||||
|
||||
// User podcast subscriptions
|
||||
@@ -976,3 +1017,30 @@ model Notification {
|
||||
@@index([userId, read])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Enrichment Failure Tracking
|
||||
// ============================================
|
||||
|
||||
model EnrichmentFailure {
|
||||
id String @id @default(cuid())
|
||||
entityType String // artist, track, audio
|
||||
entityId String // Artist/Track ID
|
||||
entityName String? // Display name
|
||||
errorMessage String? // Human-readable error
|
||||
errorCode String? // Machine-readable code
|
||||
retryCount Int @default(0)
|
||||
maxRetries Int @default(3)
|
||||
firstFailedAt DateTime @default(now())
|
||||
lastFailedAt DateTime @default(now())
|
||||
skipped Boolean @default(false)
|
||||
skippedAt DateTime?
|
||||
resolved Boolean @default(false)
|
||||
resolvedAt DateTime?
|
||||
metadata Json? // Additional context (filePath, etc.)
|
||||
|
||||
@@unique([entityType, entityId])
|
||||
@@index([entityType, resolved])
|
||||
@@index([skipped])
|
||||
@@index([lastFailedAt])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user