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
@@ -14,6 +14,7 @@ import { ActivityPanel } from "./ActivityPanel";
import { GalaxyBackground } from "../ui/GalaxyBackground";
import { GradientSpinner } from "../ui/GradientSpinner";
import { PWAInstallPrompt } from "../PWAInstallPrompt";
import { PullToRefresh } from "../ui/PullToRefresh";
import { ReactNode } from "react";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
import { useIsTV } from "@/lib/tv-utils";
@@ -84,6 +85,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
if (isTV) {
return (
<PlayerModeWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Skip to main content
</a>
<MediaControlsHandler />
<TVLayout>{children}</TVLayout>
</PlayerModeWrapper>
@@ -94,6 +101,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
if (isMobileOrTablet) {
return (
<PlayerModeWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Skip to main content
</a>
<div className="h-screen bg-black overflow-hidden flex flex-col">
<MediaControlsHandler />
<TopBar />
@@ -110,18 +123,22 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
/>
{/* Main content area with rounded corners */}
<main
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
style={{
marginTop: "58px",
marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}}
>
<GalaxyBackground />
{/* Padding at bottom for mini player floating above */}
<div className="pb-24">{children}</div>
</main>
<PullToRefresh>
<main
id="main-content"
tabIndex={-1}
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative focus:outline-none"
style={{
marginTop: "58px",
marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}}
>
<GalaxyBackground />
{/* Padding at bottom for mini player floating above */}
<div className="pb-24">{children}</div>
</main>
</PullToRefresh>
{/* Mini Player - fixed, positioned above bottom nav */}
<UniversalPlayer />
@@ -137,6 +154,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
// Desktop Layout
return (
<PlayerModeWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Skip to main content
</a>
<div
className="h-screen bg-black overflow-hidden flex flex-col"
style={{ paddingTop: "64px" }}
@@ -145,7 +168,11 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
<TopBar />
<div className="flex-1 flex gap-2 p-2 pt-0 overflow-hidden">
<Sidebar />
<main className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative">
<main
id="main-content"
tabIndex={-1}
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative focus:outline-none"
>
<GalaxyBackground />
{children}
</main>
@@ -43,9 +43,11 @@ export function BottomNavigation() {
if (!isMobileOrTablet) return null;
return (
<nav
<nav
className="fixed bottom-0 left-0 right-0 z-40 bg-black border-t border-white/10"
style={{
role="navigation"
aria-label="Main navigation"
style={{
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
}}
>
@@ -60,10 +62,12 @@ export function BottomNavigation() {
href={item.href}
className={cn(
"flex flex-col items-center justify-center flex-1 h-full py-2 transition-colors",
isActive
? "text-white"
isActive
? "text-white"
: "text-gray-500 active:text-gray-300"
)}
aria-label={item.name}
aria-current={isActive ? "page" : undefined}
>
<Icon
className={cn(
+9 -1
View File
@@ -72,6 +72,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<div
className="fixed inset-0 bg-black/60 z-50 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Sidebar Drawer */}
@@ -109,7 +110,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
</div>
{/* Menu Content */}
<nav className="flex-1 overflow-y-auto py-4">
<nav className="flex-1 overflow-y-auto py-4" role="navigation" aria-label="Mobile menu">
{/* Quick Links Section */}
<div className="px-3 mb-6">
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
@@ -118,6 +119,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/discover"
aria-current={pathname === "/discover" ? "page" : undefined}
aria-label="Discover"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/discover"
@@ -133,6 +136,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/radio"
aria-current={pathname === "/radio" ? "page" : undefined}
aria-label="Radio"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/radio"
@@ -148,6 +153,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/releases"
aria-current={pathname === "/releases" ? "page" : undefined}
aria-label="Releases"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/releases"
@@ -191,6 +198,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
<Link
href="/settings"
aria-current={pathname === "/settings" ? "page" : undefined}
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/settings"
+6 -8
View File
@@ -90,17 +90,9 @@ export function Sidebar() {
// Listen for playlist events to refresh playlists
const handlePlaylistEvent = async () => {
console.log(
"[Sidebar] Playlist event received, refreshing playlists..."
);
if (!isAuthenticated) return;
try {
const data = await api.getPlaylists();
console.log(
"[Sidebar] Playlists refreshed:",
data.length,
"playlists"
);
setPlaylists(data);
} catch (error) {
console.error("Failed to reload playlists:", error);
@@ -215,6 +207,7 @@ export function Sidebar() {
? "bg-[#1DB954] text-black"
: "bg-white/10 text-white hover:bg-white/15 active:scale-95"
)}
aria-label={isSyncing ? "Syncing library" : "Sync library"}
title={isSyncing ? "Syncing..." : "Sync Library"}
>
<RefreshCw
@@ -233,6 +226,7 @@ export function Sidebar() {
? "bg-white text-black"
: "bg-white/10 text-gray-400 hover:text-white hover:bg-white/15 active:scale-95"
)}
aria-label="Settings"
title="Settings"
>
<Settings className="w-4 h-4" />
@@ -247,6 +241,8 @@ export function Sidebar() {
"pt-6 space-y-1",
isMobileOrTablet ? "px-6" : "px-3"
)}
role="navigation"
aria-label="Main navigation"
>
{navigation.map((item) => {
const isActive = pathname === item.href;
@@ -257,6 +253,7 @@ export function Sidebar() {
key={item.name}
href={item.href}
prefetch={false}
aria-current={isActive ? "page" : undefined}
className={cn(
"block rounded-lg transition-all duration-200 group relative overflow-hidden",
isMobileOrTablet ? "px-4 py-3.5" : "px-4 py-3",
@@ -310,6 +307,7 @@ export function Sidebar() {
href="/playlists"
prefetch={false}
className="w-7 h-7 flex items-center justify-center rounded-md bg-white/5 text-gray-400 hover:text-white hover:bg-gradient-to-br hover:from-purple-500 hover:to-pink-500 hover:scale-110 transition-all duration-300 shadow-lg shadow-transparent hover:shadow-purple-500/30 border border-white/5 hover:border-transparent"
aria-label="Create playlist"
title="Create Playlist"
>
<Plus className="w-4 h-4" />
+9 -8
View File
@@ -9,6 +9,7 @@ import { useAudio } from "@/lib/audio-context";
import { api } from "@/lib/api";
import { DPAD_KEYS } from "@/lib/tv-utils";
import { useTVNavigation } from "@/hooks/useTVNavigation";
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
import { RefreshCw, SkipBack, SkipForward, Shuffle, Repeat } from "lucide-react";
const tvNavigation = [
@@ -99,12 +100,8 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
: null;
}
// Format time helper
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// CRITICAL: Clamp currentTime to prevent display of invalid times
const clampedCurrentTime = clampTime(currentTime, duration);
// Sync library
const handleSync = async () => {
@@ -260,7 +257,11 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
{/* Time counter */}
<div className="tv-np-time">
{formatTime(currentTime)} / {formatTime(duration)}
{formatTime(clampedCurrentTime)} / {
playbackType === "podcast" || playbackType === "audiobook"
? formatTimeRemaining(Math.max(0, duration - clampedCurrentTime))
: formatTime(duration)
}
</div>
{/* Shuffle */}
@@ -309,7 +310,7 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
)}
{/* Content */}
<main ref={contentRef} className="tv-content">
<main id="main-content" tabIndex={-1} ref={contentRef} className="tv-content">
{children}
</main>
</>
+25 -6
View File
@@ -44,8 +44,11 @@ export function TopBar() {
// Track download status from context (single source of truth)
const { pendingDownloads, downloadStatus } = useDownloadContext();
const hasActiveDownloads =
downloadStatus.hasActiveDownloads || pendingDownloads.length > 0;
// Only use API-driven state for the icon
// pendingDownloads is optimistic local state that can become stale
const hasActiveDownloads = downloadStatus.hasActiveDownloads;
const hasPendingUploads = pendingDownloads.length > 0 &&
pendingDownloads.some(p => Date.now() - p.timestamp < 30000); // Only count recent pending
const hasFailedDownloads = downloadStatus.failedDownloads.length > 0;
const handleSync = async () => {
@@ -167,6 +170,7 @@ export function TopBar() {
? "bg-white text-black"
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
)}
aria-label="Home"
title="Home"
>
<Home className="w-5 h-5" />
@@ -186,6 +190,7 @@ export function TopBar() {
setSearchQuery(e.target.value)
}
placeholder="Search..."
aria-label="Search"
autoCapitalize="none"
autoCorrect="off"
tabIndex={0}
@@ -241,6 +246,7 @@ export function TopBar() {
? "bg-white text-black"
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white hover:scale-105"
)}
aria-label="Home"
title="Home"
>
<Home className="w-6 h-6" />
@@ -262,6 +268,7 @@ export function TopBar() {
setSearchQuery(e.target.value)
}
placeholder="What do you want to play?"
aria-label="Search"
autoCapitalize="none"
autoCorrect="off"
tabIndex={0}
@@ -276,6 +283,17 @@ export function TopBar() {
<button
onClick={handleSync}
disabled={isPolling}
aria-label={
isPolling
? "Library scan in progress"
: hasActiveDownloads
? `${downloadStatus.activeDownloads.length} download(s) in progress`
: hasPendingUploads
? `${pendingDownloads.length} download(s) starting`
: hasFailedDownloads
? `${downloadStatus.failedDownloads.length} download(s) failed`
: "Sync library"
}
className={cn(
"flex items-center gap-2 px-3 h-10 rounded-full transition-all text-sm font-medium",
isPolling
@@ -290,10 +308,9 @@ export function TopBar() {
isPolling
? "Library scan in progress..."
: hasActiveDownloads
? `${
downloadStatus.activeDownloads
.length + pendingDownloads.length
} download(s) in progress`
? `${downloadStatus.activeDownloads.length} download(s) in progress`
: hasPendingUploads
? `${pendingDownloads.length} download(s) starting...`
: hasFailedDownloads
? `${downloadStatus.failedDownloads.length} download(s) failed`
: "Sync Library"
@@ -316,6 +333,7 @@ export function TopBar() {
? "bg-white text-black"
: "text-white/60 hover:text-white"
)}
aria-label="Settings"
title="Settings"
>
<Settings className="w-5 h-5" />
@@ -323,6 +341,7 @@ export function TopBar() {
<button
onClick={handleLogout}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all text-red-400 hover:text-red-300"
aria-label="Logout"
title="Logout"
>
<Power className="w-5 h-5" />