fix: images and id not loading for playlists in watchlist
This commit is contained in:
@@ -205,6 +205,9 @@ async def get_playlist_info(
|
|||||||
playlist_info = get_playlist(client, spotify_id, expand_items=False)
|
playlist_info = get_playlist(client, spotify_id, expand_items=False)
|
||||||
finally:
|
finally:
|
||||||
pass
|
pass
|
||||||
|
# Ensure id field is present (librespot sometimes omits it)
|
||||||
|
if playlist_info and "id" not in playlist_info:
|
||||||
|
playlist_info["id"] = spotify_id
|
||||||
|
|
||||||
return JSONResponse(content=playlist_info, status_code=200)
|
return JSONResponse(content=playlist_info, status_code=200)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -2,26 +2,15 @@ import { Link, useParams } from "@tanstack/react-router";
|
|||||||
import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import apiClient from "../lib/api-client";
|
import apiClient from "../lib/api-client";
|
||||||
import type { LibrespotAlbumType, LibrespotArtistType, LibrespotTrackType, LibrespotImage } from "@/types/librespot";
|
import type { LibrespotAlbumType, LibrespotArtistType, LibrespotTrackType } from "@/types/librespot";
|
||||||
import { QueueContext, getStatus } from "../contexts/queue-context";
|
import { QueueContext, getStatus } from "../contexts/queue-context";
|
||||||
import { useSettings } from "../contexts/settings-context";
|
import { useSettings } from "../contexts/settings-context";
|
||||||
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
|
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
|
||||||
import { AlbumCard } from "../components/AlbumCard";
|
import { AlbumCard } from "../components/AlbumCard";
|
||||||
|
|
||||||
// Narrow type for the artist info response additions
|
|
||||||
type ArtistInfoResponse = LibrespotArtistType & {
|
|
||||||
biography?: Array<{ text?: string; portrait_group?: { image?: LibrespotImage[] } }>;
|
|
||||||
portrait_group?: { image?: LibrespotImage[] };
|
|
||||||
top_track?: Array<{ country: string; track: string[] }>;
|
|
||||||
album_group?: string[];
|
|
||||||
single_group?: string[];
|
|
||||||
compilation_group?: string[];
|
|
||||||
appears_on_group?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Artist = () => {
|
export const Artist = () => {
|
||||||
const { artistId } = useParams({ from: "/artist/$artistId" });
|
const { artistId } = useParams({ from: "/artist/$artistId" });
|
||||||
const [artist, setArtist] = useState<ArtistInfoResponse | null>(null);
|
const [artist, setArtist] = useState<LibrespotArtistType | null>(null);
|
||||||
const [artistAlbums, setArtistAlbums] = useState<LibrespotAlbumType[]>([]);
|
const [artistAlbums, setArtistAlbums] = useState<LibrespotAlbumType[]>([]);
|
||||||
const [artistSingles, setArtistSingles] = useState<LibrespotAlbumType[]>([]);
|
const [artistSingles, setArtistSingles] = useState<LibrespotAlbumType[]>([]);
|
||||||
const [artistCompilations, setArtistCompilations] = useState<LibrespotAlbumType[]>([]);
|
const [artistCompilations, setArtistCompilations] = useState<LibrespotAlbumType[]>([]);
|
||||||
@@ -94,8 +83,8 @@ export const Artist = () => {
|
|||||||
setBannerUrl(null); // reset hero; will lazy-load below
|
setBannerUrl(null); // reset hero; will lazy-load below
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await apiClient.get<ArtistInfoResponse>(`/artist/info?id=${artistId}`);
|
const resp = await apiClient.get<LibrespotArtistType>(`/artist/info?id=${artistId}`);
|
||||||
const data: ArtistInfoResponse = resp.data;
|
const data: LibrespotArtistType = resp.data;
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -104,10 +93,10 @@ export const Artist = () => {
|
|||||||
setArtist(data);
|
setArtist(data);
|
||||||
|
|
||||||
// Lazy-load banner image after render
|
// Lazy-load banner image after render
|
||||||
const bioEntry = Array.isArray(data.biography) && data.biography.length > 0 ? data.biography[0] : undefined;
|
const allImages = [...(data.portrait_group ?? []), ...(data.biography?.portrait_group ?? [])];
|
||||||
const portraitImages = data.portrait_group?.image ?? bioEntry?.portrait_group?.image ?? [];
|
const candidateBanner = allImages
|
||||||
const allImages = [...(portraitImages ?? []), ...((data.images as LibrespotImage[] | undefined) ?? [])];
|
.filter(img => img && typeof img === 'object' && 'url' in img)
|
||||||
const candidateBanner = allImages.sort((a, b) => (b?.width ?? 0) - (a?.width ?? 0))[0]?.url || "/placeholder.jpg";
|
.sort((a, b) => (b.width ?? 0) - (a.width ?? 0))[0]?.url || "/placeholder.jpg";
|
||||||
// Use async preload to avoid blocking initial paint
|
// Use async preload to avoid blocking initial paint
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import apiClient from "../lib/api-client";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useSettings } from "../contexts/settings-context";
|
import { useSettings } from "../contexts/settings-context";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import type { ArtistType, PlaylistType } from "../types/spotify";
|
import type { LibrespotArtistType, LibrespotPlaylistType } from "../types/librespot";
|
||||||
import { FaRegTrashAlt, FaSearch } from "react-icons/fa";
|
import { FaRegTrashAlt, FaSearch } from "react-icons/fa";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
@@ -11,8 +11,8 @@ interface BaseWatched {
|
|||||||
itemType: "artist" | "playlist";
|
itemType: "artist" | "playlist";
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
}
|
}
|
||||||
type WatchedArtist = ArtistType & { itemType: "artist" };
|
type WatchedArtist = LibrespotArtistType & { itemType: "artist" };
|
||||||
type WatchedPlaylist = PlaylistType & { itemType: "playlist" };
|
type WatchedPlaylist = LibrespotPlaylistType & { itemType: "playlist" };
|
||||||
|
|
||||||
type WatchedItem = WatchedArtist | WatchedPlaylist;
|
type WatchedItem = WatchedArtist | WatchedPlaylist;
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ export const Watchlist = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fetch artist details in batches
|
// Fetch artist details in batches
|
||||||
await batchFetch<ArtistType>(
|
await batchFetch<LibrespotArtistType>(
|
||||||
artistIds,
|
artistIds,
|
||||||
(id) => apiClient.get<ArtistType>(`/artist/info?id=${id}`).then(res => res.data),
|
(id) => apiClient.get<LibrespotArtistType>(`/artist/info?id=${id}`).then(res => res.data),
|
||||||
5, // batch size
|
5, // batch size
|
||||||
(results) => {
|
(results) => {
|
||||||
const items: WatchedArtist[] = results.map((data) => ({
|
const items: WatchedArtist[] = results.map((data) => ({
|
||||||
@@ -76,9 +76,9 @@ export const Watchlist = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Fetch playlist details in batches
|
// Fetch playlist details in batches
|
||||||
await batchFetch<PlaylistType>(
|
await batchFetch<LibrespotPlaylistType>(
|
||||||
playlistIds,
|
playlistIds,
|
||||||
(id) => apiClient.get<PlaylistType>(`/playlist/info?id=${id}`).then(res => res.data),
|
(id) => apiClient.get<LibrespotPlaylistType>(`/playlist/info?id=${id}`).then(res => res.data),
|
||||||
5, // batch size
|
5, // batch size
|
||||||
(results) => {
|
(results) => {
|
||||||
const items: WatchedPlaylist[] = results.map((data) => ({
|
const items: WatchedPlaylist[] = results.map((data) => ({
|
||||||
@@ -170,11 +170,15 @@ export const Watchlist = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col">
|
<div key={item.id} className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col">
|
||||||
<a href={`/${item.itemType}/${item.id}`} className="flex-grow">
|
<a href={`/${item.itemType}/${item.id}`} className="flex-grow">
|
||||||
<img
|
<img
|
||||||
src={item.images?.[0]?.url || "/images/placeholder.jpg"}
|
src={
|
||||||
|
item.itemType === "artist"
|
||||||
|
? (item as WatchedArtist).portrait_group[0]?.url || "/images/placeholder.jpg"
|
||||||
|
: (item as WatchedPlaylist).picture || "/images/placeholder.jpg"
|
||||||
|
}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-auto object-cover rounded-md aspect-square"
|
className="w-full h-auto object-cover rounded-md aspect-square"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export interface LibrespotExternalUrls {
|
|||||||
|
|
||||||
export interface LibrespotImage {
|
export interface LibrespotImage {
|
||||||
url: string;
|
url: string;
|
||||||
width?: number;
|
width: number;
|
||||||
height?: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibrespotArtistStub {
|
export interface LibrespotArtistStub {
|
||||||
@@ -18,18 +18,23 @@ export interface LibrespotArtistStub {
|
|||||||
external_urls?: LibrespotExternalUrls;
|
external_urls?: LibrespotExternalUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibrespotBiographyType {
|
||||||
|
text: string;
|
||||||
|
portrait_group: LibrespotImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibrespotTopTrackType {
|
||||||
|
country: string;
|
||||||
|
track: string[];
|
||||||
|
}
|
||||||
// Full artist object (get_artist)
|
// Full artist object (get_artist)
|
||||||
export interface LibrespotArtistType {
|
export interface LibrespotArtistType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
images?: LibrespotImage[];
|
top_track: LibrespotTopTrackType[];
|
||||||
external_urls?: LibrespotExternalUrls;
|
portrait_group: LibrespotImage[];
|
||||||
followers?: { total: number };
|
popularity: number;
|
||||||
genres?: string[];
|
biography: LibrespotBiographyType;
|
||||||
popularity?: number;
|
|
||||||
type?: "artist";
|
|
||||||
uri?: string;
|
|
||||||
// Album groups: arrays of album IDs
|
|
||||||
album_group?: string[];
|
album_group?: string[];
|
||||||
single_group?: string[];
|
single_group?: string[];
|
||||||
compilation_group?: string[];
|
compilation_group?: string[];
|
||||||
@@ -64,24 +69,23 @@ export interface LibrespotTrackType {
|
|||||||
disc_number: number;
|
disc_number: number;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
explicit: boolean;
|
explicit: boolean;
|
||||||
external_ids?: { isrc?: string };
|
external_ids: { isrc?: string };
|
||||||
external_urls: LibrespotExternalUrls;
|
external_urls: LibrespotExternalUrls;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
popularity?: number;
|
popularity: number;
|
||||||
track_number: number;
|
track_number: number;
|
||||||
type: "track";
|
type: "track";
|
||||||
uri: string;
|
uri: string;
|
||||||
preview_url?: string;
|
preview_url: string;
|
||||||
has_lyrics?: boolean;
|
has_lyrics: boolean;
|
||||||
earliest_live_timestamp?: number;
|
earliest_live_timestamp: number;
|
||||||
licensor_uuid?: string; // when available
|
licensor_uuid: string; // when available
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibrespotAlbumType {
|
export interface LibrespotAlbumType {
|
||||||
album_type: "album" | "single" | "compilation";
|
album_type: "album" | "single" | "compilation";
|
||||||
total_tracks: number;
|
total_tracks: number;
|
||||||
available_markets?: string[];
|
|
||||||
external_urls: LibrespotExternalUrls;
|
external_urls: LibrespotExternalUrls;
|
||||||
id: string;
|
id: string;
|
||||||
images: LibrespotImage[];
|
images: LibrespotImage[];
|
||||||
@@ -96,8 +100,8 @@ export interface LibrespotAlbumType {
|
|||||||
tracks: string[] | LibrespotTrackType[];
|
tracks: string[] | LibrespotTrackType[];
|
||||||
copyrights?: LibrespotCopyright[];
|
copyrights?: LibrespotCopyright[];
|
||||||
external_ids?: { upc?: string };
|
external_ids?: { upc?: string };
|
||||||
label?: string;
|
label: string;
|
||||||
popularity?: number;
|
popularity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist types
|
// Playlist types
|
||||||
@@ -135,14 +139,14 @@ export interface LibrespotPlaylistTracksPageType {
|
|||||||
|
|
||||||
export interface LibrespotPlaylistType {
|
export interface LibrespotPlaylistType {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
id: string;
|
||||||
collaborative?: boolean;
|
description: string | null;
|
||||||
images?: Array<Pick<LibrespotImage, "url"> & Partial<LibrespotImage>>;
|
collaborative: boolean;
|
||||||
owner: LibrespotPlaylistOwnerType;
|
owner: LibrespotPlaylistOwnerType;
|
||||||
snapshot_id: string;
|
snapshot_id: string;
|
||||||
tracks: LibrespotPlaylistTracksPageType;
|
tracks: LibrespotPlaylistTracksPageType;
|
||||||
type: "playlist";
|
type: "playlist";
|
||||||
picture?: string;
|
picture: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type guards
|
// Type guards
|
||||||
|
|||||||
Reference in New Issue
Block a user