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:
@@ -1,15 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string;
|
||||
type: 'artist' | 'album';
|
||||
type: "artist" | "album";
|
||||
subject: string;
|
||||
targetMbid: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
metadata?: {
|
||||
statusText?: string;
|
||||
currentSource?: "lidarr" | "soulseek";
|
||||
lidarrAttempts?: number;
|
||||
soulseekAttempts?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
@@ -25,7 +31,10 @@ export interface DownloadStatus {
|
||||
* @param pollingInterval - How often to poll in milliseconds (default: 15000)
|
||||
* @param isAuthenticated - Whether the user is authenticated (required to prevent polling when logged out)
|
||||
*/
|
||||
export function useDownloadStatus(pollingInterval: number = 15000, isAuthenticated: boolean = false) {
|
||||
export function useDownloadStatus(
|
||||
pollingInterval: number = 15000,
|
||||
isAuthenticated: boolean = false
|
||||
) {
|
||||
const [status, setStatus] = useState<DownloadStatus>({
|
||||
activeDownloads: [],
|
||||
recentDownloads: [],
|
||||
@@ -57,17 +66,23 @@ export function useDownloadStatus(pollingInterval: number = 15000, isAuthenticat
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const activeDownloads = response.filter(
|
||||
(job) => job.status === 'pending' || job.status === 'processing'
|
||||
(job) =>
|
||||
job.status === "pending" || job.status === "processing"
|
||||
);
|
||||
|
||||
const recentDownloads = response.filter(
|
||||
(job) =>
|
||||
(job.status === 'completed' || job.status === 'failed') &&
|
||||
new Date(job.completedAt || job.createdAt) > fiveMinutesAgo
|
||||
(job.status === "completed" ||
|
||||
job.status === "failed") &&
|
||||
new Date(job.completedAt || job.createdAt) >
|
||||
fiveMinutesAgo
|
||||
);
|
||||
|
||||
const failedDownloads = response.filter(
|
||||
(job) => job.status === 'failed' && new Date(job.completedAt || job.createdAt) > fiveMinutesAgo
|
||||
(job) =>
|
||||
job.status === "failed" &&
|
||||
new Date(job.completedAt || job.createdAt) >
|
||||
fiveMinutesAgo
|
||||
);
|
||||
|
||||
setStatus({
|
||||
@@ -79,23 +94,34 @@ export function useDownloadStatus(pollingInterval: number = 15000, isAuthenticat
|
||||
|
||||
// Continue polling if there are active downloads
|
||||
if (activeDownloads.length > 0) {
|
||||
pollTimeout = setTimeout(pollDownloads, pollingInterval);
|
||||
// Poll faster when downloads are active (5 seconds)
|
||||
pollTimeout = setTimeout(pollDownloads, 5000);
|
||||
} else if (
|
||||
activeDownloads.length === 0 &&
|
||||
response.length > 0
|
||||
) {
|
||||
// Some jobs exist but none active - check again in 10 seconds
|
||||
// to catch newly created jobs
|
||||
pollTimeout = setTimeout(pollDownloads, 10000);
|
||||
} else {
|
||||
// Check again in longer interval if no active downloads (30 seconds)
|
||||
// No downloads at all - check again in 30 seconds
|
||||
pollTimeout = setTimeout(pollDownloads, 30000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to poll download status:', error);
|
||||
console.error("Failed to poll download status:", error);
|
||||
|
||||
// Increment error count
|
||||
errorCount++;
|
||||
|
||||
// Exponential backoff on errors (max 2 minutes)
|
||||
const backoffDelay = Math.min(pollingInterval * Math.pow(2, errorCount), 120000);
|
||||
const backoffDelay = Math.min(
|
||||
pollingInterval * Math.pow(2, errorCount),
|
||||
120000
|
||||
);
|
||||
|
||||
// Silently continue on rate limit errors - don't spam console
|
||||
if (error.message !== 'Too Many Requests') {
|
||||
console.error('Download polling error:', error);
|
||||
if (error.message !== "Too Many Requests") {
|
||||
console.error("Download polling error:", error);
|
||||
}
|
||||
|
||||
// Retry with backoff
|
||||
@@ -112,14 +138,20 @@ export function useDownloadStatus(pollingInterval: number = 15000, isAuthenticat
|
||||
const handleDownloadStatusChanged = () => {
|
||||
pollDownloads();
|
||||
};
|
||||
window.addEventListener('download-status-changed', handleDownloadStatusChanged);
|
||||
window.addEventListener(
|
||||
"download-status-changed",
|
||||
handleDownloadStatusChanged
|
||||
);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollTimeout) {
|
||||
clearTimeout(pollTimeout);
|
||||
}
|
||||
window.removeEventListener('download-status-changed', handleDownloadStatusChanged);
|
||||
window.removeEventListener(
|
||||
"download-status-changed",
|
||||
handleDownloadStatusChanged
|
||||
);
|
||||
};
|
||||
}, [pollingInterval, isAuthenticated]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
@@ -27,6 +27,37 @@ export function useMediaSession() {
|
||||
currentTime,
|
||||
} = useAudio();
|
||||
|
||||
// Track if this device has initiated playback locally
|
||||
// Prevents cross-device media session interference from state sync
|
||||
const hasPlayedLocallyRef = useRef(false);
|
||||
|
||||
// Set flag when playback starts on this device
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
hasPlayedLocallyRef.current = true;
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Reset flag when all media is cleared
|
||||
useEffect(() => {
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) {
|
||||
hasPlayedLocallyRef.current = false;
|
||||
}
|
||||
}, [currentTrack, currentAudiobook, currentPodcast]);
|
||||
|
||||
// Convert relative URLs to absolute (required for iOS)
|
||||
const getAbsoluteUrl = useCallback((url: string): string => {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
// Construct absolute URL
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}${url}`;
|
||||
}
|
||||
return url;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Media Session API is supported
|
||||
if (!("mediaSession" in navigator)) {
|
||||
@@ -34,10 +65,19 @@ export function useMediaSession() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set metadata if this device has initiated playback
|
||||
// Prevents cross-device interference from state sync
|
||||
if (!hasPlayedLocallyRef.current) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metadata when track/audiobook/podcast changes
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
const coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 512)
|
||||
? getAbsoluteUrl(
|
||||
api.getCoverArtUrl(currentTrack.album.coverArt, 512)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
@@ -77,7 +117,9 @@ export function useMediaSession() {
|
||||
});
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
const coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 512)
|
||||
? getAbsoluteUrl(
|
||||
api.getCoverArtUrl(currentAudiobook.coverUrl, 512)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
@@ -119,7 +161,9 @@ export function useMediaSession() {
|
||||
});
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
const coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 512)
|
||||
? getAbsoluteUrl(
|
||||
api.getCoverArtUrl(currentPodcast.coverUrl, 512)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
@@ -175,6 +219,12 @@ export function useMediaSession() {
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
|
||||
// Only register handlers if this device has initiated playback
|
||||
// Prevents cross-device interference from state sync
|
||||
if (!hasPlayedLocallyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register action handlers
|
||||
navigator.mediaSession.setActionHandler("play", () => {
|
||||
resume();
|
||||
@@ -296,5 +346,5 @@ export function useMediaSession() {
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [currentTime, currentTrack, currentAudiobook, currentPodcast]);
|
||||
}, [currentTime, currentTrack, currentAudiobook, currentPodcast, getAbsoluteUrl]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Hook for computing display values from override/canonical fields
|
||||
* Pattern: displayValue = userOverride ?? canonicalValue
|
||||
*/
|
||||
|
||||
interface ArtistDisplayData {
|
||||
name: string;
|
||||
summary: string | null;
|
||||
heroUrl: string | null;
|
||||
genres: string[];
|
||||
hasUserOverrides: boolean;
|
||||
// Original values for tooltip/reset display
|
||||
originalName?: string;
|
||||
originalSummary?: string | null;
|
||||
}
|
||||
|
||||
interface AlbumDisplayData {
|
||||
title: string;
|
||||
year: number | null;
|
||||
coverUrl: string | null;
|
||||
genres: string[];
|
||||
hasUserOverrides: boolean;
|
||||
// Original values for tooltip/reset display
|
||||
originalTitle?: string;
|
||||
originalYear?: number | null;
|
||||
}
|
||||
|
||||
interface TrackDisplayData {
|
||||
title: string;
|
||||
trackNo: number | null;
|
||||
hasUserOverrides: boolean;
|
||||
// Original value for tooltip/reset display
|
||||
originalTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute display data for an artist, merging user overrides with canonical data
|
||||
*/
|
||||
export function useArtistDisplayData(artist: any): ArtistDisplayData {
|
||||
if (!artist) {
|
||||
return {
|
||||
name: "Unknown Artist",
|
||||
summary: null,
|
||||
heroUrl: null,
|
||||
genres: [],
|
||||
hasUserOverrides: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: artist.displayName ?? artist.name ?? "Unknown Artist",
|
||||
summary: artist.userSummary ?? artist.summary ?? artist.bio ?? null,
|
||||
heroUrl: artist.userHeroUrl ?? artist.heroUrl ?? artist.image ?? null,
|
||||
genres: mergeGenres(artist.userGenres, artist.genres ?? artist.tags),
|
||||
hasUserOverrides: artist.hasUserOverrides ?? false,
|
||||
originalName: artist.displayName ? artist.name : undefined,
|
||||
originalSummary: artist.userSummary
|
||||
? artist.summary ?? artist.bio
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute display data for an album, merging user overrides with canonical data
|
||||
*/
|
||||
export function useAlbumDisplayData(album: any): AlbumDisplayData {
|
||||
if (!album) {
|
||||
return {
|
||||
title: "Unknown Album",
|
||||
year: null,
|
||||
coverUrl: null,
|
||||
genres: [],
|
||||
hasUserOverrides: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: album.displayTitle ?? album.title ?? "Unknown Album",
|
||||
year: album.displayYear ?? album.year ?? null,
|
||||
coverUrl: album.userCoverUrl ?? album.coverUrl ?? null,
|
||||
genres: mergeGenres(album.userGenres, album.genres),
|
||||
hasUserOverrides: album.hasUserOverrides ?? false,
|
||||
originalTitle: album.displayTitle ? album.title : undefined,
|
||||
originalYear:
|
||||
album.displayYear !== undefined && album.displayYear !== null
|
||||
? album.year
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute display data for a track, merging user overrides with canonical data
|
||||
*/
|
||||
export function useTrackDisplayData(track: any): TrackDisplayData {
|
||||
if (!track) {
|
||||
return {
|
||||
title: "Unknown Track",
|
||||
trackNo: null,
|
||||
hasUserOverrides: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: track.displayTitle ?? track.title ?? "Unknown Track",
|
||||
trackNo: track.displayTrackNo ?? track.trackNo ?? null,
|
||||
hasUserOverrides: track.hasUserOverrides ?? false,
|
||||
originalTitle: track.displayTitle ? track.title : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge user genres with canonical genres (user genres first for priority)
|
||||
* Deduplicates the result
|
||||
*/
|
||||
function mergeGenres(
|
||||
userGenres?: string[],
|
||||
canonicalGenres?: string[]
|
||||
): string[] {
|
||||
const user = Array.isArray(userGenres) ? userGenres : [];
|
||||
const canonical = Array.isArray(canonicalGenres) ? canonicalGenres : [];
|
||||
|
||||
// Merge with user genres taking precedence, then deduplicate
|
||||
return [...new Set([...user, ...canonical])];
|
||||
}
|
||||
Reference in New Issue
Block a user