diff --git a/routes/content/artist.py b/routes/content/artist.py index e967ca8..b4c5302 100644 --- a/routes/content/artist.py +++ b/routes/content/artist.py @@ -2,7 +2,7 @@ Artist endpoint router. """ -from fastapi import APIRouter, HTTPException, Request, Depends +from fastapi import APIRouter, HTTPException, Request, Depends, Query from fastapi.responses import JSONResponse import json import traceback @@ -118,7 +118,9 @@ async def cancel_artist_download(): @router.get("/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. @@ -134,7 +136,7 @@ async def get_artist_info( artist_metadata = get_spotify_info(spotify_id, "artist") # 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 artist_info = {**artist_metadata, "albums": artist_discography} diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index c027fc9..7729b17 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -269,7 +269,7 @@ def get_spotify_info( elif spotify_type == "artist_discography": # Get artist's albums with pagination 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 diff --git a/spotizerr-ui/package.json b/spotizerr-ui/package.json index 5e81c13..54ed741 100644 --- a/spotizerr-ui/package.json +++ b/spotizerr-ui/package.json @@ -1,7 +1,7 @@ { "name": "spotizerr-ui", "private": true, - "version": "3.1.2", + "version": "3.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/spotizerr-ui/src/routes/artist.tsx b/spotizerr-ui/src/routes/artist.tsx index 7664216..7a59725 100644 --- a/spotizerr-ui/src/routes/artist.tsx +++ b/spotizerr-ui/src/routes/artist.tsx @@ -1,5 +1,5 @@ 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 apiClient from "../lib/api-client"; import type { AlbumType, ArtistType, TrackType } from "../types/spotify"; @@ -18,58 +18,170 @@ export const Artist = () => { const context = useContext(QueueContext); const { settings } = useSettings(); + const sentinelRef = useRef(null); + + // Pagination state + const LIMIT = 20; // tune as you like + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); // assume more until we learn otherwise + if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } const { addItem } = context; + const applyFilters = useCallback( + (items: AlbumType[]) => { + return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true)); + }, + [settings?.explicitFilter] + ); + + // Helper to dedupe albums by id + const dedupeAppendAlbums = (current: AlbumType[], incoming: AlbumType[]) => { + 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(() => { - const fetchArtistData = async () => { - if (!artistId) return; + if (!artistId) return; + + let cancelled = false; + + const fetchInitial = async () => { + setLoading(true); + setError(null); + setAlbums([]); + setOffset(0); + setHasMore(true); + try { - const response = await apiClient.get(`/artist/info?id=${artistId}`); - const artistData = response.data; + const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=0`); + const data = resp.data; - // Check if we have artist data in the response - if (artistData?.id && artistData?.name) { - // Set artist info directly from the response - setArtist({ - id: artistData.id, - name: artistData.name, - images: artistData.images || [], - external_urls: artistData.external_urls || { spotify: "" }, - followers: artistData.followers || { total: 0 }, - genres: artistData.genres || [], - popularity: artistData.popularity || 0, - type: artistData.type || 'artist', - uri: artistData.uri || '' - }); + if (cancelled) return; - // Check if we have albums data - if (artistData?.albums?.items && artistData.albums.items.length > 0) { - setAlbums(artistData.albums.items); + if (data?.id && data?.name) { + // set artist meta + setArtist({ + id: data.id, + name: data.name, + images: data.images || [], + external_urls: data.external_urls || { spotify: "" }, + followers: data.followers || { total: 0 }, + genres: data.genres || [], + popularity: data.popularity || 0, + type: data.type || "artist", + uri: data.uri || "", + }); + + // top tracks (if provided) + if (Array.isArray(data.top_tracks)) { + setTopTracks(data.top_tracks); } else { - setError("No albums found for this artist."); - return; + setTopTracks([]); + } + + // 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 { setError("Could not load artist data."); - return; } - setTopTracks([]); - - const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`); - setIsWatched(watchStatusResponse.data.is_watched); + // fetch watch status + try { + const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`); + if (!cancelled) setIsWatched(watchStatusResponse.data.is_watched); + } catch (e) { + // ignore watch status errors + console.warn("Failed to load watch status", e); + } } catch (err) { - setError("Failed to load artist page"); - console.error(err); + if (!cancelled) { + console.error(err); + setError("Failed to load artist page"); + } + } finally { + if (!cancelled) setLoading(false); } }; - fetchArtistData(); - }, [artistId]); + fetchInitial(); + 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) => { if (!track.id) return; toast.info(`Adding ${track.name} to queue...`); @@ -83,31 +195,25 @@ export const Artist = () => { const handleDownloadArtist = async () => { if (!artistId || !artist) return; - + try { toast.info(`Downloading ${artist.name} discography...`); - + // Call the artist download endpoint which returns album task IDs const response = await apiClient.get(`/artist/download/${artistId}`); - + if (response.data.queued_albums?.length > 0) { - toast.success( - `${artist.name} discography queued successfully!`, - { - description: `${response.data.queued_albums.length} albums added to queue.`, - } - ); + toast.success(`${artist.name} discography queued successfully!`, { + description: `${response.data.queued_albums.length} albums added to queue.`, + }); } else { toast.info("No new albums to download for this artist."); } } catch (error: any) { console.error("Artist download failed:", error); - toast.error( - "Failed to download artist", - { - description: error.response?.data?.error || "An unexpected error occurred.", - } - ); + toast.error("Failed to download artist", { + description: error.response?.data?.error || "An unexpected error occurred.", + }); } }; @@ -132,18 +238,14 @@ export const Artist = () => { return
{error}
; } - if (!artist) { + if (loading && !artist) { return
Loading...
; } - if (!artist.name) { + if (!artist) { return
Artist data could not be fully loaded. Please try again later.
; } - const applyFilters = (items: AlbumType[]) => { - return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true)); - }; - const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album")); const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single")); const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation")); @@ -178,11 +280,10 @@ export const Artist = () => { + )} +
+
); };