This commit is contained in:
Xoconoch
2025-08-19 14:37:14 -06:00
parent 9830a46ebb
commit 93f8a019cc
4 changed files with 191 additions and 65 deletions

View File

@@ -2,7 +2,7 @@
Artist endpoint router. Artist endpoint router.
""" """
from fastapi import APIRouter, HTTPException, Request, Depends from fastapi import APIRouter, HTTPException, Request, Depends, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import json import json
import traceback import traceback
@@ -118,7 +118,9 @@ async def cancel_artist_download():
@router.get("/info") @router.get("/info")
async def get_artist_info( async def get_artist_info(
request: Request, current_user: User = Depends(require_auth_from_state) request: Request, current_user: User = Depends(require_auth_from_state),
limit: int = Query(10, ge=1), # default=10, must be >=1
offset: int = Query(0, ge=0) # default=0, must be >=0
): ):
""" """
Retrieves Spotify artist metadata given a Spotify artist ID. Retrieves Spotify artist metadata given a Spotify artist ID.
@@ -134,7 +136,7 @@ async def get_artist_info(
artist_metadata = get_spotify_info(spotify_id, "artist") artist_metadata = get_spotify_info(spotify_id, "artist")
# Get artist discography for albums # Get artist discography for albums
artist_discography = get_spotify_info(spotify_id, "artist_discography") artist_discography = get_spotify_info(spotify_id, "artist_discography", limit=limit, offset=offset)
# Combine metadata with discography # Combine metadata with discography
artist_info = {**artist_metadata, "albums": artist_discography} artist_info = {**artist_metadata, "albums": artist_discography}

View File

@@ -269,7 +269,7 @@ def get_spotify_info(
elif spotify_type == "artist_discography": elif spotify_type == "artist_discography":
# Get artist's albums with pagination # Get artist's albums with pagination
albums = client.artist_albums( albums = client.artist_albums(
spotify_id, limit=limit or 20, offset=offset or 0 spotify_id, limit=limit or 20, offset=offset or 0, include_groups="single,album,appears_on"
) )
return albums return albums

View File

@@ -1,7 +1,7 @@
{ {
"name": "spotizerr-ui", "name": "spotizerr-ui",
"private": true, "private": true,
"version": "3.1.2", "version": "3.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,5 +1,5 @@
import { Link, useParams } from "@tanstack/react-router"; import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext } 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 { AlbumType, ArtistType, TrackType } from "../types/spotify"; import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
@@ -18,58 +18,170 @@ export const Artist = () => {
const context = useContext(QueueContext); const context = useContext(QueueContext);
const { settings } = useSettings(); const { settings } = useSettings();
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Pagination state
const LIMIT = 20; // tune as you like
const [offset, setOffset] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(false);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true); // assume more until we learn otherwise
if (!context) { if (!context) {
throw new Error("useQueue must be used within a QueueProvider"); throw new Error("useQueue must be used within a QueueProvider");
} }
const { addItem } = context; const { addItem } = context;
useEffect(() => { const applyFilters = useCallback(
const fetchArtistData = async () => { (items: AlbumType[]) => {
if (!artistId) return; return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true));
try { },
const response = await apiClient.get(`/artist/info?id=${artistId}`); [settings?.explicitFilter]
const artistData = response.data; );
// Check if we have artist data in the response // Helper to dedupe albums by id
if (artistData?.id && artistData?.name) { const dedupeAppendAlbums = (current: AlbumType[], incoming: AlbumType[]) => {
// Set artist info directly from the response const seen = new Set(current.map((a) => a.id));
const filtered = incoming.filter((a) => !seen.has(a.id));
return current.concat(filtered);
};
// Fetch artist info & first page of albums
useEffect(() => {
if (!artistId) return;
let cancelled = false;
const fetchInitial = async () => {
setLoading(true);
setError(null);
setAlbums([]);
setOffset(0);
setHasMore(true);
try {
const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=0`);
const data = resp.data;
if (cancelled) return;
if (data?.id && data?.name) {
// set artist meta
setArtist({ setArtist({
id: artistData.id, id: data.id,
name: artistData.name, name: data.name,
images: artistData.images || [], images: data.images || [],
external_urls: artistData.external_urls || { spotify: "" }, external_urls: data.external_urls || { spotify: "" },
followers: artistData.followers || { total: 0 }, followers: data.followers || { total: 0 },
genres: artistData.genres || [], genres: data.genres || [],
popularity: artistData.popularity || 0, popularity: data.popularity || 0,
type: artistData.type || 'artist', type: data.type || "artist",
uri: artistData.uri || '' uri: data.uri || "",
}); });
// Check if we have albums data // top tracks (if provided)
if (artistData?.albums?.items && artistData.albums.items.length > 0) { if (Array.isArray(data.top_tracks)) {
setAlbums(artistData.albums.items); setTopTracks(data.top_tracks);
} else { } else {
setError("No albums found for this artist."); setTopTracks([]);
return; }
// albums pagination info
const items: AlbumType[] = (data?.albums?.items as AlbumType[]) || [];
const total: number | undefined = data?.albums?.total;
setAlbums(items);
setOffset(items.length);
if (typeof total === "number") {
setHasMore(items.length < total);
} else {
// If server didn't return total, default behavior: stop when an empty page arrives.
setHasMore(items.length > 0);
} }
} else { } else {
setError("Could not load artist data."); setError("Could not load artist data.");
return;
} }
setTopTracks([]); // fetch watch status
try {
const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`); const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`);
setIsWatched(watchStatusResponse.data.is_watched); if (!cancelled) setIsWatched(watchStatusResponse.data.is_watched);
} catch (e) {
// ignore watch status errors
console.warn("Failed to load watch status", e);
}
} catch (err) { } catch (err) {
setError("Failed to load artist page"); if (!cancelled) {
console.error(err); console.error(err);
setError("Failed to load artist page");
}
} finally {
if (!cancelled) setLoading(false);
} }
}; };
fetchArtistData(); fetchInitial();
}, [artistId]);
return () => {
cancelled = true;
};
}, [artistId, LIMIT]);
// Fetch more albums (next page)
const fetchMoreAlbums = useCallback(async () => {
if (!artistId || loadingMore || loading || !hasMore) return;
setLoadingMore(true);
try {
const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=${offset}`);
const data = resp.data;
const items: AlbumType[] = (data?.albums?.items as AlbumType[]) || [];
const total: number | undefined = data?.albums?.total;
setAlbums((cur) => dedupeAppendAlbums(cur, items));
setOffset((cur) => cur + items.length);
if (typeof total === "number") {
setHasMore((prev) => prev && offset + items.length < total);
} else {
// if server doesn't expose total, stop when we get fewer than LIMIT items
setHasMore(items.length === LIMIT);
}
} catch (err) {
console.error("Failed to load more albums", err);
toast.error("Failed to load more albums");
setHasMore(false);
} finally {
setLoadingMore(false);
}
}, [artistId, offset, LIMIT, loadingMore, loading, hasMore]);
// IntersectionObserver to trigger fetchMoreAlbums when sentinel is visible
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
if (!hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fetchMoreAlbums();
}
});
},
{
root: null,
rootMargin: "400px", // start loading a bit before the sentinel enters viewport
threshold: 0.1,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchMoreAlbums, hasMore]);
// --- existing handlers (unchanged) ---
const handleDownloadTrack = (track: TrackType) => { const handleDownloadTrack = (track: TrackType) => {
if (!track.id) return; if (!track.id) return;
toast.info(`Adding ${track.name} to queue...`); toast.info(`Adding ${track.name} to queue...`);
@@ -91,23 +203,17 @@ export const Artist = () => {
const response = await apiClient.get(`/artist/download/${artistId}`); const response = await apiClient.get(`/artist/download/${artistId}`);
if (response.data.queued_albums?.length > 0) { if (response.data.queued_albums?.length > 0) {
toast.success( toast.success(`${artist.name} discography queued successfully!`, {
`${artist.name} discography queued successfully!`,
{
description: `${response.data.queued_albums.length} albums added to queue.`, description: `${response.data.queued_albums.length} albums added to queue.`,
} });
);
} else { } else {
toast.info("No new albums to download for this artist."); toast.info("No new albums to download for this artist.");
} }
} catch (error: any) { } catch (error: any) {
console.error("Artist download failed:", error); console.error("Artist download failed:", error);
toast.error( toast.error("Failed to download artist", {
"Failed to download artist",
{
description: error.response?.data?.error || "An unexpected error occurred.", description: error.response?.data?.error || "An unexpected error occurred.",
} });
);
} }
}; };
@@ -132,18 +238,14 @@ export const Artist = () => {
return <div className="text-red-500">{error}</div>; return <div className="text-red-500">{error}</div>;
} }
if (!artist) { if (loading && !artist) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
if (!artist.name) { if (!artist) {
return <div>Artist data could not be fully loaded. Please try again later.</div>; return <div>Artist data could not be fully loaded. Please try again later.</div>;
} }
const applyFilters = (items: AlbumType[]) => {
return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true));
};
const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album")); const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album"));
const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single")); const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single"));
const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation")); const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation"));
@@ -178,8 +280,7 @@ export const Artist = () => {
</button> </button>
<button <button
onClick={handleToggleWatch} onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${ className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
isWatched
? "bg-button-primary text-button-primary-text border-primary" ? "bg-button-primary text-button-primary-text border-primary"
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark" : "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
}`} }`}
@@ -208,7 +309,11 @@ export const Artist = () => {
key={track.id} key={track.id}
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors" className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
> >
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold text-content-primary dark:text-content-primary-dark"> <Link
to="/track/$trackId"
params={{ trackId: track.id }}
className="font-semibold text-content-primary dark:text-content-primary-dark"
>
{track.name} {track.name}
</Link> </Link>
<button <button
@@ -223,6 +328,7 @@ export const Artist = () => {
</div> </div>
)} )}
{/* Albums */}
{artistAlbums.length > 0 && ( {artistAlbums.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Albums</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Albums</h2>
@@ -234,6 +340,7 @@ export const Artist = () => {
</div> </div>
)} )}
{/* Singles */}
{artistSingles.length > 0 && ( {artistSingles.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Singles</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Singles</h2>
@@ -245,6 +352,7 @@ export const Artist = () => {
</div> </div>
)} )}
{/* Compilations */}
{artistCompilations.length > 0 && ( {artistCompilations.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Compilations</h2> <h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Compilations</h2>
@@ -255,6 +363,22 @@ export const Artist = () => {
</div> </div>
</div> </div>
)} )}
{/* sentinel + loading */}
<div className="flex flex-col items-center gap-2">
{loadingMore && <div className="py-4">Loading more...</div>}
{!hasMore && !loading && <div className="py-4 text-sm text-content-secondary">End of discography</div>}
{/* fallback load more button for browsers that block IntersectionObserver or for manual control */}
{hasMore && !loadingMore && (
<button
onClick={() => fetchMoreAlbums()}
className="px-4 py-2 mb-6 rounded bg-surface-muted hover:bg-surface-muted-dark"
>
Load more
</button>
)}
<div ref={sentinelRef} style={{ height: 1, width: "100%" }} />
</div>
</div> </div>
); );
}; };