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
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+3 -1
View File
@@ -29,7 +29,7 @@ export default function ArtistPage() {
const { isPendingByMbid } = useDownloadContext();
// Data hook
const { artist, albums, loading, error, source, reloadArtist } = useArtistData();
const { artist, albums, loading, error, source, sortBy, setSortBy, reloadArtist } = useArtistData();
// Action hooks
const { playAll, shufflePlay } = useArtistActions();
@@ -209,6 +209,8 @@ export default function ArtistPage() {
albums={ownedAlbums}
colors={colors}
onPlayAlbum={handlePlayAlbum}
sortBy={sortBy}
onSortChange={setSortBy}
/>
{/* Available Albums to Download */}
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/Button';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Route error:', error);
}, [error]);
return (
<div className="flex h-screen items-center justify-center bg-black">
<div className="text-center">
<h2 className="text-2xl font-bold text-white mb-4">
Something went wrong
</h2>
<p className="text-gray-400 mb-6">
{error.message || 'An unexpected error occurred'}
</p>
<Button onClick={reset}>Try again</Button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Global error:', error);
}, [error]);
return (
<html>
<body>
<div className="flex h-screen items-center justify-center bg-black">
<div className="text-center">
<h2 className="text-2xl font-bold text-white mb-4">
Application Error
</h2>
<p className="text-gray-400 mb-6">
{error.message || 'A critical error occurred'}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Reload Application
</button>
</div>
</div>
</body>
</html>
);
}
+15
View File
@@ -198,6 +198,21 @@ button:disabled {
}
}
/* =====================================================
Accessibility - Reduced Motion Support
===================================================== */
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* =====================================================
Android TV - Navigation Bar & Now Playing Only
===================================================== */
+17 -14
View File
@@ -8,6 +8,7 @@ import { ConditionalAudioProvider } from "@/components/providers/ConditionalAudi
import { AuthenticatedLayout } from "@/components/layout/AuthenticatedLayout";
import { QueryProvider } from "@/lib/query-client";
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
import { GlobalErrorBoundary } from "@/components/providers/GlobalErrorBoundary";
const montserrat = Montserrat({
weight: ["300", "400", "500", "600", "700", "800"],
@@ -55,20 +56,22 @@ export default function RootLayout({
className={`${montserrat.variable} antialiased`}
style={{ fontFamily: "var(--font-montserrat)" }}
>
<ServiceWorkerRegistration />
<AuthProvider>
<QueryProvider>
<DownloadProvider>
<ConditionalAudioProvider>
<ToastProvider>
<AuthenticatedLayout>
{children}
</AuthenticatedLayout>
</ToastProvider>
</ConditionalAudioProvider>
</DownloadProvider>
</QueryProvider>
</AuthProvider>
<GlobalErrorBoundary>
<ServiceWorkerRegistration />
<AuthProvider>
<QueryProvider>
<DownloadProvider>
<ConditionalAudioProvider>
<ToastProvider>
<AuthenticatedLayout>
{children}
</AuthenticatedLayout>
</ToastProvider>
</ConditionalAudioProvider>
</DownloadProvider>
</QueryProvider>
</AuthProvider>
</GlobalErrorBoundary>
</body>
</html>
);
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+42 -20
View File
@@ -27,23 +27,32 @@ export default function LibraryPage() {
// Get active tab from URL params, default to "artists"
const activeTab = (searchParams.get("tab") as Tab) || "artists";
// Read page from URL params
const urlPage = parseInt(searchParams.get("page") || "1", 10);
// Filter state (owned = your library, discovery = discovery weekly artists)
const [filter, setFilter] = useState<LibraryFilter>("owned");
// Sort and pagination state
const [sortBy, setSortBy] = useState<SortOption>("name");
const [itemsPerPage, setItemsPerPage] = useState<number>(50);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(40);
const [currentPage, setCurrentPage] = useState(urlPage);
const [showFilters, setShowFilters] = useState(false);
// Sync currentPage with URL changes (browser back/forward)
useEffect(() => {
setCurrentPage(urlPage);
}, [urlPage]);
// Use custom hooks with server-side pagination
const { artists, albums, tracks, isLoading, reloadData, pagination } = useLibraryData({
activeTab,
filter,
sortBy,
itemsPerPage,
currentPage,
});
const { artists, albums, tracks, isLoading, reloadData, pagination } =
useLibraryData({
activeTab,
filter,
sortBy,
itemsPerPage,
currentPage,
});
const {
playArtist,
playAlbum,
@@ -85,6 +94,15 @@ export default function LibraryPage() {
router.push(`/library?tab=${tab}`, { scroll: false });
};
// Update page with URL state and scroll to top
const updatePage = (page: number) => {
const params = new URLSearchParams();
params.set("tab", activeTab);
params.set("page", String(page));
router.push(`/library?${params.toString()}`, { scroll: false });
window.scrollTo({ top: 0, behavior: "smooth" });
};
// Helper to convert library Track to audio context Track format
const formatTracksForAudio = (libraryTracks: typeof tracks) => {
return libraryTracks.map((track) => ({
@@ -248,7 +266,9 @@ export default function LibraryPage() {
{/* Sort Dropdown */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
onChange={(e) =>
setSortBy(e.target.value as SortOption)
}
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
>
<option value="name">Name (A-Z)</option>
@@ -264,13 +284,15 @@ export default function LibraryPage() {
{/* Items per page */}
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
onChange={(e) =>
setItemsPerPage(Number(e.target.value))
}
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
<option value={100}>100 per page</option>
<option value={250}>250 per page</option>
<option value={24}>24 per page</option>
<option value={40}>40 per page</option>
<option value={80}>80 per page</option>
<option value={200}>200 per page</option>
</select>
</div>
)}
@@ -330,7 +352,7 @@ export default function LibraryPage() {
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8 pt-4 border-t border-white/5">
<button
onClick={() => setCurrentPage(1)}
onClick={() => updatePage(1)}
disabled={currentPage === 1 || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -338,7 +360,7 @@ export default function LibraryPage() {
</button>
<button
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
updatePage(Math.max(1, currentPage - 1))
}
disabled={currentPage === 1 || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
@@ -350,8 +372,8 @@ export default function LibraryPage() {
</span>
<button
onClick={() =>
setCurrentPage((p) =>
Math.min(totalPages, p + 1)
updatePage(
Math.min(totalPages, currentPage + 1)
)
}
disabled={currentPage === totalPages || isLoading}
@@ -360,7 +382,7 @@ export default function LibraryPage() {
Next
</button>
<button
onClick={() => setCurrentPage(totalPages)}
onClick={() => updatePage(totalPages)}
disabled={currentPage === totalPages || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+10 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, lazy, Suspense } from "react";
import { LoadingScreen } from "@/components/ui/LoadingScreen";
import { RefreshCw, AudioWaveform } from "lucide-react";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
@@ -16,7 +16,9 @@ import { PodcastsGrid } from "@/features/home/components/PodcastsGrid";
import { AudiobooksGrid } from "@/features/home/components/AudiobooksGrid";
import { FeaturedPlaylistsGrid } from "@/features/home/components/FeaturedPlaylistsGrid";
import { LibraryRadioStations } from "@/features/home/components/LibraryRadioStations";
import { MoodMixer } from "@/components/MoodMixer";
// Lazy load MoodMixer - only loads when user opens it
const MoodMixer = lazy(() => import("@/components/MoodMixer").then(mod => ({ default: mod.MoodMixer })));
// Loading skeleton for playlist cards
function PlaylistSkeleton() {
@@ -163,8 +165,12 @@ export default function HomePage() {
</div>
</div>
{/* Mood Mixer Modal */}
<MoodMixer isOpen={showMoodMixer} onClose={() => setShowMoodMixer(false)} />
{/* Mood Mixer Modal - Lazy loaded */}
{showMoodMixer && (
<Suspense fallback={null}>
<MoodMixer isOpen={showMoodMixer} onClose={() => setShowMoodMixer(false)} />
</Suspense>
)}
</div>
);
}
+14 -1
View File
@@ -16,6 +16,7 @@ import {
Pause,
Trash2,
Shuffle,
Eye,
EyeOff,
ListPlus,
ListMusic,
@@ -195,10 +196,18 @@ export default function PlaylistDetailPage() {
} else {
await api.hidePlaylist(playlistId);
}
// Update local state immediately
queryClient.setQueryData(["playlist", playlistId], (old: any) => ({
...old,
isHidden: !playlist.isHidden,
}));
// Dispatch event to update sidebar and other components
window.dispatchEvent(
new CustomEvent("playlist-updated", { detail: { playlistId } })
);
// Optionally navigate away if hiding
if (!playlist.isHidden) {
router.push("/playlists");
@@ -532,7 +541,11 @@ export default function PlaylistDetailPage() {
: "Hide playlist"
}
>
<EyeOff className="w-5 h-5" />
{playlist.isHidden ? (
<Eye className="w-5 h-5" />
) : (
<EyeOff className="w-5 h-5" />
)}
</button>
{/* Delete Button */}
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+4 -2
View File
@@ -34,7 +34,7 @@ export default function PodcastDetailPage() {
// Extract colors from the proxied image URL
const { colors } = useImageColor(heroImage);
// Action hooks
// Action hooks - pass sortedEpisodes for queue building
const {
isSubscribing,
showDeleteConfirm,
@@ -43,9 +43,10 @@ export default function PodcastDetailPage() {
handleRemovePodcast,
handlePlayEpisode,
handlePlayPauseEpisode,
handleMarkEpisodeComplete,
isEpisodePlaying,
isPlaying,
} = usePodcastActions(podcastId);
} = usePodcastActions(podcastId, sortedEpisodes);
// Loading state
if (isLoading) {
@@ -154,6 +155,7 @@ export default function PodcastDetailPage() {
handlePlayPauseEpisode(episode, podcast)
}
onPlay={(episode) => handlePlayEpisode(episode, podcast)}
onMarkComplete={handleMarkEpisodeComplete}
/>
)}
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+4 -2
View File
@@ -150,7 +150,8 @@ export default function QueuePage() {
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-purple-400 truncate">
{currentTrack.title}
{currentTrack.displayTitle ??
currentTrack.title}
</h3>
<p className="text-sm text-gray-400 truncate">
{currentTrack.artist?.name}
@@ -218,7 +219,8 @@ export default function QueuePage() {
{/* Track Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-white truncate">
{track.title}
{track.displayTitle ??
track.title}
</h3>
<p className="text-sm text-gray-400 truncate">
{track.artist?.name}
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+110 -34
View File
@@ -10,6 +10,7 @@ import { TopResult } from "@/features/search/components/TopResult";
import { EmptyState } from "@/features/search/components/EmptyState";
import { LibraryAlbumsGrid } from "@/features/search/components/LibraryAlbumsGrid";
import { LibraryPodcastsGrid } from "@/features/search/components/LibraryPodcastsGrid";
import { LibraryAudiobooksGrid } from "@/features/search/components/LibraryAudiobooksGrid";
import { LibraryTracksList } from "@/features/search/components/LibraryTracksList";
import { SimilarArtistsGrid } from "@/features/search/components/SimilarArtistsGrid";
import { SoulseekSongsList } from "@/features/search/components/SoulseekSongsList";
@@ -58,6 +59,16 @@ export default function SearchPage() {
const showDiscover = filterTab === "all" || filterTab === "discover";
const showSoulseek = filterTab === "all" || filterTab === "soulseek";
// Determine if we should show the 2-column layout
const hasTopResult = libraryResults?.artists?.[0] || topArtist;
const hasTracks =
libraryResults?.tracks?.length > 0 || soulseekResults.length > 0;
const show2ColumnLayout =
hasSearched &&
hasTopResult &&
hasTracks &&
(showLibrary || showDiscover);
// Handle TV search
const handleTVSearch = (searchQuery: string) => {
setQuery(searchQuery);
@@ -157,41 +168,88 @@ export default function SearchPage() {
</div>
)}
{/* Top Result */}
{hasSearched &&
(showDiscover || showLibrary) &&
(libraryResults?.artists?.[0] || topArtist) && (
<TopResult
libraryArtist={libraryResults?.artists?.[0]}
discoveryArtist={topArtist}
/>
)}
{/* Soulseek Songs */}
{hasSearched && showSoulseek && soulseekResults.length > 0 && (
<section>
<h2 className="text-2xl font-bold text-white mb-6">
Songs
</h2>
<SoulseekSongsList
soulseekResults={soulseekResults}
downloadingFiles={downloadingFiles}
onDownload={handleDownload}
/>
</section>
)}
{/* Library Songs */}
{hasSearched &&
showLibrary &&
libraryResults?.tracks?.length > 0 && (
<section>
{/* 2-Column Layout: Top Result (left) + Songs (right) */}
{show2ColumnLayout ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column: Top Result */}
<div>
<h2 className="text-2xl font-bold text-white mb-6">
Songs in Your Library
Top Result
</h2>
<LibraryTracksList tracks={libraryResults.tracks} />
</section>
)}
<TopResult
libraryArtist={libraryResults?.artists?.[0]}
discoveryArtist={topArtist}
/>
</div>
{/* Right Column: Songs */}
<div>
<h2 className="text-2xl font-bold text-white mb-6">
{showSoulseek && soulseekResults.length > 0
? "Songs"
: "Songs in Your Library"}
</h2>
{showSoulseek && soulseekResults.length > 0 ? (
<SoulseekSongsList
soulseekResults={soulseekResults}
downloadingFiles={downloadingFiles}
onDownload={handleDownload}
/>
) : showLibrary &&
libraryResults?.tracks?.length > 0 ? (
<LibraryTracksList
tracks={libraryResults.tracks}
/>
) : null}
</div>
</div>
) : (
<>
{/* Original single-column layout when not showing 2-column */}
{hasSearched &&
(showDiscover || showLibrary) &&
hasTopResult && (
<div>
<TopResult
libraryArtist={
libraryResults?.artists?.[0]
}
discoveryArtist={topArtist}
/>
</div>
)}
{/* Soulseek Songs */}
{hasSearched &&
showSoulseek &&
soulseekResults.length > 0 && (
<section>
<h2 className="text-2xl font-bold text-white mb-6">
Songs
</h2>
<SoulseekSongsList
soulseekResults={soulseekResults}
downloadingFiles={downloadingFiles}
onDownload={handleDownload}
/>
</section>
)}
{/* Library Songs */}
{hasSearched &&
showLibrary &&
libraryResults?.tracks?.length > 0 && (
<section>
<h2 className="text-2xl font-bold text-white mb-6">
Songs in Your Library
</h2>
<LibraryTracksList
tracks={libraryResults.tracks}
/>
</section>
)}
</>
)}
{/* Library Albums */}
{hasSearched &&
@@ -219,6 +277,21 @@ export default function SearchPage() {
</section>
)}
{/* Library Audiobooks */}
{hasSearched &&
showLibrary &&
libraryResults?.audiobooks &&
libraryResults.audiobooks.length > 0 && (
<section>
<h2 className="text-2xl font-bold text-white mb-6">
Audiobooks
</h2>
<LibraryAudiobooksGrid
audiobooks={libraryResults.audiobooks}
/>
</section>
)}
{/* Similar Artists */}
{hasSearched &&
showDiscover &&
@@ -235,7 +308,10 @@ export default function SearchPage() {
(!libraryResults ||
(!libraryResults.artists?.length &&
!libraryResults.albums?.length &&
!libraryResults.tracks?.length)) && (
!libraryResults.tracks?.length &&
!libraryResults.podcasts?.length &&
!libraryResults.audiobooks?.length &&
!libraryResults.episodes?.length)) && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<SearchIcon className="w-16 h-16 text-gray-700 mb-4" />
<h3 className="text-xl font-bold text-white mb-2">
+5
View File
@@ -0,0 +1,5 @@
import { LoadingScreen } from "@/components/ui/LoadingScreen";
export default function Loading() {
return <LoadingScreen />;
}
+4
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import Image from "next/image";
import { dispatchQueryEvent } from "@/lib/query-events";
export default function SyncPage() {
const router = useRouter();
@@ -58,6 +59,9 @@ export default function SyncPage() {
// Enrichment runs on-demand from Settings page
// Artists get images from Deezer/Fanart when first viewed
// Dispatch event to update Recently Added section
dispatchQueryEvent("library-updated");
setProgress(100);
setMessage("All set! Redirecting...");
redirectTimeout = setTimeout(() => {