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
+52 -2
View File
@@ -664,6 +664,21 @@ class ApiClient {
});
}
async cleanupStaleJobs() {
return this.request<{
success: boolean;
cleaned: {
discoveryBatches: { cleaned: number; ids: string[] };
downloadJobs: { cleaned: number; ids: string[] };
spotifyImportJobs: { cleaned: number; ids: string[] };
bullQueues: { cleaned: number; queues: string[] };
};
totalCleaned: number;
}>("/settings/cleanup-stale-jobs", {
method: "POST",
});
}
// System Settings Tests
async testLidarr(url: string, apiKey: string) {
return this.request<any>("/system-settings/test-lidarr", {
@@ -686,10 +701,10 @@ class ApiClient {
});
}
async testLastfm(apiKey: string, apiSecret: string) {
async testLastfm(apiKey: string) {
return this.request<any>("/system-settings/test-lastfm", {
method: "POST",
body: JSON.stringify({ apiKey, apiSecret }),
body: JSON.stringify({ lastfmApiKey: apiKey }),
});
}
@@ -1347,6 +1362,20 @@ class ApiClient {
);
}
async syncLibraryEnrichment() {
return this.request<{
message: string;
description: string;
result: {
artists: number;
tracks: number;
audioQueued: number;
};
}>("/enrichment/sync", {
method: "POST",
});
}
async getEnrichmentProgress() {
return this.request<{
artists: {
@@ -1423,6 +1452,27 @@ class ApiClient {
});
}
async resetArtistMetadata(artistId: string) {
return this.request<{ message: string; artist: any }>(
`/enrichment/artists/${artistId}/reset`,
{ method: "POST" }
);
}
async resetAlbumMetadata(albumId: string) {
return this.request<{ message: string; album: any }>(
`/enrichment/albums/${albumId}/reset`,
{ method: "POST" }
);
}
async resetTrackMetadata(trackId: string) {
return this.request<{ message: string; track: any }>(
`/enrichment/tracks/${trackId}/reset`,
{ method: "POST" }
);
}
// Homepage
async getHomepageGenres(limit = 4) {
return this.request<any[]>(`/homepage/genres?limit=${limit}`);
+44
View File
@@ -48,6 +48,7 @@ interface AudioControlsContextType {
// Podcast methods
playPodcast: (podcast: Podcast) => void;
nextPodcastEpisode: () => void;
// Playback controls
pause: () => void;
@@ -213,6 +214,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
state.setCurrentTrack(track);
state.setCurrentAudiobook(null);
state.setCurrentPodcast(null);
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing tracks
state.setQueue([track]);
state.setCurrentIndex(0);
playback.setIsPlaying(true);
@@ -246,6 +248,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
state.setPlaybackType("track");
state.setCurrentAudiobook(null);
state.setCurrentPodcast(null);
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing tracks
state.setQueue(tracks);
state.setCurrentIndex(startIndex);
state.setCurrentTrack(tracks[startIndex]);
@@ -275,6 +278,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
state.setCurrentAudiobook(audiobook);
state.setCurrentTrack(null);
state.setCurrentPodcast(null);
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing audiobooks
state.setQueue([]);
state.setCurrentIndex(0);
playback.setIsPlaying(true);
@@ -313,6 +317,44 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
playback.setIsPlaying(false);
}, [playback]);
const nextPodcastEpisode = useCallback(() => {
if (!state.podcastEpisodeQueue || state.podcastEpisodeQueue.length === 0) {
pause();
return;
}
if (!state.currentPodcast) {
pause();
return;
}
// Extract episodeId from currentPodcast.id (format: "podcastId:episodeId")
const [podcastId, currentEpisodeId] = state.currentPodcast.id.split(":");
// Find current episode index
const currentIndex = state.podcastEpisodeQueue.findIndex(
(ep) => ep.id === currentEpisodeId
);
// If there's a next episode, play it
if (currentIndex >= 0 && currentIndex < state.podcastEpisodeQueue.length - 1) {
const nextEpisode = state.podcastEpisodeQueue[currentIndex + 1];
// Build the podcast object for playback
playPodcast({
id: `${podcastId}:${nextEpisode.id}`,
title: nextEpisode.title,
podcastTitle: state.currentPodcast.podcastTitle,
coverUrl: state.currentPodcast.coverUrl,
duration: nextEpisode.duration,
progress: nextEpisode.progress || null,
});
} else {
// Last episode, pause and clear queue
pause();
state.setPodcastEpisodeQueue(null);
}
}, [state.podcastEpisodeQueue, state.currentPodcast, playPodcast, pause, state.setPodcastEpisodeQueue]);
const resume = useCallback(() => {
playback.setIsPlaying(true);
}, [playback]);
@@ -809,6 +851,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
playTracks,
playAudiobook,
playPodcast,
nextPodcastEpisode,
pause,
resume,
play,
@@ -836,6 +879,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
playTracks,
playAudiobook,
playPodcast,
nextPodcastEpisode,
pause,
resume,
play,
+25
View File
@@ -10,6 +10,7 @@ import {
ReactNode,
useMemo,
} from "react";
import { useAudioState } from "./audio-state-context";
interface AudioPlaybackContextType {
isPlaying: boolean;
@@ -129,6 +130,30 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
setIsHydrated(true);
}, []);
// Get state from AudioStateContext for position sync
const state = useAudioState();
// Sync currentTime from audiobook/podcast progress when not playing
// This ensures the UI shows the correct saved position on page load
useEffect(() => {
if (!isHydrated) return;
if (isPlaying) return; // Don't override during active playback
const { currentAudiobook, currentPodcast, playbackType } = state;
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
setCurrentTime(currentAudiobook.progress.currentTime);
} else if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
setCurrentTime(currentPodcast.progress.currentTime);
}
}, [
isHydrated,
isPlaying,
state.currentAudiobook?.progress?.currentTime,
state.currentPodcast?.progress?.currentTime,
state.playbackType,
]);
// Cleanup seek lock timeout on unmount
useEffect(() => {
return () => {
+31 -3
View File
@@ -9,6 +9,7 @@ import {
useMemo,
} from "react";
import { api } from "@/lib/api";
import type { Episode } from "@/features/podcast/types";
function queueDebugEnabled(): boolean {
try {
@@ -56,6 +57,10 @@ export interface Track {
album: { title: string; coverArt?: string; id?: string };
duration: number;
filePath?: string;
// Metadata override fields
displayTitle?: string | null;
displayTrackNo?: number | null;
hasUserOverrides?: boolean;
// Audio features for vibe mode visualization
audioFeatures?: {
bpm?: number | null;
@@ -122,6 +127,7 @@ interface AudioStateContextType {
repeatMode: "off" | "one" | "all";
isRepeat: boolean;
shuffleIndices: number[];
podcastEpisodeQueue: Episode[] | null;
// UI state
playerMode: PlayerMode;
@@ -151,6 +157,7 @@ interface AudioStateContextType {
setIsShuffle: (shuffle: SetStateAction<boolean>) => void;
setRepeatMode: (mode: SetStateAction<"off" | "one" | "all">) => void;
setShuffleIndices: (indices: SetStateAction<number[]>) => void;
setPodcastEpisodeQueue: (queue: SetStateAction<Episode[] | null>) => void;
setPlayerMode: (mode: SetStateAction<PlayerMode>) => void;
setPreviousPlayerMode: (mode: SetStateAction<PlayerMode>) => void;
setVolume: (volume: SetStateAction<number>) => void;
@@ -158,7 +165,9 @@ interface AudioStateContextType {
setLastServerSync: (date: SetStateAction<Date | null>) => void;
setRepeatOneCount: (count: SetStateAction<number>) => void;
setVibeMode: (mode: SetStateAction<boolean>) => void;
setVibeSourceFeatures: (features: SetStateAction<AudioFeatures | null>) => void;
setVibeSourceFeatures: (
features: SetStateAction<AudioFeatures | null>
) => void;
setVibeQueueIds: (ids: SetStateAction<string[]>) => void;
}
@@ -179,6 +188,7 @@ const STORAGE_KEYS = {
PLAYER_MODE: "lidify_player_mode",
VOLUME: "lidify_volume",
IS_MUTED: "lidify_muted",
PODCAST_EPISODE_QUEUE: "lidify_podcast_episode_queue",
};
export function AudioStateProvider({ children }: { children: ReactNode }) {
@@ -196,6 +206,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
const [shuffleIndices, setShuffleIndices] = useState<number[]>([]);
const [repeatMode, setRepeatMode] = useState<"off" | "one" | "all">("off");
const [repeatOneCount, setRepeatOneCount] = useState(0);
const [podcastEpisodeQueue, setPodcastEpisodeQueue] = useState<Episode[] | null>(null);
const [playerMode, setPlayerMode] = useState<PlayerMode>("full");
const [previousPlayerMode, setPreviousPlayerMode] =
useState<PlayerMode>("full");
@@ -203,10 +214,11 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
const [isMuted, setIsMuted] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
const [lastServerSync, setLastServerSync] = useState<Date | null>(null);
// Vibe mode state
const [vibeMode, setVibeMode] = useState(false);
const [vibeSourceFeatures, setVibeSourceFeatures] = useState<AudioFeatures | null>(null);
const [vibeSourceFeatures, setVibeSourceFeatures] =
useState<AudioFeatures | null>(null);
const [vibeQueueIds, setVibeQueueIds] = useState<string[]>([]);
// Restore state from localStorage on mount
@@ -230,6 +242,9 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
const savedRepeatMode = localStorage.getItem(
STORAGE_KEYS.REPEAT_MODE
);
const savedPodcastQueue = localStorage.getItem(
STORAGE_KEYS.PODCAST_EPISODE_QUEUE
);
const savedPlayerMode = localStorage.getItem(
STORAGE_KEYS.PLAYER_MODE
);
@@ -297,6 +312,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
if (savedShuffle) setIsShuffle(savedShuffle === "true");
if (savedRepeatMode)
setRepeatMode(savedRepeatMode as "off" | "one" | "all");
if (savedPodcastQueue) setPodcastEpisodeQueue(JSON.parse(savedPodcastQueue));
if (savedVolume) setVolume(parseFloat(savedVolume));
if (savedMuted) setIsMuted(savedMuted === "true");
if (savedPlayerMode) setPlayerMode(savedPlayerMode as PlayerMode);
@@ -420,6 +436,14 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
);
localStorage.setItem(STORAGE_KEYS.IS_SHUFFLE, isShuffle.toString());
localStorage.setItem(STORAGE_KEYS.REPEAT_MODE, repeatMode);
if (podcastEpisodeQueue) {
localStorage.setItem(
STORAGE_KEYS.PODCAST_EPISODE_QUEUE,
JSON.stringify(podcastEpisodeQueue)
);
} else {
localStorage.removeItem(STORAGE_KEYS.PODCAST_EPISODE_QUEUE);
}
localStorage.setItem(STORAGE_KEYS.PLAYER_MODE, playerMode);
localStorage.setItem(STORAGE_KEYS.VOLUME, volume.toString());
localStorage.setItem(STORAGE_KEYS.IS_MUTED, isMuted.toString());
@@ -435,6 +459,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
currentIndex,
isShuffle,
repeatMode,
podcastEpisodeQueue,
playerMode,
volume,
isMuted,
@@ -669,6 +694,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
repeatMode,
isRepeat: repeatMode !== "off",
shuffleIndices,
podcastEpisodeQueue,
playerMode,
previousPlayerMode,
volume,
@@ -688,6 +714,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
setIsShuffle,
setRepeatMode,
setShuffleIndices,
setPodcastEpisodeQueue,
setPlayerMode,
setPreviousPlayerMode,
setVolume,
@@ -708,6 +735,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
isShuffle,
repeatMode,
shuffleIndices,
podcastEpisodeQueue,
playerMode,
previousPlayerMode,
volume,
+35 -8
View File
@@ -54,23 +54,28 @@ export function DownloadProvider({ children }: { children: ReactNode }) {
// Remove pending downloads that have completed or failed
setPendingDownloads((prev) => {
return prev.filter((pending) => {
// Check if this MBID has a job that's completed or failed
// Check if this MBID has an active job being tracked by API
const hasActiveJob = downloadStatus.activeDownloads.some(
(job) => job.targetMbid === pending.mbid
);
// If job is now being tracked by the API, remove from local pending
if (hasActiveJob) {
return false;
}
// Check if this MBID has completed or failed
const matchingJob = [
...downloadStatus.activeDownloads,
...downloadStatus.recentDownloads,
...downloadStatus.failedDownloads,
].find((job) => job.targetMbid === pending.mbid);
// If job is completed or failed, remove from pending
if (
matchingJob &&
(matchingJob.status === "completed" ||
matchingJob.status === "failed")
) {
if (matchingJob) {
return false;
}
// Keep if still pending/processing or no job found yet
// Keep if no job found yet
return true;
});
});
@@ -80,6 +85,28 @@ export function DownloadProvider({ children }: { children: ReactNode }) {
downloadStatus.failedDownloads,
]);
// Cleanup pending downloads older than 2 minutes
// This handles cases where jobs fail immediately and don't appear in any API response
useEffect(() => {
const STALE_THRESHOLD = 2 * 60 * 1000; // 2 minutes
const cleanup = setInterval(() => {
setPendingDownloads((prev) => {
const now = Date.now();
const filtered = prev.filter((pending) => {
const age = now - pending.timestamp;
if (age > STALE_THRESHOLD) {
return false;
}
return true;
});
return filtered;
});
}, 30000); // Check every 30 seconds
return () => clearInterval(cleanup);
}, []);
const addPendingDownload = (
type: "artist" | "album",
subject: string,
+199
View File
@@ -0,0 +1,199 @@
/**
* Enrichment API Client
*
* Client-side methods for enrichment control and failure management
*/
import { api } from "./api";
export interface EnrichmentState {
status: "idle" | "running" | "paused" | "stopping";
startedAt?: string;
pausedAt?: string;
stoppedAt?: string;
currentPhase: "artists" | "tracks" | "audio" | null;
lastActivity: string;
stoppingInfo?: {
phase: string;
currentItem: string;
itemsRemaining: number;
};
artists: {
total: number;
completed: number;
failed: number;
current?: string;
};
tracks: {
total: number;
completed: number;
failed: number;
current?: string;
};
audio: {
total: number;
completed: number;
failed: number;
processing: number;
};
}
export interface EnrichmentFailure {
id: string;
entityType: "artist" | "track" | "audio";
entityId: string;
entityName: string | null;
errorMessage: string | null;
errorCode: string | null;
retryCount: number;
maxRetries: number;
firstFailedAt: string;
lastFailedAt: string;
skipped: boolean;
skippedAt: string | null;
resolved: boolean;
resolvedAt: string | null;
metadata: Record<string, unknown> | null;
}
export interface FailureCounts {
artist: number;
track: number;
audio: number;
total: number;
}
export interface ConcurrencyConfig {
concurrency: number;
estimatedSpeed: string;
artistsPerMin: number;
tracksPerMin: number;
}
export interface AnalysisWorkersConfig {
workers: number;
cpuCores: number;
recommended: number;
description: string;
}
export const enrichmentApi = {
/**
* Get detailed enrichment state
*/
getStatus: async (): Promise<EnrichmentState | null> => {
return api.get("/enrichment/status");
},
/**
* Pause enrichment
*/
pause: async (): Promise<{ message: string; state: EnrichmentState }> => {
return api.post("/enrichment/pause", {});
},
/**
* Resume enrichment
*/
resume: async (): Promise<{ message: string; state: EnrichmentState }> => {
return api.post("/enrichment/resume", {});
},
/**
* Stop enrichment
*/
stop: async (): Promise<{ message: string; state: EnrichmentState }> => {
return api.post("/enrichment/stop", {});
},
/**
* Get enrichment failures with filtering
*/
getFailures: async (params?: {
entityType?: "artist" | "track" | "audio";
includeSkipped?: boolean;
includeResolved?: boolean;
limit?: number;
offset?: number;
}): Promise<{ failures: EnrichmentFailure[]; total: number }> => {
const query = new URLSearchParams();
if (params?.entityType) query.set("entityType", params.entityType);
if (params?.includeSkipped) query.set("includeSkipped", "true");
if (params?.includeResolved) query.set("includeResolved", "true");
if (params?.limit) query.set("limit", params.limit.toString());
if (params?.offset) query.set("offset", params.offset.toString());
const queryString = query.toString();
return api.get(
`/enrichment/failures${queryString ? `?${queryString}` : ""}`
);
},
/**
* Get failure counts by type
*/
getFailureCounts: async (): Promise<FailureCounts> => {
return api.get("/enrichment/failures/counts");
},
/**
* Retry specific failures
*/
retryFailures: async (
ids: string[]
): Promise<{ message: string; queued: number }> => {
return api.post("/enrichment/retry", { ids });
},
/**
* Skip failures permanently
*/
skipFailures: async (
ids: string[]
): Promise<{ message: string; count: number }> => {
return api.post("/enrichment/skip", { ids });
},
/**
* Delete a failure record
*/
deleteFailure: async (
id: string
): Promise<{ message: string; count: number }> => {
return api.delete(`/enrichment/failures/${id}`);
},
/**
* Get enrichment concurrency configuration
*/
getConcurrency: async (): Promise<ConcurrencyConfig> => {
return api.get("/enrichment/concurrency");
},
/**
* Set enrichment concurrency (1-5)
*/
setConcurrency: async (concurrency: number): Promise<ConcurrencyConfig> => {
return api.request("/enrichment/concurrency", {
method: "PUT",
body: JSON.stringify({ concurrency }),
});
},
/**
* Get audio analyzer worker configuration
*/
getAnalysisWorkers: async (): Promise<AnalysisWorkersConfig> => {
return api.get("/analysis/workers");
},
/**
* Set audio analyzer worker count (1-8)
*/
setAnalysisWorkers: async (workers: number): Promise<AnalysisWorkersConfig> => {
return api.request("/analysis/workers", {
method: "PUT",
body: JSON.stringify({ workers }),
});
},
};
+16
View File
@@ -0,0 +1,16 @@
/**
* Format large numbers into compact notation (e.g., 5,100,000 → "5.1M")
*/
export function formatListeners(count: number | undefined): string {
if (!count || count === 0) return "Artist";
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M listeners`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}K listeners`;
}
return `${count.toLocaleString()} listeners`;
}
-3
View File
@@ -186,9 +186,6 @@ class HowlerEngine {
this.state.currentSrc
) {
this.retryCount++;
console.log(
`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`
);
// Save src before cleanup
const srcToRetry = this.state.currentSrc;
+65
View File
@@ -0,0 +1,65 @@
/**
* Query Events - Typed event system for cache invalidation
*
* Provides a type-safe wrapper around CustomEvent for triggering React Query cache invalidation.
* Events are dispatched when data changes and listeners refetch queries to update the UI.
*/
/**
* Available query event types
*/
export type QueryEventType =
| "audiobook-progress-updated"
| "podcast-progress-updated"
| "library-updated"
| "mixes-updated"; // Include existing event for consistency
/**
* Event payload interface - can be extended for event-specific data
*/
export interface QueryEventDetail {
[key: string]: unknown;
}
/**
* Dispatch a typed query event
*
* @param eventType - The type of event to dispatch
* @param detail - Optional event payload data
*
* @example
* dispatchQueryEvent("audiobook-progress-updated", { audiobookId: "123" });
*/
export function dispatchQueryEvent(
eventType: QueryEventType,
detail?: QueryEventDetail
): void {
window.dispatchEvent(
new CustomEvent(eventType, { detail: detail || {} })
);
}
/**
* Subscribe to a typed query event
*
* @param eventType - The type of event to listen for
* @param handler - Callback function to execute when event fires
* @returns Cleanup function to remove the event listener
*
* @example
* const unsubscribe = subscribeQueryEvent("audiobook-progress-updated", () => {
* queryClient.refetchQueries({ queryKey: ["audiobook", id] });
* });
* // Later: unsubscribe();
*/
export function subscribeQueryEvent(
eventType: QueryEventType,
handler: (event: CustomEvent<QueryEventDetail>) => void
): () => void {
const listener = handler as EventListener;
window.addEventListener(eventType, listener);
return () => {
window.removeEventListener(eventType, listener);
};
}
+4 -1
View File
@@ -96,7 +96,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full px-4 md:px-0">
<div className="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full px-4 md:px-0" aria-live="polite" aria-atomic="false">
{toasts.map((t) => (
<ToastItem
key={t.id}
@@ -130,6 +130,9 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
return (
<div
role={toast.type === "error" ? "alert" : "status"}
aria-live={toast.type === "error" ? "assertive" : "polite"}
aria-atomic="true"
className={cn(
"flex items-start gap-3 p-4 rounded-sm border shadow-2xl animate-in slide-in-from-right duration-300",
styles[toast.type]