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;