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:
Your Name
2026-01-06 20:07:33 -06:00
parent 8fe151a0d1
commit cc8d0f6969
242 changed files with 20562 additions and 7725 deletions
@@ -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 $$;
@@ -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");
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "soulseekConcurrentDownloads" INTEGER NOT NULL DEFAULT 4;
+105 -37
View File
@@ -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])
}