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

View File

@@ -3,9 +3,12 @@
import { Disc3 } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { MetadataEditor } from "@/components/MetadataEditor";
import { Album, AlbumSource } from "../types";
import { ReactNode } from "react";
import { ReactNode, lazy, Suspense } from "react";
import { useAlbumDisplayData } from "@/hooks/useMetadataDisplay";
// Lazy load MetadataEditor - modal component opened on user action
const MetadataEditor = lazy(() => import("@/components/MetadataEditor").then(mod => ({ default: mod.MetadataEditor })));
interface AlbumHeroProps {
album: Album;
@@ -24,6 +27,7 @@ export function AlbumHero({
onReload,
children,
}: AlbumHeroProps) {
const displayData = useAlbumDisplayData(album);
const formatDuration = (seconds?: number) => {
if (!seconds) return "";
const hours = Math.floor(seconds / 3600);
@@ -101,40 +105,55 @@ export function AlbumHero({
<p className="text-xs font-medium text-white/90 mb-1">
Album
</p>
<div className="flex items-center gap-3 group mb-2">
<div className="flex items-center gap-2 group mb-2">
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold text-white leading-tight line-clamp-2">
{album.title}
{displayData.title}
</h1>
{displayData.hasUserOverrides && (
<span className="px-2 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded-full border border-amber-500/30 shrink-0">
Edited
</span>
)}
{source === "library" && (
<MetadataEditor
type="album"
id={album.id}
currentData={{
title: album.title,
year: album.year,
genres: album.genre ? [album.genre] : [],
mbid: album.mbid,
coverUrl: album.coverUrl,
}}
onSave={async () => {
await onReload();
}}
/>
<Suspense fallback={null}>
<MetadataEditor
type="album"
id={album.id}
currentData={{
title: displayData.title,
year: displayData.year,
genres: displayData.genres,
mbid: album.mbid,
coverUrl: displayData.coverUrl,
// Pass originals for reset comparison
_originalTitle: album.title,
_originalYear: album.year,
_originalGenres: album.genre ? [album.genre] : [],
_originalCoverUrl: album.coverUrl,
_hasUserOverrides: displayData.hasUserOverrides,
}}
onSave={async () => {
await onReload();
}}
/>
</Suspense>
)}
</div>
<div className="flex flex-wrap items-center gap-1 text-sm text-white/70 mb-1">
{album.artist && (
<Link
href={`/artist/${album.artist.mbid || album.artist.id}`}
href={`/artist/${
album.artist.mbid || album.artist.id
}`}
className="font-medium text-white hover:underline"
>
{album.artist.name}
</Link>
)}
{album.year && (
{displayData.year && (
<>
<span className="mx-1"></span>
<span>{album.year}</span>
<span>{displayData.year}</span>
</>
)}
{album.trackCount && album.trackCount > 0 && (
@@ -143,9 +162,7 @@ export function AlbumHero({
<span>{album.trackCount} songs</span>
</>
)}
{totalDuration && (
<span>, {totalDuration}</span>
)}
{totalDuration && <span>, {totalDuration}</span>}
</div>
{album.genre && (
<span className="inline-block px-2 py-0.5 bg-white/10 rounded-full text-xs text-white/70">
@@ -158,9 +175,7 @@ export function AlbumHero({
{/* Action Bar - Full Width */}
{children && (
<div className="relative px-4 md:px-8 pb-4">
{children}
</div>
<div className="relative px-4 md:px-8 pb-4">{children}</div>
)}
</div>
);

View File

@@ -1,254 +1,287 @@
import React, { memo, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Play, Pause, Volume2, ListPlus, Plus } from 'lucide-react';
import { cn } from '@/utils/cn';
import type { Track, Album, AlbumSource } from '../types';
import React, { memo, useCallback } from "react";
import { Card } from "@/components/ui/Card";
import { Play, Pause, Volume2, ListPlus, Plus } from "lucide-react";
import { cn } from "@/utils/cn";
import type { Track, Album, AlbumSource } from "../types";
interface TrackListProps {
tracks: Track[];
album: Album;
source: AlbumSource;
currentTrackId: string | undefined;
colors: any;
onPlayTrack: (track: Track, index: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (trackId: string) => void;
previewTrack: string | null;
previewPlaying: boolean;
onPreview: (track: Track, e: React.MouseEvent) => void;
tracks: Track[];
album: Album;
source: AlbumSource;
currentTrackId: string | undefined;
colors: any;
onPlayTrack: (track: Track, index: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (trackId: string) => void;
previewTrack: string | null;
previewPlaying: boolean;
onPreview: (track: Track, e: React.MouseEvent) => void;
}
interface TrackRowProps {
track: Track;
index: number;
album: Album;
isOwned: boolean;
isPlaying: boolean;
isPreviewPlaying: boolean;
colors: any;
onPlayTrack: (track: Track, index: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (trackId: string) => void;
onPreview: (track: Track, e: React.MouseEvent) => void;
track: Track;
index: number;
album: Album;
isOwned: boolean;
isPlaying: boolean;
isPreviewPlaying: boolean;
colors: any;
onPlayTrack: (track: Track, index: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (trackId: string) => void;
onPreview: (track: Track, e: React.MouseEvent) => void;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const formatNumber = (num: number) => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
} else if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
} else if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
};
const TrackRow = memo(function TrackRow({
track,
index,
album,
isOwned,
isPlaying,
isPreviewPlaying,
colors,
onPlayTrack,
onAddToQueue,
onAddToPlaylist,
onPreview,
}: TrackRowProps) {
const isPreviewOnly = !isOwned;
const TrackRow = memo(
function TrackRow({
track,
index,
album,
isOwned,
isPlaying,
isPreviewPlaying,
colors,
onPlayTrack,
onAddToQueue,
onAddToPlaylist,
onPreview,
}: TrackRowProps) {
const isPreviewOnly = !isOwned;
const handleAddToQueue = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onAddToQueue(track);
}, [track, onAddToQueue]);
const handleAddToQueue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddToQueue(track);
},
[track, onAddToQueue]
);
const handleAddToPlaylist = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onAddToPlaylist(track.id);
}, [track.id, onAddToPlaylist]);
const handleAddToPlaylist = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddToPlaylist(track.id);
},
[track.id, onAddToPlaylist]
);
const handlePreview = useCallback((e: React.MouseEvent) => {
onPreview(track, e);
}, [track, onPreview]);
const handlePreview = useCallback(
(e: React.MouseEvent) => {
onPreview(track, e);
},
[track, onPreview]
);
const handlePlayTrack = useCallback(() => {
onPlayTrack(track, index);
}, [track, index, onPlayTrack]);
const handlePlayTrack = useCallback(() => {
onPlayTrack(track, index);
}, [track, index, onPlayTrack]);
const handleRowClick = useCallback((e: React.MouseEvent) => {
// For unowned tracks, play preview instead of local file
if (isPreviewOnly) {
onPreview(track, e);
} else {
onPlayTrack(track, index);
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// For unowned tracks, play preview instead of local file
if (isPreviewOnly) {
onPreview(track, e);
} else {
onPlayTrack(track, index);
}
},
[isPreviewOnly, track, index, onPlayTrack, onPreview]
);
return (
<div
data-track-row
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className={cn(
"group relative flex items-center gap-3 md:gap-4 px-3 md:px-4 py-3 hover:bg-[#141414] transition-colors cursor-pointer",
isPlaying && "bg-[#1a1a1a] border-l-2",
isPreviewOnly && "opacity-70 hover:opacity-90"
)}
style={
isPlaying
? { borderLeftColor: colors?.vibrant || "#a855f7" }
: undefined
}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (isPreviewOnly) {
onPreview(track, e as unknown as React.MouseEvent);
} else {
handlePlayTrack();
}
}
}}
>
<div className="w-6 md:w-8 flex-shrink-0 text-center">
<span
className={cn(
"group-hover:hidden text-sm",
isPlaying
? "text-purple-400 font-bold"
: "text-gray-500"
)}
>
{index + 1}
</span>
<Play
className="hidden group-hover:inline-block w-4 h-4 text-white"
fill="currentColor"
/>
</div>
<div className="flex-1 min-w-0">
<div
className={cn(
"font-medium truncate text-sm md:text-base flex items-center gap-2",
isPlaying ? "text-purple-400" : "text-white"
)}
>
<span className="truncate">
{track.displayTitle ?? track.title}
</span>
{isPreviewOnly && (
<span className="shrink-0 text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30 font-medium">
PREVIEW
</span>
)}
</div>
{track.artist?.name &&
track.artist.name !== album.artist?.name && (
<div className="text-xs md:text-sm text-gray-400 truncate">
{track.artist.name}
</div>
)}
</div>
{isOwned &&
track.playCount !== undefined &&
track.playCount > 0 && (
<div className="hidden lg:flex items-center gap-1.5 text-xs text-gray-400 bg-[#1a1a1a] px-2 py-1 rounded-full">
<Play className="w-3 h-3" />
<span>{formatNumber(track.playCount)}</span>
</div>
)}
{isOwned && (
<>
<button
onClick={handleAddToQueue}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-2 hover:bg-[#2a2a2a] rounded-full transition-all text-gray-400 hover:text-white"
aria-label="Add to queue"
title="Add to queue"
>
<ListPlus className="w-4 h-4" />
</button>
<button
onClick={handleAddToPlaylist}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-2 hover:bg-[#2a2a2a] rounded-full transition-all text-gray-400 hover:text-white"
aria-label="Add to playlist"
title="Add to playlist"
>
<Plus className="w-4 h-4" />
</button>
</>
)}
{isPreviewOnly && (
<button
onClick={handlePreview}
className="p-2 rounded-full bg-[#1a1a1a] hover:bg-[#2a2a2a] transition-colors text-white"
aria-label={
isPreviewPlaying ? "Pause preview" : "Play preview"
}
>
{isPreviewPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
)}
{track.duration && (
<div className="text-xs md:text-sm text-gray-400 w-10 md:w-12 text-right tabular-nums">
{formatDuration(track.duration)}
</div>
)}
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.track.id === nextProps.track.id &&
prevProps.isPlaying === nextProps.isPlaying &&
prevProps.isPreviewPlaying === nextProps.isPreviewPlaying &&
prevProps.index === nextProps.index &&
prevProps.isOwned === nextProps.isOwned
);
}
}, [isPreviewOnly, track, index, onPlayTrack, onPreview]);
return (
<div
data-track-row
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className={cn(
'group relative flex items-center gap-3 md:gap-4 px-3 md:px-4 py-3 hover:bg-[#141414] transition-colors cursor-pointer',
isPlaying && 'bg-[#1a1a1a] border-l-2',
isPreviewOnly && 'opacity-70 hover:opacity-90'
)}
style={
isPlaying
? { borderLeftColor: colors?.vibrant || '#a855f7' }
: undefined
}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isPreviewOnly) {
onPreview(track, e as unknown as React.MouseEvent);
} else {
handlePlayTrack();
}
}
}}
>
<div className="w-6 md:w-8 flex-shrink-0 text-center">
<span
className={cn(
'group-hover:hidden text-sm',
isPlaying ? 'text-purple-400 font-bold' : 'text-gray-500'
)}
>
{index + 1}
</span>
<Play
className="hidden group-hover:inline-block w-4 h-4 text-white"
fill="currentColor"
/>
</div>
<div className="flex-1 min-w-0">
<div className={cn('font-medium truncate text-sm md:text-base flex items-center gap-2', isPlaying ? 'text-purple-400' : 'text-white')}>
<span className="truncate">{track.title}</span>
{isPreviewOnly && (
<span className="shrink-0 text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30 font-medium">
PREVIEW
</span>
)}
</div>
{track.artist?.name && track.artist.name !== album.artist?.name && (
<div className="text-xs md:text-sm text-gray-400 truncate">
{track.artist.name}
</div>
)}
</div>
{isOwned && track.playCount !== undefined && track.playCount > 0 && (
<div className="hidden lg:flex items-center gap-1.5 text-xs text-gray-400 bg-[#1a1a1a] px-2 py-1 rounded-full">
<Play className="w-3 h-3" />
<span>{formatNumber(track.playCount)}</span>
</div>
)}
{isOwned && (
<>
<button
onClick={handleAddToQueue}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-2 hover:bg-[#2a2a2a] rounded-full transition-all text-gray-400 hover:text-white"
aria-label="Add to queue"
title="Add to queue"
>
<ListPlus className="w-4 h-4" />
</button>
<button
onClick={handleAddToPlaylist}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-2 hover:bg-[#2a2a2a] rounded-full transition-all text-gray-400 hover:text-white"
aria-label="Add to playlist"
title="Add to playlist"
>
<Plus className="w-4 h-4" />
</button>
</>
)}
{isPreviewOnly && (
<button
onClick={handlePreview}
className="p-2 rounded-full bg-[#1a1a1a] hover:bg-[#2a2a2a] transition-colors text-white"
aria-label={isPreviewPlaying ? 'Pause preview' : 'Play preview'}
>
{isPreviewPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
)}
{track.duration && (
<div className="text-xs md:text-sm text-gray-400 w-10 md:w-12 text-right tabular-nums">
{formatDuration(track.duration)}
</div>
)}
</div>
);
}, (prevProps, nextProps) => {
return (
prevProps.track.id === nextProps.track.id &&
prevProps.isPlaying === nextProps.isPlaying &&
prevProps.isPreviewPlaying === nextProps.isPreviewPlaying &&
prevProps.index === nextProps.index &&
prevProps.isOwned === nextProps.isOwned
);
});
);
export const TrackList = memo(function TrackList({
tracks,
album,
source,
currentTrackId,
colors,
onPlayTrack,
onAddToQueue,
onAddToPlaylist,
previewTrack,
previewPlaying,
onPreview,
tracks,
album,
source,
currentTrackId,
colors,
onPlayTrack,
onAddToQueue,
onAddToPlaylist,
previewTrack,
previewPlaying,
onPreview,
}: TrackListProps) {
const isOwned = source === 'library';
const isOwned = source === "library";
return (
<section>
<Card>
<div data-tv-section="tracks" className="divide-y divide-[#1c1c1c]">
{tracks.map((track, index) => {
const isPlaying = currentTrackId === track.id;
const isPreviewPlaying = previewTrack === track.id && previewPlaying;
return (
<section>
<Card>
<div
data-tv-section="tracks"
className="divide-y divide-[#1c1c1c]"
>
{tracks.map((track, index) => {
const isPlaying = currentTrackId === track.id;
const isPreviewPlaying =
previewTrack === track.id && previewPlaying;
return (
<TrackRow
key={track.id}
track={track}
index={index}
album={album}
isOwned={isOwned}
isPlaying={isPlaying}
isPreviewPlaying={isPreviewPlaying}
colors={colors}
onPlayTrack={onPlayTrack}
onAddToQueue={onAddToQueue}
onAddToPlaylist={onAddToPlaylist}
onPreview={onPreview}
/>
);
})}
</div>
</Card>
</section>
);
return (
<TrackRow
key={track.id}
track={track}
index={index}
album={album}
isOwned={isOwned}
isPlaying={isPlaying}
isPreviewPlaying={isPreviewPlaying}
colors={colors}
onPlayTrack={onPlayTrack}
onAddToQueue={onAddToQueue}
onAddToPlaylist={onAddToPlaylist}
onPreview={onPreview}
/>
);
})}
</div>
</Card>
</section>
);
});

View File

@@ -1,56 +1,60 @@
export type AlbumSource = "library" | "discovery";
export interface Album {
id: string;
title: string;
artist?: {
id: string;
title: string;
artist?: {
id: string;
mbid?: string;
name: string;
};
year?: number;
genre?: string;
coverArt?: string;
coverUrl?: string;
duration?: number;
trackCount?: number;
playCount?: number;
type?: string;
mbid?: string;
name: string;
};
year?: number;
genre?: string;
coverArt?: string;
coverUrl?: string;
duration?: number;
trackCount?: number;
playCount?: number;
type?: string;
mbid?: string;
rgMbid?: string;
owned?: boolean;
tracks?: Track[];
similarAlbums?: SimilarAlbum[];
rgMbid?: string;
owned?: boolean;
tracks?: Track[];
similarAlbums?: SimilarAlbum[];
}
export interface Track {
id: string;
title: string;
duration: number;
trackNumber?: number;
discNumber?: number;
playCount?: number;
artist?: {
id?: string;
name?: string;
};
album?: {
id?: string;
title?: string;
coverArt?: string;
};
id: string;
title: string;
duration: number;
trackNumber?: number;
discNumber?: number;
playCount?: number;
artist?: {
id?: string;
name?: string;
};
album?: {
id?: string;
title?: string;
coverArt?: string;
};
// Metadata override fields
displayTitle?: string | null;
displayTrackNo?: number | null;
hasUserOverrides?: boolean;
}
export interface SimilarAlbum {
id: string;
title: string;
artist?: {
id: string;
name: string;
};
coverArt?: string;
coverUrl?: string;
year?: number;
owned?: boolean;
mbid?: string;
title: string;
artist?: {
id: string;
name: string;
};
coverArt?: string;
coverUrl?: string;
year?: number;
owned?: boolean;
mbid?: string;
}

View File

@@ -2,9 +2,12 @@
import { Music } from "lucide-react";
import Image from "next/image";
import { MetadataEditor } from "@/components/MetadataEditor";
import { Artist, ArtistSource, Album } from "../types";
import { ReactNode } from "react";
import { ReactNode, lazy, Suspense } from "react";
import { useArtistDisplayData } from "@/hooks/useMetadataDisplay";
// Lazy load MetadataEditor - modal component opened on user action
const MetadataEditor = lazy(() => import("@/components/MetadataEditor").then(mod => ({ default: mod.MetadataEditor })));
interface ArtistHeroProps {
artist: Artist;
@@ -25,6 +28,7 @@ export function ArtistHero({
onReload,
children,
}: ArtistHeroProps) {
const displayData = useArtistDisplayData(artist);
const ownedAlbums = albums.filter((a) => a.owned);
return (
@@ -35,7 +39,7 @@ export function ArtistHero({
<div className="absolute inset-0 scale-110 blur-md opacity-50">
<Image
src={heroImage}
alt={artist.name}
alt={displayData.name}
fill
sizes="100vw"
className="object-cover"
@@ -73,7 +77,7 @@ export function ArtistHero({
{heroImage ? (
<Image
src={heroImage}
alt={artist.name}
alt={displayData.name}
fill
sizes="(max-width: 768px) 140px, 192px"
className="object-cover"
@@ -94,29 +98,49 @@ export function ArtistHero({
</p>
<div className="flex items-center gap-3 group mb-2">
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold text-white leading-tight line-clamp-2">
{artist.name}
{displayData.name}
</h1>
{displayData.hasUserOverrides && (
<span className="px-2 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded-full border border-amber-500/30 shrink-0">
Edited
</span>
)}
{source === "library" && (
<MetadataEditor
type="artist"
id={artist.id}
currentData={{
name: artist.name,
bio: artist.summary || artist.bio,
genres: artist.genres || artist.tags || [],
mbid: artist.mbid,
heroUrl: artist.heroUrl || artist.image,
}}
onSave={async () => {
await onReload();
}}
/>
<Suspense fallback={null}>
<MetadataEditor
type="artist"
id={artist.id}
currentData={{
name: displayData.name,
bio: displayData.summary,
genres: displayData.genres,
mbid: artist.mbid,
heroUrl: displayData.heroUrl,
// Pass originals for reset comparison
_originalName: artist.name,
_originalBio:
artist.summary ?? artist.bio,
_originalGenres:
artist.genres ?? artist.tags ?? [],
_originalHeroUrl:
artist.heroUrl ?? artist.image,
_hasUserOverrides:
displayData.hasUserOverrides,
}}
onSave={async () => {
await onReload();
}}
/>
</Suspense>
)}
</div>
<div className="flex flex-wrap items-center gap-1 text-sm text-white/70">
{artist.listeners && artist.listeners > 0 && (
<>
<span>{artist.listeners.toLocaleString()} listeners</span>
<span>
{artist.listeners.toLocaleString()}{" "}
listeners
</span>
<span className="mx-1"></span>
</>
)}
@@ -140,9 +164,7 @@ export function ArtistHero({
{/* Action Bar - Full Width */}
{children && (
<div className="relative px-4 md:px-8 pb-4">
{children}
</div>
<div className="relative px-4 md:px-8 pb-4">{children}</div>
)}
</div>
);

View File

@@ -9,17 +9,41 @@ interface DiscographyProps {
albums: Album[];
colors: any;
onPlayAlbum: (albumId: string, albumTitle: string) => Promise<void>;
sortBy: "year" | "dateAdded";
onSortChange: (sortBy: "year" | "dateAdded") => void;
}
export function Discography({ albums, colors, onPlayAlbum }: DiscographyProps) {
export function Discography({
albums,
colors,
onPlayAlbum,
sortBy,
onSortChange,
}: DiscographyProps) {
if (!albums || albums.length === 0) {
return null;
}
return (
<section>
<h2 className="text-xl font-bold mb-4">Discography</h2>
<div data-tv-section="discography" className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Discography</h2>
{/* Sort Dropdown */}
<select
value={sortBy}
onChange={(e) =>
onSortChange(e.target.value as "year" | "dateAdded")
}
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="year">Year (Newest)</option>
<option value="dateAdded">Date Added (Recent)</option>
</select>
</div>
<div
data-tv-section="discography"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
{albums.map((album, index) => {
const subtitle = [
album.year,

View File

@@ -123,7 +123,7 @@ export const PopularTracks: React.FC<PopularTracksProps> = ({
)}
>
<span className="truncate">
{track.title}
{track.displayTitle ?? track.title}
</span>
{isUnowned && (
<span className="shrink-0 text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded font-medium">

View File

@@ -5,7 +5,7 @@ import { queryKeys } from "@/hooks/useQueries";
import { api } from "@/lib/api";
import { useDownloadContext } from "@/lib/download-context";
import { Artist, Album, ArtistSource } from "../types";
import { useMemo, useEffect, useRef } from "react";
import { useMemo, useEffect, useRef, useState } from "react";
export function useArtistData() {
const params = useParams();
@@ -55,17 +55,32 @@ export function useArtistData() {
return artist.id && !artist.id.includes("-") ? "library" : "discovery";
}, [artist]);
// Sort albums by year (newest first, nulls last) - memoized
// Sort state: 'year' or 'dateAdded'
const [sortBy, setSortBy] = useState<"year" | "dateAdded">("year");
// Sort albums by year or dateAdded - memoized
const albums = useMemo(() => {
if (!artist?.albums) return [];
return [...artist.albums].sort((a, b) => {
if (a.year == null && b.year == null) return 0;
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year - a.year;
if (sortBy === "dateAdded") {
// Sort by lastSynced (newest first, nulls last)
if (!a.lastSynced && !b.lastSynced) return 0;
if (!a.lastSynced) return 1;
if (!b.lastSynced) return -1;
return (
new Date(b.lastSynced).getTime() -
new Date(a.lastSynced).getTime()
);
} else {
// Sort by year (newest first, nulls last)
if (a.year == null && b.year == null) return 0;
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year - a.year;
}
});
}, [artist?.albums]);
}, [artist?.albums, sortBy]);
// Handle errors - only show toast once, don't auto-navigate
// The page component should handle displaying a "not found" state
@@ -77,6 +92,8 @@ export function useArtistData() {
loading: isLoading,
error: isError,
source,
sortBy,
setSortBy,
reloadArtist: refetch,
};
}

View File

@@ -16,6 +16,12 @@ export interface Artist {
albums?: Album[];
topTracks?: Track[];
similarArtists?: SimilarArtist[];
// User overrides (non-destructive edits)
displayName?: string | null;
userSummary?: string | null;
userHeroUrl?: string | null;
userGenres?: string[];
hasUserOverrides?: boolean;
}
export interface Album {
@@ -31,6 +37,14 @@ export interface Album {
mbid?: string;
rgMbid?: string;
availability?: string;
genres?: string[];
lastSynced?: string;
// User overrides (non-destructive edits)
displayTitle?: string | null;
displayYear?: number | null;
userCoverUrl?: string | null;
userGenres?: string[];
hasUserOverrides?: boolean;
}
export interface Track {
@@ -45,6 +59,11 @@ export interface Track {
title?: string;
coverArt?: string;
};
trackNo?: number;
// User overrides (non-destructive edits)
displayTitle?: string | null;
displayTrackNo?: number | null;
hasUserOverrides?: boolean;
}
export interface SimilarArtist {

View File

@@ -1,8 +1,10 @@
"use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import { useAudiobookQuery } from "@/hooks/useQueries";
import { api } from "@/lib/api";
import { subscribeQueryEvent } from "@/lib/query-events";
export function useAudiobookData() {
const params = useParams();
@@ -10,6 +12,15 @@ export function useAudiobookData() {
const { data: audiobook, isLoading, refetch } = useAudiobookQuery(audiobookId);
// Listen for audiobook-progress-updated event (fired when playback starts/updates)
useEffect(() => {
const unsubscribe = subscribeQueryEvent("audiobook-progress-updated", () => {
refetch();
});
return unsubscribe;
}, [refetch]);
// Calculate hero image for color extraction
const heroImage = audiobook?.coverUrl
? api.getCoverArtUrl(audiobook.coverUrl, 1200)

View File

@@ -9,8 +9,9 @@ interface DiscoverHeroProps {
export function DiscoverHero({ playlist, config }: DiscoverHeroProps) {
// Calculate total duration
const totalDuration = playlist?.tracks?.reduce((sum, t) => sum + (t.duration || 0), 0) || 0;
const totalDuration =
playlist?.tracks?.reduce((sum, t) => sum + (t.duration || 0), 0) || 0;
const formatTotalDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
@@ -37,26 +38,25 @@ export function DiscoverHero({ playlist, config }: DiscoverHeroProps) {
Discover Weekly
</h1>
<p className="text-sm text-white/60 mb-2 line-clamp-2">
Your personalized playlist of new music, curated based on your listening history.
Your personalized playlist of new music, curated based
on your listening history.
</p>
<div className="flex flex-wrap items-center gap-1 text-sm text-white/70">
{playlist && (
<>
<span>
Week of {format(new Date(playlist.weekStart), "MMM d, yyyy")}
Week of{" "}
{format(
new Date(playlist.weekStart),
"MMM d, yyyy"
)}
</span>
<span className="mx-1"></span>
<span>{playlist.totalCount} songs</span>
{totalDuration > 0 && (
<span>, {formatTotalDuration(totalDuration)}</span>
)}
{playlist.unavailableCount > 0 && (
<>
<span className="mx-1"></span>
<span className="text-orange-400">
{playlist.unavailableCount} unavailable
</span>
</>
<span>
, {formatTotalDuration(totalDuration)}
</span>
)}
</>
)}
@@ -64,7 +64,11 @@ export function DiscoverHero({ playlist, config }: DiscoverHeroProps) {
<>
<span className="mx-1"></span>
<span>
Updated {format(new Date(config.lastGeneratedAt), "MMM d")}
Updated{" "}
{format(
new Date(config.lastGeneratedAt),
"MMM d"
)}
</span>
</>
)}

View File

@@ -40,6 +40,10 @@ export function TrackList({
onLike,
}: TrackListProps) {
const formatDuration = (seconds: number) => {
// Defensive handling for invalid/missing duration
if (!seconds || isNaN(seconds) || seconds < 0) {
return "--:--";
}
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
@@ -78,7 +82,9 @@ export function TrackList({
<span
className={cn(
"text-sm group-hover:hidden",
isTrackPlaying ? "text-[#ecb200]" : "text-gray-400"
isTrackPlaying
? "text-[#ecb200]"
: "text-gray-400"
)}
>
{isTrackPlaying && isPlaying ? (
@@ -95,7 +101,10 @@ export function TrackList({
<div className="w-10 h-10 bg-[#282828] rounded shrink-0 overflow-hidden">
{track.coverUrl ? (
<Image
src={api.getCoverArtUrl(track.coverUrl, 80)}
src={api.getCoverArtUrl(
track.coverUrl,
80
)}
alt={track.album}
width={40}
height={40}
@@ -112,7 +121,9 @@ export function TrackList({
<p
className={cn(
"text-sm font-medium truncate",
isTrackPlaying ? "text-[#ecb200]" : "text-white"
isTrackPlaying
? "text-[#ecb200]"
: "text-white"
)}
>
{track.title}
@@ -153,7 +164,11 @@ export function TrackList({
? "text-purple-400 hover:text-purple-300"
: "text-gray-400 hover:text-white"
)}
title={track.isLiked ? "Unlike" : "Keep in library"}
title={
track.isLiked
? "Unlike"
: "Keep in library"
}
>
<Heart
className={cn(

View File

@@ -53,7 +53,6 @@ export function useDiscoverData() {
// If batch was active and now isn't, reload data
if (wasActiveRef.current && !status.active) {
console.log('[Discover] Batch completed, reloading playlist data...');
wasActiveRef.current = false;
setPendingGeneration(false);
await loadData();
@@ -76,7 +75,6 @@ export function useDiscoverData() {
const startPolling = useCallback(() => {
if (pollingRef.current) return; // Already polling
console.log('[Discover] Starting batch status polling...');
pollingRef.current = setInterval(async () => {
const status = await checkBatchStatus();
@@ -86,7 +84,6 @@ export function useDiscoverData() {
// 3. We previously had an active batch (wasActiveRef)
// This ensures we keep polling while waiting for the batch to be created
if (status && !status.active && !pendingRef.current && wasActiveRef.current) {
console.log('[Discover] Batch completed, stopping polling');
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;

View File

@@ -9,6 +9,7 @@ import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth-context";
import { toast } from "sonner";
import { subscribeQueryEvent } from "@/lib/query-events";
import type {
Artist,
ListenedItem,
@@ -93,6 +94,15 @@ export function useHomeData(): UseHomeDataReturn {
window.removeEventListener("mixes-updated", handleMixesUpdated);
}, [queryClient]);
// Listen for library-updated event (fired when library scan completes)
useEffect(() => {
const unsubscribe = subscribeQueryEvent("library-updated", () => {
queryClient.refetchQueries({ queryKey: queryKeys.recentlyAdded() });
});
return unsubscribe;
}, [queryClient]);
// React Query hooks - these automatically handle caching, refetching, and loading states
const { data: recentlyListenedData, isLoading: isLoadingListened } =
useRecentlyListenedQuery(10);

View File

@@ -103,7 +103,7 @@ const AlbumsGrid = memo(function AlbumsGrid({
return (
<div
data-tv-section="library-albums"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-4"
>
{albums.map((album, index) => (
<AlbumCardItem

View File

@@ -64,7 +64,7 @@ const ArtistCardItem = memo(
{/* Delete button - only visible on hover */}
<button
onClick={handleDelete}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all z-10"
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 hidden md:flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all z-10"
title="Delete artist"
>
<Trash2 className="w-3.5 h-3.5 text-white" />
@@ -104,7 +104,7 @@ const ArtistsGrid = memo(function ArtistsGrid({
return (
<div
data-tv-section="library-artists"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-4"
>
{artists.map((artist, index) => (
<ArtistCardItem

View File

@@ -94,7 +94,7 @@ const TrackRow = memo(function TrackRow({
"text-sm font-medium truncate",
isCurrentlyPlaying ? "text-[#ecb200]" : "text-white"
)}>
{track.title}
{track.displayTitle ?? track.title}
</h3>
<p className="text-xs text-gray-400 truncate">
{track.album?.artist?.name}

View File

@@ -1,45 +1,49 @@
export type Tab = "artists" | "albums" | "tracks";
export interface Artist {
id: string;
mbid?: string;
name: string;
coverArt?: string;
albumCount?: number;
trackCount?: number;
}
export interface Album {
id: string;
title: string;
coverArt?: string;
year?: number;
artist?: {
id: string;
mbid?: string;
name: string;
};
coverArt?: string;
albumCount?: number;
trackCount?: number;
}
export interface Track {
id: string;
title: string;
duration: number;
trackNumber?: number;
album?: {
export interface Album {
id: string;
title: string;
coverArt?: string;
year?: number;
artist?: {
id: string;
name: string;
id: string;
mbid?: string;
name: string;
};
};
}
export interface Track {
id: string;
title: string;
duration: number;
trackNumber?: number;
album?: {
id: string;
title: string;
coverArt?: string;
artist?: {
id: string;
name: string;
};
};
// Metadata override fields
displayTitle?: string | null;
displayTrackNo?: number | null;
hasUserOverrides?: boolean;
}
export interface DeleteDialogState {
isOpen: boolean;
type: "track" | "album" | "artist";
id: string;
title: string;
isOpen: boolean;
type: "track" | "album" | "artist";
id: string;
title: string;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { Play, Pause, Check, ArrowUpDown } from "lucide-react";
import { Play, Pause, Check, ArrowUpDown, CheckCircle } from "lucide-react";
import { cn } from "@/utils/cn";
import { Podcast, Episode } from "../types";
import { formatDuration, formatDate } from "../utils";
@@ -14,6 +14,7 @@ interface EpisodeListProps {
isPlaying: boolean;
onPlayPause: (episode: Episode) => void;
onPlay: (episode: Episode) => void;
onMarkComplete?: (episodeId: string, duration: number) => void;
}
export function EpisodeList({
@@ -25,6 +26,7 @@ export function EpisodeList({
isPlaying,
onPlayPause,
onPlay,
onMarkComplete,
}: EpisodeListProps) {
return (
<section>
@@ -171,6 +173,20 @@ export function EpisodeList({
<span className="text-xs text-white/40 shrink-0">
{formatDuration(episode.duration)}
</span>
{/* Complete Button - visible on hover for incomplete episodes */}
{onMarkComplete && !episode.progress?.isFinished && (
<button
onClick={(e) => {
e.stopPropagation();
onMarkComplete(episode.id, episode.duration);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity ml-2 p-1.5 rounded-full hover:bg-white/10"
title="Mark as complete"
>
<CheckCircle className="w-4 h-4 text-white/60 hover:text-green-400" />
</button>
)}
</div>
</div>
);

View File

@@ -4,15 +4,18 @@ import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAudio } from "@/lib/audio-context";
import { useAudioState } from "@/lib/audio-state-context";
import { api } from "@/lib/api";
import { Podcast, Episode, PodcastPreview } from "../types";
import { queryKeys } from "@/hooks/useQueries";
import { dispatchQueryEvent } from "@/lib/query-events";
export function usePodcastActions(podcastId: string) {
export function usePodcastActions(podcastId: string, sortedEpisodes?: Episode[]) {
const router = useRouter();
const queryClient = useQueryClient();
const { playPodcast, currentPodcast, isPlaying, pause, resume } =
useAudio();
const { setPodcastEpisodeQueue } = useAudioState();
const [isSubscribing, setIsSubscribing] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -56,6 +59,11 @@ export function usePodcastActions(podcastId: string) {
const handlePlayEpisode = useCallback(
(episode: Episode, podcast: Podcast) => {
// Build episode queue from sorted episodes
if (sortedEpisodes && sortedEpisodes.length > 0) {
setPodcastEpisodeQueue(sortedEpisodes);
}
playPodcast({
id: `${podcastId}:${episode.id}`,
title: episode.title,
@@ -65,7 +73,7 @@ export function usePodcastActions(podcastId: string) {
progress: episode.progress || null,
});
},
[podcastId, playPodcast]
[podcastId, playPodcast, sortedEpisodes, setPodcastEpisodeQueue]
);
const handlePlayPauseEpisode = useCallback(
@@ -91,6 +99,33 @@ export function usePodcastActions(podcastId: string) {
[podcastId, currentPodcast]
);
const handleMarkEpisodeComplete = useCallback(
async (episodeId: string, duration: number) => {
try {
// Mark episode as complete (set currentTime to duration and isFinished to true)
await api.updatePodcastEpisodeProgress(
podcastId,
episodeId,
duration,
duration,
true
);
// Invalidate podcast query to refresh UI
queryClient.invalidateQueries({
queryKey: queryKeys.podcast(podcastId)
});
// Dispatch event for real-time UI updates
dispatchQueryEvent("podcast-progress-updated");
} catch (error) {
console.error("Failed to mark episode as complete:", error);
throw error;
}
},
[podcastId, queryClient]
);
return {
isSubscribing,
showDeleteConfirm,
@@ -99,6 +134,7 @@ export function usePodcastActions(podcastId: string) {
handleRemovePodcast,
handlePlayEpisode,
handlePlayPauseEpisode,
handleMarkEpisodeComplete,
isEpisodePlaying,
isPlaying,
pause,

View File

@@ -6,6 +6,7 @@ import { usePodcastQuery } from "@/hooks/useQueries";
import { useAuth } from "@/lib/auth-context";
import { api } from "@/lib/api";
import { Podcast, PodcastPreview, SimilarPodcast, Episode } from "../types";
import { subscribeQueryEvent } from "@/lib/query-events";
export function usePodcastData() {
const params = useParams();
@@ -14,9 +15,18 @@ export function usePodcastData() {
const podcastId = params.id as string;
// Use React Query hook for podcast
const { data: podcast, isLoading: isPodcastLoading } =
const { data: podcast, isLoading: isPodcastLoading, refetch } =
usePodcastQuery(podcastId);
// Listen for podcast-progress-updated event (fired when playback starts/updates or episode marked complete)
useEffect(() => {
const unsubscribe = subscribeQueryEvent("podcast-progress-updated", () => {
refetch();
});
return unsubscribe;
}, [refetch]);
// State for preview mode, subscription, and similar podcasts
const [previewData, setPreviewData] = useState<PodcastPreview | null>(null);
const [previewLoadState, setPreviewLoadState] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');

View File

@@ -0,0 +1,35 @@
import { Audiobook } from "../types";
import { AudiobookCard } from "@/components/ui/AudiobookCard";
import { api } from "@/lib/api";
interface LibraryAudiobooksGridProps {
audiobooks: Audiobook[];
}
export function LibraryAudiobooksGrid({
audiobooks,
}: LibraryAudiobooksGridProps) {
const getCoverUrl = (coverUrl: string | null, size = 300) => {
if (!coverUrl) return null;
return api.getCoverArtUrl(coverUrl, size);
};
return (
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4"
data-tv-section="search-results-audiobooks"
>
{audiobooks.slice(0, 6).map((audiobook, index) => (
<AudiobookCard
key={audiobook.id}
id={audiobook.id}
title={audiobook.title}
author={audiobook.author || "Unknown Author"}
coverUrl={audiobook.coverUrl}
index={index}
getCoverUrl={getCoverUrl}
/>
))}
</div>
);
}

View File

@@ -25,6 +25,7 @@ export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
const formattedTracks = tracks.map((t) => ({
id: t.id,
title: t.title,
displayTitle: t.displayTitle,
duration: t.duration,
artist: {
id: t.album.artist.id,
@@ -64,9 +65,7 @@ export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
key={track.id}
className={cn(
"flex items-center gap-3 p-2 rounded-md group transition-colors",
isCurrentTrack
? "bg-white/10"
: "hover:bg-white/5"
isCurrentTrack ? "bg-white/10" : "hover:bg-white/5"
)}
>
{/* Play Button / Track Number */}
@@ -101,7 +100,9 @@ export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-gray-500 text-xs"></span>
<span className="text-gray-500 text-xs">
</span>
</div>
)}
</div>
@@ -111,14 +112,19 @@ export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
<p
className={cn(
"text-sm font-medium truncate",
isCurrentTrack ? "text-[#ecb200]" : "text-white"
isCurrentTrack
? "text-[#ecb200]"
: "text-white"
)}
>
{track.title}
</p>
<p className="text-xs text-gray-400 truncate">
<Link
href={`/artist/${track.album.artist.mbid || track.album.artist.id}`}
href={`/artist/${
track.album.artist.mbid ||
track.album.artist.id
}`}
className="hover:underline hover:text-white"
onClick={(e) => e.stopPropagation()}
>
@@ -145,5 +151,3 @@ export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { Music } from "lucide-react";
import { cn } from "@/utils/cn";
import { DiscoverResult } from "../types";
import { api } from "@/lib/api";
import { formatListeners } from "@/lib/format";
interface SimilarArtistsGridProps {
discoverResults: DiscoverResult[];
@@ -29,7 +30,10 @@ export function SimilarArtistsGrid({
<h2 className="text-2xl font-bold text-white mb-6">
Similar Artists
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-artists">
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4"
data-tv-section="search-results-artists"
>
{artistResults.slice(1, 7).map((result, index) => {
const artistId =
result.mbid || encodeURIComponent(result.name);
@@ -61,7 +65,9 @@ export function SimilarArtistsGrid({
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
{result.name}
</h3>
<p className="text-sm text-[#b3b3b3]">Artist</p>
<p className="text-sm text-[#b3b3b3]">
{formatListeners(result.listeners)}
</p>
</div>
</Link>
);

View File

@@ -3,6 +3,7 @@ import Image from "next/image";
import { Music } from "lucide-react";
import { api } from "@/lib/api";
import { Artist, DiscoverResult } from "../types";
import { formatListeners } from "@/lib/format";
interface TopResultProps {
libraryArtist?: Artist;

View File

@@ -26,6 +26,28 @@ export interface Podcast {
episodeCount?: number;
}
export interface Episode {
id: string;
title: string;
description?: string | null;
podcastId: string;
podcastTitle: string;
publishedAt: Date | string;
duration: number;
audioUrl: string;
}
export interface Audiobook {
id: string;
title: string;
author?: string | null;
narrator?: string | null;
series?: string | null;
description?: string | null;
coverUrl?: string | null;
duration?: number | null;
}
export interface LibraryTrack {
id: string;
title: string;
@@ -40,6 +62,10 @@ export interface LibraryTrack {
name: string;
};
};
// Metadata override fields
displayTitle?: string | null;
displayTrackNo?: number | null;
hasUserOverrides?: boolean;
}
export interface SearchResult {
@@ -47,6 +73,8 @@ export interface SearchResult {
albums?: Album[];
podcasts?: Podcast[];
tracks?: LibraryTrack[];
audiobooks?: Audiobook[];
episodes?: Episode[];
}
export interface DiscoverResult {
@@ -55,6 +83,7 @@ export interface DiscoverResult {
name: string;
mbid?: string;
image?: string;
listeners?: number;
}
export interface SoulseekResult {

View File

@@ -13,19 +13,34 @@ interface AIServicesSectionProps {
}
export function AIServicesSection({ settings, onUpdate, onTest, isTesting }: AIServicesSectionProps) {
const [testStatus, setTestStatus] = useState<StatusType>("idle");
const [testMessage, setTestMessage] = useState("");
const [fanartTestStatus, setFanartTestStatus] = useState<StatusType>("idle");
const [fanartTestMessage, setFanartTestMessage] = useState("");
const [lastfmTestStatus, setLastfmTestStatus] = useState<StatusType>("idle");
const [lastfmTestMessage, setLastfmTestMessage] = useState("");
const handleTest = async () => {
setTestStatus("loading");
setTestMessage("Testing...");
const handleFanartTest = async () => {
setFanartTestStatus("loading");
setFanartTestMessage("Testing...");
const result = await onTest("fanart");
if (result.success) {
setTestStatus("success");
setTestMessage("Connected");
setFanartTestStatus("success");
setFanartTestMessage("Connected");
} else {
setTestStatus("error");
setTestMessage(result.error || "Failed");
setFanartTestStatus("error");
setFanartTestMessage(result.error || "Failed");
}
};
const handleLastfmTest = async () => {
setLastfmTestStatus("loading");
setLastfmTestMessage("Testing...");
const result = await onTest("lastfm");
if (result.success) {
setLastfmTestStatus("success");
setLastfmTestMessage("Connected");
} else {
setLastfmTestStatus("error");
setLastfmTestMessage(result.error || "Failed");
}
};
@@ -63,22 +78,61 @@ export function AIServicesSection({ settings, onUpdate, onTest, isTesting }: AIS
<div className="pt-2">
<div className="inline-flex items-center gap-3">
<button
onClick={handleTest}
onClick={handleFanartTest}
disabled={isTesting || !settings.fanartApiKey}
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testStatus === "loading" ? "Testing..." : "Test Connection"}
{fanartTestStatus === "loading" ? "Testing..." : "Test Connection"}
</button>
<InlineStatus
status={testStatus}
message={testMessage}
onClear={() => setTestStatus("idle")}
<InlineStatus
status={fanartTestStatus}
message={fanartTestMessage}
onClear={() => setFanartTestStatus("idle")}
/>
</div>
</div>
</>
)}
{/* Last.fm */}
<div className="mt-6 pt-6 border-t border-white/5">
<div className="mb-4">
<p className="text-sm text-white/60">
Last.fm is pre-configured with a default key. Add your own for higher rate limits.
</p>
</div>
<SettingsRow label="Last.fm API Key (Optional)">
<SettingsInput
type="password"
value={settings.lastfmApiKey || ""}
onChange={(v) => onUpdate({ lastfmApiKey: v })}
placeholder="Optional: Your Last.fm API key"
className="w-64"
/>
</SettingsRow>
{settings.lastfmApiKey && (
<div className="pt-2">
<div className="inline-flex items-center gap-3">
<button
onClick={handleLastfmTest}
disabled={isTesting}
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{lastfmTestStatus === "loading" ? "Testing..." : "Test Connection"}
</button>
<InlineStatus
status={lastfmTestStatus}
message={lastfmTestMessage}
onClear={() => setLastfmTestStatus("idle")}
/>
</div>
</div>
)}
</div>
</SettingsSection>
);
}

View File

@@ -1,11 +1,23 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { SettingsSection, SettingsRow, SettingsToggle } from "../ui";
import { SystemSettings } from "../../types";
import { api } from "@/lib/api";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import { CheckCircle, Loader2, User, Heart, Activity } from "lucide-react";
import { enrichmentApi } from "@/lib/enrichmentApi";
import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query";
import {
CheckCircle,
Loader2,
User,
Heart,
Activity,
Pause,
Play,
StopCircle,
AlertTriangle,
} from "lucide-react";
import { EnrichmentFailuresModal } from "@/components/EnrichmentFailuresModal";
interface CacheSectionProps {
settings: SystemSettings;
@@ -13,42 +25,44 @@ interface CacheSectionProps {
}
// Progress bar component
function ProgressBar({
progress,
function ProgressBar({
progress,
color = "bg-[#ecb200]",
showPercentage = true
}: {
progress: number;
showPercentage = true,
}: {
progress: number;
color?: string;
showPercentage?: boolean;
}) {
return (
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div
<div
className={`h-full ${color} transition-all duration-500 ease-out`}
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
{showPercentage && (
<span className="text-xs text-white/50 w-10 text-right">{progress}%</span>
<span className="text-xs text-white/50 w-10 text-right">
{progress}%
</span>
)}
</div>
);
}
// Enrichment stage component
function EnrichmentStage({
function EnrichmentStage({
icon: Icon,
label,
label,
description,
completed,
total,
completed,
total,
progress,
isBackground = false,
failed = 0,
processing = 0,
}: {
}: {
icon: React.ElementType;
label: string;
description: string;
@@ -61,10 +75,14 @@ function EnrichmentStage({
}) {
const isComplete = progress === 100;
const hasActivity = processing > 0;
return (
<div className="flex items-start gap-3 py-2">
<div className={`mt-0.5 p-1.5 rounded-lg ${isComplete ? 'bg-green-500/20' : 'bg-white/5'}`}>
<div
className={`mt-0.5 p-1.5 rounded-lg ${
isComplete ? "bg-green-500/20" : "bg-white/5"
}`}
>
{isComplete ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : hasActivity ? (
@@ -75,7 +93,9 @@ function EnrichmentStage({
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{label}</span>
<span className="text-sm font-medium text-white">
{label}
</span>
{isBackground && !isComplete && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/50">
background
@@ -84,15 +104,29 @@ function EnrichmentStage({
</div>
<p className="text-xs text-white/40 mt-0.5">{description}</p>
<div className="flex items-center gap-2 mt-2">
<ProgressBar
progress={progress}
color={isComplete ? "bg-green-500" : isBackground ? "bg-purple-500" : "bg-[#ecb200]"}
<ProgressBar
progress={progress}
color={
isComplete
? "bg-green-500"
: isBackground
? "bg-purple-500"
: "bg-[#ecb200]"
}
/>
</div>
<div className="flex items-center gap-3 mt-1 text-[10px] text-white/30">
<span>{completed} / {total}</span>
{processing > 0 && <span className="text-[#ecb200]">{processing} processing</span>}
{failed > 0 && <span className="text-red-400">{failed} failed</span>}
<span>
{completed} / {total}
</span>
{processing > 0 && (
<span className="text-[#ecb200]">
{processing} processing
</span>
)}
{failed > 0 && (
<span className="text-red-400">{failed} failed</span>
)}
</div>
</div>
</div>
@@ -103,8 +137,27 @@ export function CacheSection({ settings, onUpdate }: CacheSectionProps) {
const [syncing, setSyncing] = useState(false);
const [clearingCaches, setClearingCaches] = useState(false);
const [reEnriching, setReEnriching] = useState(false);
const [cleaningStaleJobs, setCleaningStaleJobs] = useState(false);
const [cleanupResult, setCleanupResult] = useState<{
totalCleaned: number;
cleaned: {
discoveryBatches: { cleaned: number };
downloadJobs: { cleaned: number };
spotifyImportJobs: { cleaned: number };
bullQueues: { cleaned: number };
};
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [showFailuresModal, setShowFailuresModal] = useState(false);
const queryClient = useQueryClient();
const syncStartTimeRef = useRef<number>(0);
// Check URL hash for auto-opening failures modal
useEffect(() => {
if (window.location.hash === "#enrichment-failures") {
setShowFailuresModal(true);
}
}, []);
// Fetch enrichment progress
const { data: enrichmentProgress, refetch: refetchProgress } = useQuery({
@@ -114,28 +167,163 @@ export function CacheSection({ settings, onUpdate }: CacheSectionProps) {
staleTime: 2000,
});
// Fetch enrichment state
const { data: enrichmentState } = useQuery({
queryKey: ["enrichment-status"],
queryFn: () => enrichmentApi.getStatus(),
refetchInterval: 3000,
staleTime: 1000,
});
// Fetch failure counts
const { data: failureCounts } = useQuery({
queryKey: ["enrichment-failure-counts"],
queryFn: () => enrichmentApi.getFailureCounts(),
refetchInterval: 10000,
});
// Fetch concurrency config
const { data: concurrencyConfig, isLoading: isConcurrencyLoading } =
useQuery({
queryKey: ["enrichment-concurrency"],
queryFn: () => enrichmentApi.getConcurrency(),
staleTime: 0,
});
// Fetch audio analyzer workers config
const { data: workersConfig, isLoading: isWorkersLoading } = useQuery({
queryKey: ["analysis-workers"],
queryFn: () => enrichmentApi.getAnalysisWorkers(),
staleTime: 0,
});
// Update concurrency mutation with optimistic updates
// Note: We do NOT invalidate on onSettled because the optimistic update
// already provides the correct UI state. Invalidating causes a race condition
// where the refetch returns stale data before the server update completes,
// causing the slider to "bounce" between values.
const setConcurrencyMutation = useMutation({
mutationFn: (concurrency: number) =>
enrichmentApi.setConcurrency(concurrency),
onMutate: async (newConcurrency) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ["enrichment-concurrency"],
});
// Snapshot previous value
const previousConcurrency = queryClient.getQueryData([
"enrichment-concurrency",
]);
// Optimistically update to new value
queryClient.setQueryData(["enrichment-concurrency"], {
concurrency: newConcurrency,
artistsPerMin: newConcurrency * 6, // Approximate estimate
});
return { previousConcurrency };
},
onError: (err, newConcurrency, context) => {
// Rollback on error
queryClient.setQueryData(
["enrichment-concurrency"],
context?.previousConcurrency
);
},
// Removed onSettled invalidation - optimistic update handles UI,
// and the query will refetch naturally based on staleTime
});
// Update audio analyzer workers mutation with optimistic updates
const setAnalysisWorkersMutation = useMutation({
mutationFn: (workers: number) =>
enrichmentApi.setAnalysisWorkers(workers),
onMutate: async (newWorkers) => {
await queryClient.cancelQueries({
queryKey: ["analysis-workers"],
});
const previousWorkers = queryClient.getQueryData([
"analysis-workers",
]);
queryClient.setQueryData(["analysis-workers"], {
workers: newWorkers,
cpuCores: workersConfig?.cpuCores || 4,
recommended: workersConfig?.recommended || 2,
description: `Using ${newWorkers} of ${workersConfig?.cpuCores || 4} available CPU cores`,
});
return { previousWorkers };
},
onError: (err, newWorkers, context) => {
queryClient.setQueryData(
["analysis-workers"],
context?.previousWorkers
);
},
});
// Use query data directly instead of local state
const enrichmentSpeed = concurrencyConfig?.concurrency ?? 1;
// Poll enrichment status when syncing to detect completion
useEffect(() => {
if (!syncing) return;
const maxPollDuration = 5 * 60 * 1000; // 5 minutes max
const pollInterval = 2000; // Check every 2 seconds
const startTime = syncStartTimeRef.current;
const checkStatus = async () => {
try {
const status = await enrichmentApi.getStatus();
const elapsed = Date.now() - startTime;
// Stop polling if idle or max duration exceeded
if (status?.status === "idle" || elapsed > maxPollDuration) {
setSyncing(false);
refetchProgress();
}
} catch (err) {
console.error("Failed to check enrichment status:", err);
}
};
const intervalId = setInterval(checkStatus, pollInterval);
return () => clearInterval(intervalId);
}, [syncing, refetchProgress]);
const refreshNotifications = () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({ queryKey: ["unread-notification-count"] });
queryClient.invalidateQueries({
queryKey: ["unread-notification-count"],
});
window.dispatchEvent(new CustomEvent("notifications-changed"));
};
const handleSyncAndEnrich = async () => {
setSyncing(true);
syncStartTimeRef.current = Date.now();
setError(null);
try {
if (settings.autoEnrichMetadata) {
// Always sync audiobooks if Audiobookshelf is enabled (independent of enrichment setting)
if (settings.audiobookshelfEnabled) {
await api.post("/audiobooks/sync", {});
}
await api.post("/podcasts/sync-covers", {});
await api.startLibraryEnrichment();
// Use the new fast incremental sync endpoint
await api.syncLibraryEnrichment();
refreshNotifications();
refetchProgress();
// Don't set syncing to false here - let the polling effect handle it
} catch (err) {
console.error("Sync error:", err);
setError("Failed to sync");
} finally {
setSyncing(false);
setSyncing(false); // Only stop on error
}
};
@@ -167,165 +355,480 @@ export function CacheSection({ settings, onUpdate }: CacheSectionProps) {
}
};
const handleCleanupStaleJobs = async () => {
setCleaningStaleJobs(true);
setCleanupResult(null);
setError(null);
try {
const result = await api.cleanupStaleJobs();
setCleanupResult(result);
refreshNotifications();
} catch (err) {
console.error("Stale job cleanup error:", err);
setError("Failed to cleanup stale jobs");
} finally {
setCleaningStaleJobs(false);
}
};
const handlePause = async () => {
try {
await enrichmentApi.pause();
queryClient.invalidateQueries({ queryKey: ["enrichment-status"] });
} catch (err) {
console.error("Pause error:", err);
setError("Failed to pause enrichment");
}
};
const handleResume = async () => {
try {
await enrichmentApi.resume();
queryClient.invalidateQueries({ queryKey: ["enrichment-status"] });
} catch (err) {
console.error("Resume error:", err);
setError("Failed to resume enrichment");
}
};
const handleStop = async () => {
try {
await enrichmentApi.stop();
queryClient.invalidateQueries({ queryKey: ["enrichment-status"] });
queryClient.invalidateQueries({
queryKey: ["enrichment-progress"],
});
} catch (err) {
console.error("Stop error:", err);
setError("Failed to stop enrichment");
}
};
const isEnrichmentActive =
enrichmentState?.status === "running" ||
enrichmentState?.status === "paused";
const totalFailures = failureCounts?.total || 0;
return (
<SettingsSection id="cache" title="Cache & Automation">
{/* Enrichment Progress */}
{enrichmentProgress && (
<div className="mb-6 p-4 bg-white/5 rounded-lg border border-white/10">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-white">Library Enrichment</h3>
{enrichmentProgress.coreComplete && !enrichmentProgress.isFullyComplete && (
<span className="text-xs text-purple-400 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Audio analysis running
</span>
)}
{enrichmentProgress.isFullyComplete && (
<span className="text-xs text-green-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Complete
</span>
)}
</div>
<div className="space-y-1">
<EnrichmentStage
icon={User}
label="Artist Metadata"
description="Bios, images, and similar artists from Last.fm"
completed={enrichmentProgress.artists.completed}
total={enrichmentProgress.artists.total}
progress={enrichmentProgress.artists.progress}
failed={enrichmentProgress.artists.failed}
/>
<EnrichmentStage
icon={Heart}
label="Mood Tags"
description="Vibes and mood data from Last.fm"
completed={enrichmentProgress.trackTags.enriched}
total={enrichmentProgress.trackTags.total}
progress={enrichmentProgress.trackTags.progress}
/>
<EnrichmentStage
icon={Activity}
label="Audio Analysis"
description="BPM, key, energy, and danceability from audio files"
completed={enrichmentProgress.audioAnalysis.completed}
total={enrichmentProgress.audioAnalysis.total}
progress={enrichmentProgress.audioAnalysis.progress}
processing={enrichmentProgress.audioAnalysis.processing}
failed={enrichmentProgress.audioAnalysis.failed}
isBackground={true}
/>
</div>
<div className="flex gap-2 mt-4 pt-3 border-t border-white/10">
<button
onClick={handleSyncAndEnrich}
disabled={syncing || reEnriching}
className="px-3 py-1.5 text-xs bg-white text-black font-medium rounded-full
<>
<SettingsSection id="cache" title="Cache & Automation">
{/* Enrichment Progress */}
{enrichmentProgress && (
<div className="mb-6 p-4 bg-white/5 rounded-lg border border-white/10">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-white">
Library Enrichment
</h3>
{enrichmentProgress.coreComplete &&
!enrichmentProgress.isFullyComplete && (
<span className="text-xs text-purple-400 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Audio analysis running
</span>
)}
{enrichmentProgress.isFullyComplete && (
<span className="text-xs text-green-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Complete
</span>
)}
</div>
<div className="space-y-1">
<EnrichmentStage
icon={User}
label="Artist Metadata"
description="Bios, images, and similar artists from Last.fm"
completed={enrichmentProgress.artists.completed}
total={enrichmentProgress.artists.total}
progress={enrichmentProgress.artists.progress}
failed={enrichmentProgress.artists.failed}
/>
<EnrichmentStage
icon={Heart}
label="Mood Tags"
description="Vibes and mood data from Last.fm"
completed={
enrichmentProgress.trackTags.enriched
}
total={enrichmentProgress.trackTags.total}
progress={enrichmentProgress.trackTags.progress}
/>
<EnrichmentStage
icon={Activity}
label="Audio Analysis"
description="BPM, key, energy, and danceability from audio files"
completed={
enrichmentProgress.audioAnalysis.completed
}
total={enrichmentProgress.audioAnalysis.total}
progress={
enrichmentProgress.audioAnalysis.progress
}
processing={
enrichmentProgress.audioAnalysis.processing
}
failed={enrichmentProgress.audioAnalysis.failed}
isBackground={true}
/>
</div>
{/* Control Buttons */}
<div className="flex flex-wrap gap-2 mt-4 pt-3 border-t border-white/10">
{/* Main Actions */}
<button
onClick={handleSyncAndEnrich}
disabled={
syncing || reEnriching || isEnrichmentActive
}
className="px-3 py-1.5 text-xs bg-white text-black font-medium rounded-full
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
>
{syncing ? "Syncing..." : "Sync New"}
</button>
<button
onClick={handleFullEnrichment}
disabled={syncing || reEnriching}
className="px-3 py-1.5 text-xs bg-[#333] text-white rounded-full
>
{syncing ? "Syncing..." : "Sync New"}
</button>
<button
onClick={handleFullEnrichment}
disabled={
syncing || reEnriching || isEnrichmentActive
}
className="px-3 py-1.5 text-xs bg-[#333] text-white rounded-full
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{reEnriching ? "Starting..." : "Re-enrich All"}
</button>
>
{reEnriching ? "Starting..." : "Re-enrich All"}
</button>
{/* Control Actions */}
{isEnrichmentActive && (
<>
{enrichmentState?.status === "running" ? (
<button
onClick={handlePause}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-yellow-600 text-white rounded-full
hover:bg-yellow-700 transition-colors"
>
<Pause className="w-3 h-3" />
Pause
</button>
) : (
<button
onClick={handleResume}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded-full
hover:bg-green-700 transition-colors"
>
<Play className="w-3 h-3" />
Resume
</button>
)}
<button
onClick={handleStop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-red-600 text-white rounded-full
hover:bg-red-700 transition-colors"
>
<StopCircle className="w-3 h-3" />
Stop
</button>
</>
)}
{/* Failures Button */}
{totalFailures > 0 && (
<button
onClick={() => setShowFailuresModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-red-500/20 text-red-400 border border-red-500/30 rounded-full
hover:bg-red-500/30 transition-colors ml-auto"
>
<AlertTriangle className="w-3 h-3" />
View Failures ({totalFailures})
</button>
)}
</div>
{/* Status Message */}
{enrichmentState &&
enrichmentState.status !== "idle" && (
<div className="mt-3 p-2 bg-white/5 rounded text-xs">
<div className="flex items-center gap-2">
{enrichmentState.status ===
"running" && (
<Loader2 className="w-3 h-3 animate-spin text-[#ecb200]" />
)}
{enrichmentState.status ===
"paused" && (
<Pause className="w-3 h-3 text-yellow-400" />
)}
{enrichmentState.status ===
"stopping" && (
<StopCircle className="w-3 h-3 text-red-400 animate-pulse" />
)}
<span className="text-white/70">
{enrichmentState.status ===
"running" &&
`Processing ${enrichmentState.currentPhase}...`}
{enrichmentState.status ===
"paused" && "Enrichment paused"}
{enrichmentState.status ===
"stopping" &&
`Stopping... finishing ${
enrichmentState.stoppingInfo
?.currentItem ||
"current item"
}`}
</span>
</div>
{enrichmentState.status === "running" &&
enrichmentState.currentPhase ===
"artists" &&
enrichmentState.artists?.current && (
<div className="mt-1 text-white/50 truncate">
Current:{" "}
{
enrichmentState.artists
.current
}
</div>
)}
{enrichmentState.status === "running" &&
enrichmentState.currentPhase ===
"tracks" &&
enrichmentState.tracks?.current && (
<div className="mt-1 text-white/50 truncate">
Current:{" "}
{enrichmentState.tracks.current}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Cache Sizes */}
<SettingsRow
label="User cache size"
description="Maximum storage for offline content"
>
<div className="flex items-center gap-3">
<input
type="range"
min={512}
max={20480}
step={512}
value={settings.maxCacheSizeMb}
onChange={(e) => onUpdate({ maxCacheSizeMb: parseInt(e.target.value) })}
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
<span className="text-sm text-white w-16 text-right">
{(settings.maxCacheSizeMb / 1024).toFixed(1)} GB
</span>
</div>
</SettingsRow>
<SettingsRow
label="Transcode cache size"
description="Server restart required for changes"
>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={50}
value={settings.transcodeCacheMaxGb}
onChange={(e) => onUpdate({ transcodeCacheMaxGb: parseInt(e.target.value) })}
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
<span className="text-sm text-white w-16 text-right">
{settings.transcodeCacheMaxGb} GB
</span>
</div>
</SettingsRow>
{/* Automation */}
<SettingsRow
label="Auto sync library"
description="Automatically sync library changes"
htmlFor="auto-sync"
>
<SettingsToggle
id="auto-sync"
checked={settings.autoSync}
onChange={(checked) => onUpdate({ autoSync: checked })}
/>
</SettingsRow>
<SettingsRow
label="Auto enrich metadata"
description="Automatically enrich metadata for new content"
htmlFor="auto-enrich"
>
<SettingsToggle
id="auto-enrich"
checked={settings.autoEnrichMetadata}
onChange={(checked) => onUpdate({ autoEnrichMetadata: checked })}
/>
</SettingsRow>
{/* Cache Actions */}
<div className="flex flex-col gap-3 pt-4">
<button
onClick={handleClearCaches}
disabled={clearingCaches}
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full w-fit
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{clearingCaches ? "Clearing..." : "Clear All Caches"}
</button>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
</div>
</SettingsSection>
{/* Cache Sizes */}
<SettingsRow
label="User cache size"
description="Maximum storage for offline content"
>
<div className="flex items-center gap-3">
<input
type="range"
min={512}
max={20480}
step={512}
value={settings.maxCacheSizeMb}
onChange={(e) =>
onUpdate({
maxCacheSizeMb: parseInt(e.target.value),
})
}
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
<span className="text-sm text-white w-16 text-right">
{(settings.maxCacheSizeMb / 1024).toFixed(1)} GB
</span>
</div>
</SettingsRow>
<SettingsRow
label="Transcode cache size"
description="Server restart required for changes"
>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={50}
value={settings.transcodeCacheMaxGb}
onChange={(e) =>
onUpdate({
transcodeCacheMaxGb: parseInt(
e.target.value
),
})
}
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
<span className="text-sm text-white w-16 text-right">
{settings.transcodeCacheMaxGb} GB
</span>
</div>
</SettingsRow>
{/* Automation */}
<SettingsRow
label="Auto sync library"
description="Automatically sync library changes"
htmlFor="auto-sync"
>
<SettingsToggle
id="auto-sync"
checked={settings.autoSync}
onChange={(checked) => onUpdate({ autoSync: checked })}
/>
</SettingsRow>
<SettingsRow
label="Auto enrich metadata"
description="Automatically enrich metadata for new content"
htmlFor="auto-enrich"
>
<SettingsToggle
id="auto-enrich"
checked={settings.autoEnrichMetadata}
onChange={(checked) =>
onUpdate({ autoEnrichMetadata: checked })
}
/>
</SettingsRow>
{/* Enrichment Speed Control */}
{settings.autoEnrichMetadata && (
<SettingsRow
label="Metadata Fetch Speed"
description="Parallel Last.fm/MusicBrainz requests for artist bios and mood tags. Higher = faster but may trigger rate limits."
>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={5}
value={enrichmentSpeed}
disabled={isConcurrencyLoading}
onChange={(e) => {
const newSpeed = parseInt(e.target.value);
setConcurrencyMutation.mutate(newSpeed);
}}
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
<div className="flex flex-col items-end gap-0.5">
{isConcurrencyLoading ? (
<span className="text-sm text-white/50 w-24 text-right">
Loading...
</span>
) : (
<>
<span className="text-sm text-white w-24 text-right">
{enrichmentSpeed === 1
? "Conservative"
: enrichmentSpeed === 2
? "Moderate"
: enrichmentSpeed === 3
? "Balanced"
: enrichmentSpeed === 4
? "Fast"
: "Maximum"}
</span>
{concurrencyConfig && (
<span className="text-xs text-white/50 w-24 text-right">
~
{
concurrencyConfig.artistsPerMin
}{" "}
artists/min
</span>
)}
</>
)}
</div>
</div>
</SettingsRow>
)}
{/* Audio Analyzer Workers Control */}
{settings.autoEnrichMetadata && (
<SettingsRow
label="Audio Analysis Workers"
description="CPU workers for Essentia ML analysis (BPM, key, mood, energy). Lower values reduce CPU usage on older systems."
>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={8}
value={workersConfig?.workers ?? 2}
disabled={isWorkersLoading}
onChange={(e) => {
const newWorkers = parseInt(e.target.value);
setAnalysisWorkersMutation.mutate(newWorkers);
}}
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
<div className="flex flex-col items-end gap-0.5">
{isWorkersLoading ? (
<span className="text-sm text-white/50 w-24 text-right">
Loading...
</span>
) : (
<>
<span className="text-sm text-white w-24 text-right">
{workersConfig?.workers ?? 2} workers
</span>
{workersConfig && (
<span className="text-xs text-white/50 w-24 text-right">
{workersConfig.cpuCores} cores available
</span>
)}
</>
)}
</div>
</div>
</SettingsRow>
)}
{/* Cache Actions */}
<div className="flex flex-col gap-3 pt-4">
<button
onClick={handleClearCaches}
disabled={clearingCaches}
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full w-fit
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{clearingCaches ? "Clearing..." : "Clear All Caches"}
</button>
<button
onClick={handleCleanupStaleJobs}
disabled={cleaningStaleJobs}
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full w-fit
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{cleaningStaleJobs
? "Cleaning..."
: "Cleanup Stale Jobs"}
</button>
{cleanupResult && cleanupResult.totalCleaned > 0 && (
<p className="text-sm text-green-400">
Cleaned:{" "}
{cleanupResult.cleaned.discoveryBatches.cleaned}{" "}
batches,{" "}
{cleanupResult.cleaned.downloadJobs.cleaned}{" "}
downloads,{" "}
{cleanupResult.cleaned.spotifyImportJobs.cleaned}{" "}
imports, {cleanupResult.cleaned.bullQueues.cleaned}{" "}
queue jobs
</p>
)}
{cleanupResult && cleanupResult.totalCleaned === 0 && (
<p className="text-sm text-white/50">
No stale jobs found
</p>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
</SettingsSection>
<EnrichmentFailuresModal
isOpen={showFailuresModal}
onClose={() => setShowFailuresModal(false)}
/>
</>
);
}

View File

@@ -12,6 +12,34 @@ export function DownloadPreferencesSection({
settings,
onUpdate,
}: DownloadPreferencesSectionProps) {
// Service configuration detection
const isLidarrConfigured =
settings.lidarrEnabled === true &&
settings.lidarrUrl.trim() !== "" &&
settings.lidarrApiKey.trim() !== "";
const isSoulseekConfigured =
settings.soulseekUsername.trim() !== "" &&
settings.soulseekPassword.trim() !== "";
const areBothServicesConfigured = isLidarrConfigured && isSoulseekConfigured;
const isDisabled = !areBothServicesConfigured;
// Dynamic fallback options based on primary source
const getFallbackOptions = () => {
if (settings.downloadSource === "soulseek") {
return [
{ value: "none", label: "Skip track" },
{ value: "lidarr", label: "Download full album via Lidarr" },
];
} else {
return [
{ value: "none", label: "Skip album" },
{ value: "soulseek", label: "Try Soulseek for individual tracks" },
];
}
};
return (
<SettingsSection
id="download-preferences"
@@ -20,42 +48,80 @@ export function DownloadPreferencesSection({
>
<SettingsRow
label="Primary Download Source"
description="Choose how to download music for imported playlists"
description={
isDisabled
? "Requires both Soulseek and Lidarr to be configured"
: "Choose how to download music for imported playlists"
}
>
<SettingsSelect
value={settings.downloadSource || "soulseek"}
onChange={(v) =>
onUpdate({ downloadSource: v as "soulseek" | "lidarr" })
onUpdate({
downloadSource: v as "soulseek" | "lidarr",
primaryFailureFallback: "none"
})
}
options={[
{ value: "soulseek", label: "Soulseek (Per-track)" },
{ value: "lidarr", label: "Lidarr (Full albums)" },
]}
disabled={isDisabled}
/>
</SettingsRow>
{settings.downloadSource === "soulseek" && (
<SettingsRow
label="When Soulseek Fails"
description="What to do if a track can't be found on Soulseek"
>
<SettingsSelect
value={settings.soulseekFallback || "none"}
onChange={(v) =>
onUpdate({
soulseekFallback: v as "none" | "lidarr",
})
}
options={[
{ value: "none", label: "Skip track" },
{
value: "lidarr",
label: "Download full album via Lidarr",
},
]}
/>
</SettingsRow>
)}
<SettingsRow
label={
settings.downloadSource === "soulseek"
? "When Soulseek Fails"
: "When Lidarr Fails"
}
description={
isDisabled
? "Requires both Soulseek and Lidarr to be configured"
: settings.downloadSource === "soulseek"
? "What to do if a track can't be found on Soulseek"
: "What to do if an album can't be found on Lidarr"
}
>
<SettingsSelect
value={settings.primaryFailureFallback || "none"}
onChange={(v) =>
onUpdate({
primaryFailureFallback: v as "none" | "lidarr" | "soulseek",
})
}
options={getFallbackOptions()}
disabled={isDisabled}
/>
</SettingsRow>
<SettingsRow
label="Soulseek Concurrent Downloads"
description="Number of simultaneous downloads when using Soulseek (1-10)"
>
<SettingsSelect
value={settings.soulseekConcurrentDownloads?.toString() || "4"}
onChange={(v) =>
onUpdate({
soulseekConcurrentDownloads: parseInt(v),
})
}
options={[
{ value: "1", label: "1" },
{ value: "2", label: "2" },
{ value: "3", label: "3" },
{ value: "4", label: "4 (Default)" },
{ value: "5", label: "5" },
{ value: "6", label: "6" },
{ value: "7", label: "7" },
{ value: "8", label: "8" },
{ value: "9", label: "9" },
{ value: "10", label: "10" },
]}
disabled={!isSoulseekConfigured}
/>
</SettingsRow>
</SettingsSection>
);
}

View File

@@ -12,6 +12,7 @@ const defaultSystemSettings: SystemSettings = {
openaiModel: "gpt-4",
fanartEnabled: false,
fanartApiKey: "",
lastfmApiKey: "",
audiobookshelfEnabled: false,
audiobookshelfUrl: "http://localhost:13378",
audiobookshelfApiKey: "",
@@ -25,9 +26,11 @@ const defaultSystemSettings: SystemSettings = {
maxCacheSizeMb: 10240,
autoSync: true,
autoEnrichMetadata: true,
audioAnalyzerWorkers: 2,
soulseekConcurrentDownloads: 4,
// Download preferences
downloadSource: "soulseek",
soulseekFallback: "none",
primaryFailureFallback: "none",
};
export function useSystemSettings() {
@@ -167,6 +170,9 @@ export function useSystemSettings() {
case "fanart":
result = await api.testFanart(systemSettings.fanartApiKey);
break;
case "lastfm":
result = await api.testLastfm(systemSettings.lastfmApiKey);
break;
case "audiobookshelf":
result = await api.testAudiobookshelf(
systemSettings.audiobookshelfUrl,

View File

@@ -23,6 +23,7 @@ export interface SystemSettings {
openaiModel: string;
fanartEnabled: boolean;
fanartApiKey: string;
lastfmApiKey: string;
// Audiobookshelf
audiobookshelfEnabled: boolean;
audiobookshelfUrl: string;
@@ -41,9 +42,11 @@ export interface SystemSettings {
maxCacheSizeMb: number;
autoSync: boolean;
autoEnrichMetadata: boolean;
audioAnalyzerWorkers: number;
soulseekConcurrentDownloads: number;
// Download Preferences
downloadSource: "soulseek" | "lidarr";
soulseekFallback: "none" | "lidarr";
primaryFailureFallback: "none" | "lidarr" | "soulseek";
}
export interface ApiKey {