- {topTracks.map((track) => (
+ {topTracks.map((track, index) => (
))}
@@ -394,18 +474,6 @@ export const Artist = () => {
)}
- {/* Compilations */}
- {artistCompilations.length > 0 && (
-
- )}
-
{/* Appears On */}
{artistAppearsOn.length > 0 && (
diff --git a/spotizerr-ui/src/routes/playlist.tsx b/spotizerr-ui/src/routes/playlist.tsx
index 7fdfe7b..a8e850b 100644
--- a/spotizerr-ui/src/routes/playlist.tsx
+++ b/spotizerr-ui/src/routes/playlist.tsx
@@ -3,16 +3,14 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner";
-import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
+import type { LibrespotTrackType, LibrespotPlaylistType, LibrespotPlaylistItemType, LibrespotPlaylistTrackStubType } from "@/types/librespot";
import { QueueContext, getStatus } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa";
-
-
export const Playlist = () => {
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
- const [playlistMetadata, setPlaylistMetadata] = useState
(null);
- const [tracks, setTracks] = useState([]);
+ const [playlistMetadata, setPlaylistMetadata] = useState(null);
+ const [items, setItems] = useState([]);
const [isWatched, setIsWatched] = useState(false);
const [error, setError] = useState(null);
const [loadingTracks, setLoadingTracks] = useState(false);
@@ -28,11 +26,11 @@ export const Playlist = () => {
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
- const { addItem, items } = context;
+ const { addItem, items: queueItems } = context;
// Playlist queue status
const playlistQueueItem = playlistMetadata
- ? items.find(item => item.downloadType === "playlist" && item.spotifyId === playlistMetadata.id)
+ ? queueItems.find(item => item.downloadType === "playlist" && item.spotifyId === (playlistId ?? ""))
: undefined;
const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null;
@@ -44,14 +42,15 @@ export const Playlist = () => {
}
}, [playlistStatus]);
- // Load playlist metadata first
+ // Load playlist metadata first (no expanded items)
useEffect(() => {
- const fetchPlaylistMetadata = async () => {
+ const fetchPlaylist = async () => {
if (!playlistId) return;
try {
- const response = await apiClient.get(`/playlist/metadata?id=${playlistId}`);
- setPlaylistMetadata(response.data);
- setTotalTracks(response.data.tracks.total);
+ const response = await apiClient.get(`/playlist/info?id=${playlistId}`);
+ const data = response.data;
+ setPlaylistMetadata(data);
+ setTotalTracks(data.tracks.total);
} catch (err) {
setError("Failed to load playlist metadata");
console.error(err);
@@ -70,27 +69,49 @@ export const Playlist = () => {
}
};
- fetchPlaylistMetadata();
+ setItems([]);
+ setTracksOffset(0);
+ setHasMoreTracks(true);
+ setTotalTracks(0);
+ setError(null);
+ fetchPlaylist();
checkWatchStatus();
}, [playlistId]);
- // Load tracks progressively
+ const BATCH_SIZE = 6;
+
+ // Load items progressively by expanding track stubs when needed
const loadMoreTracks = useCallback(async () => {
- if (!playlistId || loadingTracks || !hasMoreTracks) return;
+ if (!playlistId || loadingTracks || !hasMoreTracks || !playlistMetadata) return;
setLoadingTracks(true);
try {
- const limit = 50; // Load 50 tracks at a time
- const response = await apiClient.get(
- `/playlist/tracks?id=${playlistId}&limit=${limit}&offset=${tracksOffset}`
- );
+ // Fetch full playlist snapshot (stub items)
+ const response = await apiClient.get(`/playlist/info?id=${playlistId}`);
+ const allItems = response.data.tracks.items;
+ const slice = allItems.slice(tracksOffset, tracksOffset + BATCH_SIZE);
- const newTracks = response.data.items;
- setTracks(prev => [...prev, ...newTracks]);
- setTracksOffset(prev => prev + newTracks.length);
-
- // Check if we've loaded all tracks
- if (tracksOffset + newTracks.length >= totalTracks) {
+ // Expand any stubbed track entries by fetching full track info
+ const expandedSlice: LibrespotPlaylistItemType[] = await Promise.all(
+ slice.map(async (it) => {
+ const t = it.track as LibrespotPlaylistTrackStubType | LibrespotTrackType;
+ // If track has only stub fields (no duration_ms), fetch full
+ if (t && (t as any).id && !("duration_ms" in (t as any))) {
+ try {
+ const full = await apiClient.get(`/track/info?id=${(t as LibrespotPlaylistTrackStubType).id}`).then(r => r.data);
+ return { ...it, track: full } as LibrespotPlaylistItemType;
+ } catch {
+ return it; // fallback to stub if fetch fails
+ }
+ }
+ return it;
+ })
+ );
+
+ setItems((prev) => [...prev, ...expandedSlice]);
+ const loaded = tracksOffset + expandedSlice.length;
+ setTracksOffset(loaded);
+ if (loaded >= totalTracks) {
setHasMoreTracks(false);
}
} catch (err) {
@@ -99,7 +120,7 @@ export const Playlist = () => {
} finally {
setLoadingTracks(false);
}
- }, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks]);
+ }, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks, playlistMetadata]);
// Intersection Observer for infinite scroll
useEffect(() => {
@@ -125,22 +146,14 @@ export const Playlist = () => {
};
}, [loadMoreTracks, hasMoreTracks, loadingTracks]);
- // Load initial tracks when metadata is loaded
+ // Kick off initial batch
useEffect(() => {
- if (playlistMetadata && tracks.length === 0 && totalTracks > 0) {
+ if (playlistMetadata && items.length === 0 && totalTracks > 0) {
loadMoreTracks();
}
- }, [playlistMetadata, tracks.length, totalTracks, loadMoreTracks]);
+ }, [playlistMetadata, items.length, totalTracks, loadMoreTracks]);
- // Reset state when playlist ID changes
- useEffect(() => {
- setTracks([]);
- setTracksOffset(0);
- setHasMoreTracks(true);
- setTotalTracks(0);
- }, [playlistId]);
-
- const handleDownloadTrack = (track: TrackType) => {
+ const handleDownloadTrack = (track: LibrespotTrackType) => {
if (!track?.id) return;
addItem({ spotifyId: track.id, type: "track", name: track.name });
toast.info(`Adding ${track.name} to queue...`);
@@ -149,7 +162,7 @@ export const Playlist = () => {
const handleDownloadPlaylist = () => {
if (!playlistMetadata) return;
addItem({
- spotifyId: playlistMetadata.id,
+ spotifyId: playlistId!,
type: "playlist",
name: playlistMetadata.name,
});
@@ -182,16 +195,19 @@ export const Playlist = () => {
}
// Map track download statuses
- const trackStatuses = tracks.reduce((acc, { track }) => {
- if (!track) return acc;
- const qi = items.find(item => item.downloadType === "track" && item.spotifyId === track.id);
- acc[track.id] = qi ? getStatus(qi) : null;
+ const trackStatuses = items.reduce((acc, { track }) => {
+ if (!track || (track as any).id === undefined) return acc;
+ const t = track as LibrespotTrackType;
+ const qi = queueItems.find(item => item.downloadType === "track" && item.spotifyId === t.id);
+ acc[t.id] = qi ? getStatus(qi) : null;
return acc;
}, {} as Record);
- const filteredTracks = tracks.filter(({ track }) => {
- if (!track) return false;
- if (settings?.explicitFilter && track.explicit) return false;
+ const filteredItems = items.filter(({ track }) => {
+ const t = track as LibrespotTrackType | LibrespotPlaylistTrackStubType | null;
+ if (!t || (t as any).id === undefined) return false;
+ const full = t as LibrespotTrackType;
+ if (settings?.explicitFilter && full.explicit) return false;
return true;
});
@@ -222,7 +238,7 @@ export const Playlist = () => {
{playlistMetadata.description}
)}
- By {playlistMetadata.owner.display_name} • {playlistMetadata.followers.total.toLocaleString()} followers • {totalTracks} songs
+ By {playlistMetadata.owner.display_name} • {totalTracks} songs