diff --git a/spotizerr-ui/package.json b/spotizerr-ui/package.json index a4f034c..f2d0260 100644 --- a/spotizerr-ui/package.json +++ b/spotizerr-ui/package.json @@ -22,6 +22,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.57.0", + "react-icons": "^5.5.0", "sonner": "^2.0.5", "tailwindcss": "^4.1.8", "use-debounce": "^10.0.5", diff --git a/spotizerr-ui/pnpm-lock.yaml b/spotizerr-ui/pnpm-lock.yaml index 541de6e..2e4b915 100644 --- a/spotizerr-ui/pnpm-lock.yaml +++ b/spotizerr-ui/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: react-hook-form: specifier: ^7.57.0 version: 7.57.0(react@19.1.0) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.1.0) sonner: specifier: ^2.0.5 version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1719,6 +1722,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-icons@5.5.0: + resolution: + { integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw== } + peerDependencies: + react: "*" + react-refresh@0.17.0: resolution: { integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== } @@ -3196,6 +3205,10 @@ snapshots: dependencies: react: 19.1.0 + react-icons@5.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-refresh@0.17.0: {} react@19.1.0: {} diff --git a/spotizerr-ui/src/components/AlbumCard.tsx b/spotizerr-ui/src/components/AlbumCard.tsx new file mode 100644 index 0000000..5d5fbfa --- /dev/null +++ b/spotizerr-ui/src/components/AlbumCard.tsx @@ -0,0 +1,44 @@ +import { Link } from "@tanstack/react-router"; +import type { AlbumType } from "../types/spotify"; + +interface AlbumCardProps { + album: AlbumType; + onDownload?: () => void; +} + +export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => { + const imageUrl = album.images && album.images.length > 0 ? album.images[0].url : "/placeholder.jpg"; + const subtitle = album.artists.map((artist) => artist.name).join(", "); + + return ( +
+
+ + {album.name} + {onDownload && ( + + )} + +
+
+ + {album.name} + + {subtitle &&

{subtitle}

} +
+
+ ); +}; diff --git a/spotizerr-ui/src/components/Queue.tsx b/spotizerr-ui/src/components/Queue.tsx index aea11d0..d5b89e0 100644 --- a/spotizerr-ui/src/components/Queue.tsx +++ b/spotizerr-ui/src/components/Queue.tsx @@ -1,162 +1,183 @@ -import { useQueue, type QueueItem } from "../contexts/queue-context"; +import { useContext } from "react"; +import { + FaTimes, + FaSync, + FaCheckCircle, + FaExclamationCircle, + FaHourglassHalf, + FaMusic, + FaCompactDisc, +} from "react-icons/fa"; +import { QueueContext, type QueueItem, type QueueStatus } from "@/contexts/queue-context"; -export function Queue() { - const { items, isVisible, removeItem, retryItem, clearQueue, toggleVisibility, clearCompleted } = useQueue(); +const statusStyles: Record = { + queued: { + icon: , + color: "text-gray-500", + bgColor: "bg-gray-100", + name: "Queued", + }, + initializing: { + icon: , + color: "text-blue-500", + bgColor: "bg-blue-100", + name: "Initializing", + }, + downloading: { + icon: , + color: "text-blue-500", + bgColor: "bg-blue-100", + name: "Downloading", + }, + processing: { + icon: , + color: "text-purple-500", + bgColor: "bg-purple-100", + name: "Processing", + }, + completed: { + icon: , + color: "text-green-500", + bgColor: "bg-green-100", + name: "Completed", + }, + done: { + icon: , + color: "text-green-500", + bgColor: "bg-green-100", + name: "Done", + }, + error: { + icon: , + color: "text-red-500", + bgColor: "bg-red-100", + name: "Error", + }, + cancelled: { + icon: , + color: "text-yellow-500", + bgColor: "bg-yellow-100", + name: "Cancelled", + }, + skipped: { + icon: , + color: "text-gray-500", + bgColor: "bg-gray-100", + name: "Skipped", + }, + pending: { + icon: , + color: "text-gray-500", + bgColor: "bg-gray-100", + name: "Pending", + }, +}; + +const QueueItemCard = ({ item }: { item: QueueItem }) => { + const { removeItem, retryItem } = useContext(QueueContext) || {}; + const statusInfo = statusStyles[item.status] || statusStyles.queued; + + const isTerminal = item.status === "completed" || item.status === "done"; + const currentCount = isTerminal ? (item.summary?.successful?.length ?? item.totalTracks) : item.currentTrackNumber; + + const progressText = + item.type === "album" || item.type === "playlist" + ? `${currentCount || 0}/${item.totalTracks || "?"}` + : item.progress + ? `${item.progress.toFixed(0)}%` + : ""; + + return ( +
+
+
+
{statusInfo.icon}
+
+
+ {item.type === "track" ? ( + + ) : ( + + )} +

+ {item.name} +

+
+ +

+ {item.artist} +

+
+
+
+
+

{statusInfo.name}

+ {progressText &&

{progressText}

} +
+ + {item.canRetry && ( + + )} +
+
+ {item.error &&

Error: {item.error}

} + {(item.status === "downloading" || item.status === "processing") && item.progress !== undefined && ( +
+
+
+ )} +
+ ); +}; + +export const Queue = () => { + const context = useContext(QueueContext); + + if (!context) return null; + const { items, isVisible, toggleVisibility, clearQueue } = context; if (!isVisible) return null; - const handleClearQueue = () => { - if (confirm("Are you sure you want to cancel all downloads and clear the queue?")) { - clearQueue(); - } - }; - - const renderProgress = (item: QueueItem) => { - if (item.status === "downloading" || item.status === "processing") { - const isMultiTrack = item.totalTracks && item.totalTracks > 1; - const overallProgress = - isMultiTrack && item.totalTracks - ? ((item.currentTrackNumber || 0) / item.totalTracks) * 100 - : item.progress || 0; - - return ( -
-
- {isMultiTrack && ( -
-
-
- )} -
- ); - } - return null; - }; - - const renderStatusDetails = (item: QueueItem) => { - const statusClass = { - initializing: "text-gray-400", - pending: "text-gray-400", - downloading: "text-blue-400", - processing: "text-purple-400", - completed: "text-green-500 font-semibold", - error: "text-red-500 font-semibold", - skipped: "text-yellow-500", - cancelled: "text-gray-500", - queued: "text-gray-400", - }[item.status]; - - const isMultiTrack = item.totalTracks && item.totalTracks > 1; - - return ( -
- {item.status.toUpperCase()} - {item.status === "downloading" && ( - <> - {item.progress?.toFixed(0)}% - {item.speed} - {item.eta} - - )} - {isMultiTrack && ( - - {item.currentTrackNumber}/{item.totalTracks} - - )} -
- ); - }; - - const renderSummary = (item: QueueItem) => { - if (item.status !== "completed" || !item.summary) return null; - - return ( -
- - Success: {item.summary.successful} - {" "} - |{" "} - - Skipped: {item.summary.skipped} - {" "} - |{" "} - - Failed: {item.summary.failed} - -
- ); - }; - return ( - +
+ ); -} +}; diff --git a/spotizerr-ui/src/components/SearchResultCard.tsx b/spotizerr-ui/src/components/SearchResultCard.tsx index ac15171..45d8a30 100644 --- a/spotizerr-ui/src/components/SearchResultCard.tsx +++ b/spotizerr-ui/src/components/SearchResultCard.tsx @@ -24,13 +24,13 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa }; return ( -
+
{name} {onDownload && (
- + {name} {subtitle &&

{subtitle}

} diff --git a/spotizerr-ui/src/components/config/AccountsTab.tsx b/spotizerr-ui/src/components/config/AccountsTab.tsx index 682a2a0..69545c0 100644 --- a/spotizerr-ui/src/components/config/AccountsTab.tsx +++ b/spotizerr-ui/src/components/config/AccountsTab.tsx @@ -94,7 +94,7 @@ export function AccountsTab() { {errors.accountName &&

{errors.accountName.message}

}
@@ -104,7 +104,7 @@ export function AccountsTab() { {errors.authBlob &&

{errors.authBlob.message}

} @@ -116,7 +116,7 @@ export function AccountsTab() { {errors.arl &&

{errors.arl.message}

}
@@ -127,7 +127,7 @@ export function AccountsTab() { id="accountRegion" {...register("accountRegion")} placeholder="e.g. US, GB" - className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" + className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
@@ -171,7 +171,10 @@ export function AccountsTab() { ) : (
{credentials?.map((cred) => ( -
+
{cred.name} +
{ const { artistId } = useParams({ from: "/artist/$artistId" }); @@ -25,36 +27,27 @@ export const Artist = () => { const fetchArtistData = async () => { if (!artistId) return; try { - // Since the backend doesn't provide a single endpoint, we make multiple calls - const artistPromise = apiClient.get(`/artist/info?id=${artistId}`); - const topTracksPromise = apiClient.get<{ tracks: TrackType[] }>(`/artist/${artistId}/top-tracks`); - const albumsPromise = apiClient.get<{ items: AlbumType[] }>(`/artist/${artistId}/albums`); - const watchStatusPromise = apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`); + const response = await apiClient.get<{ items: AlbumType[] }>(`/artist/info?id=${artistId}`); + const albumData = response.data; - const [artistRes, topTracksRes, albumsRes, watchStatusRes] = await Promise.allSettled([ - artistPromise, - topTracksPromise, - albumsPromise, - watchStatusPromise, - ]); - - if (artistRes.status === "fulfilled") { - setArtist(artistRes.value.data); + if (albumData?.items && albumData.items.length > 0) { + const firstAlbum = albumData.items[0]; + if (firstAlbum.artists && firstAlbum.artists.length > 0) { + setArtist(firstAlbum.artists[0]); + } else { + setError("Could not determine artist from album data."); + return; + } + setAlbums(albumData.items); } else { - throw new Error("Failed to load artist details"); + setError("No albums found for this artist."); + return; } - if (topTracksRes.status === "fulfilled") { - setTopTracks(topTracksRes.value.data.tracks); - } + setTopTracks([]); - if (albumsRes.status === "fulfilled") { - setAlbums(albumsRes.value.data.items); - } - - if (watchStatusRes.status === "fulfilled") { - setIsWatched(watchStatusRes.value.data.is_watched); - } + const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`); + setIsWatched(watchStatusResponse.data.is_watched); } catch (err) { setError("Failed to load artist page"); console.error(err); @@ -70,6 +63,11 @@ export const Artist = () => { addItem({ spotifyId: track.id, type: "track", name: track.name }); }; + const handleDownloadAlbum = (album: AlbumType) => { + toast.info(`Adding ${album.name} to queue...`); + addItem({ spotifyId: album.id, type: "album", name: album.name }); + }; + const handleDownloadArtist = () => { if (!artistId || !artist) return; toast.info(`Adding ${artist.name} to queue...`); @@ -105,51 +103,122 @@ export const Artist = () => { return
Loading...
; } - const filteredAlbums = albums.filter((album) => { - if (settings?.explicitFilter) { - return !album.name.toLowerCase().includes("remix"); - } - return true; - }); + if (!artist.name) { + 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")); return (
-
- {artist.name} -

{artist.name}

-
- +
+
+ {artist.images && artist.images.length > 0 && ( + {artist.name} + )} +

{artist.name}

+
+ -
-

Top Tracks

-
- {topTracks.map((track) => ( -
- - {track.name} - - + {topTracks.length > 0 && ( +
+

Top Tracks

+
+ {topTracks.map((track) => ( +
+ + {track.name} + + +
+ ))}
- ))} -
+
+ )} -

Albums

-
- {filteredAlbums.map((album) => ( -
- - {album.name} -

{album.name}

- + {artistAlbums.length > 0 && ( +
+

Albums

+
+ {artistAlbums.map((album) => ( + handleDownloadAlbum(album)} /> + ))}
- ))} -
+
+ )} + + {artistSingles.length > 0 && ( +
+

Singles

+
+ {artistSingles.map((album) => ( + handleDownloadAlbum(album)} /> + ))} +
+
+ )} + + {artistCompilations.length > 0 && ( +
+

Compilations

+
+ {artistCompilations.map((album) => ( + handleDownloadAlbum(album)} /> + ))} +
+
+ )}
); }; diff --git a/spotizerr-ui/src/routes/home.tsx b/spotizerr-ui/src/routes/home.tsx index b4c2e6a..af7fd62 100644 --- a/spotizerr-ui/src/routes/home.tsx +++ b/spotizerr-ui/src/routes/home.tsx @@ -1,63 +1,42 @@ import { useState, useEffect, useMemo, useContext, useCallback, useRef } from "react"; +import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router"; import { useDebounce } from "use-debounce"; -import apiClient from "@/lib/api-client"; import { toast } from "sonner"; -import type { TrackType, AlbumType, ArtistType, PlaylistType } from "@/types/spotify"; +import type { TrackType, AlbumType, ArtistType, PlaylistType, SearchResult } from "@/types/spotify"; import { QueueContext } from "@/contexts/queue-context"; import { SearchResultCard } from "@/components/SearchResultCard"; +import { indexRoute } from "@/router"; const PAGE_SIZE = 12; -type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & { - model: "track" | "album" | "artist" | "playlist"; -}; - export const Home = () => { - const [query, setQuery] = useState(""); - const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">("track"); - const [allResults, setAllResults] = useState([]); - const [displayedResults, setDisplayedResults] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); + const navigate = useNavigate({ from: "/" }); + const { q, type } = useSearch({ from: "/" }); + const { items: allResults } = indexRoute.useLoaderData(); + const isLoading = useRouterState({ select: (s) => s.status === "pending" }); + + const [query, setQuery] = useState(q || ""); + const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track"); const [debouncedQuery] = useDebounce(query, 500); + + const [displayedResults, setDisplayedResults] = useState([]); + const [isLoadingMore, setIsLoadingMore] = useState(false); const context = useContext(QueueContext); const loaderRef = useRef(null); + useEffect(() => { + navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) }); + }, [debouncedQuery, searchType, navigate]); + + useEffect(() => { + setDisplayedResults(allResults.slice(0, PAGE_SIZE)); + }, [allResults]); + if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } const { addItem } = context; - useEffect(() => { - if (debouncedQuery.length < 3) { - setAllResults([]); - setDisplayedResults([]); - return; - } - - const performSearch = async () => { - setIsLoading(true); - try { - const response = await apiClient.get<{ - items: SearchResult[]; - }>(`/search?q=${debouncedQuery}&search_type=${searchType}&limit=50`); - - const augmentedResults = response.data.items.map((item) => ({ - ...item, - model: searchType, - })); - setAllResults(augmentedResults); - setDisplayedResults(augmentedResults.slice(0, PAGE_SIZE)); - } catch { - toast.error("Search failed. Please try again."); - } finally { - setIsLoading(false); - } - }; - - performSearch(); - }, [debouncedQuery, searchType]); - const loadMore = useCallback(() => { setIsLoadingMore(true); setTimeout(() => { @@ -93,7 +72,8 @@ export const Home = () => { const handleDownloadTrack = useCallback( (track: TrackType) => { - addItem({ spotifyId: track.id, type: "track", name: track.name }); + const artistName = track.artists?.map((a) => a.name).join(", "); + addItem({ spotifyId: track.id, type: "track", name: track.name, artist: artistName }); toast.info(`Adding ${track.name} to queue...`); }, [addItem], @@ -101,7 +81,8 @@ export const Home = () => { const handleDownloadAlbum = useCallback( (album: AlbumType) => { - addItem({ spotifyId: album.id, type: "album", name: album.name }); + const artistName = album.artists?.map((a) => a.name).join(", "); + addItem({ spotifyId: album.id, type: "album", name: album.name, artist: artistName }); toast.info(`Adding ${album.name} to queue...`); }, [addItem], diff --git a/spotizerr-ui/src/routes/playlist.tsx b/spotizerr-ui/src/routes/playlist.tsx index 336d8c3..4444233 100644 --- a/spotizerr-ui/src/routes/playlist.tsx +++ b/spotizerr-ui/src/routes/playlist.tsx @@ -3,22 +3,10 @@ import { useEffect, useState, useContext } from "react"; import apiClient from "../lib/api-client"; import { useSettings } from "../contexts/settings-context"; import { toast } from "sonner"; -import type { ImageType, TrackType } from "../types/spotify"; +import type { PlaylistType, TrackType } from "../types/spotify"; import { QueueContext } from "../contexts/queue-context"; - -interface PlaylistItemType { - track: TrackType | null; -} - -interface PlaylistType { - id: string; - name: string; - description: string | null; - images: ImageType[]; - tracks: { - items: PlaylistItemType[]; - }; -} +import { FaArrowLeft } from "react-icons/fa"; +import { FaDownload } from "react-icons/fa6"; export const Playlist = () => { const { playlistId } = useParams({ from: "/playlist/$playlistId" }); @@ -95,11 +83,11 @@ export const Playlist = () => { }; if (error) { - return
{error}
; + return
{error}
; } if (!playlist) { - return
Loading...
; + return
Loading...
; } const filteredTracks = playlist.tracks.items.filter(({ track }) => { @@ -109,34 +97,108 @@ export const Playlist = () => { }); return ( -
-
- {playlist.name} -
-

{playlist.name}

-

{playlist.description}

-
- +
+
+ {playlist.name} +
+

{playlist.name}

+ {playlist.description &&

{playlist.description}

} +
+

+ By {playlist.owner.display_name} • {playlist.followers.total.toLocaleString()} followers •{" "} + {playlist.tracks.total} songs +

+
+
+ -
-
- {filteredTracks.map(({ track }) => { - if (!track) return null; - return ( -
- - {track.name} - - -
- ); - })} + +
+

Tracks

+
+ {filteredTracks.map(({ track }, index) => { + if (!track) return null; + return ( +
+
+ {index + 1} + {track.album.name} +
+ + {track.name} + +

+ {track.artists.map((artist, index) => ( + + + {artist.name} + + {index < track.artists.length - 1 && ", "} + + ))} +

+
+
+
+ + {Math.floor(track.duration_ms / 60000)}: + {((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")} + + +
+
+ ); + })} +
); diff --git a/spotizerr-ui/src/routes/track.tsx b/spotizerr-ui/src/routes/track.tsx index 2c96e11..1a11793 100644 --- a/spotizerr-ui/src/routes/track.tsx +++ b/spotizerr-ui/src/routes/track.tsx @@ -1,9 +1,17 @@ -import { useParams } from "@tanstack/react-router"; +import { Link, useParams } from "@tanstack/react-router"; import { useEffect, useState, useContext } from "react"; import apiClient from "../lib/api-client"; import type { TrackType } from "../types/spotify"; import { toast } from "sonner"; import { QueueContext } from "../contexts/queue-context"; +import { FaSpotify, FaArrowLeft } from "react-icons/fa"; + +// Helper to format milliseconds to mm:ss +const formatDuration = (ms: number) => { + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, "0")}`; +}; export const Track = () => { const { trackId } = useParams({ from: "/track/$trackId" }); @@ -37,18 +45,95 @@ export const Track = () => { }; if (error) { - return
{error}
; + return ( +
+

{error}

+
+ ); } if (!track) { - return
Loading...
; + return ( +
+

Loading...

+
+ ); } + const imageUrl = track.album.images?.[0]?.url; + return ( -
-

{track.name}

-

by {track.artists.map((artist) => artist.name).join(", ")}

- +
+
+ +
+
+ {imageUrl && ( +
+ {track.album.name} +
+ )} +
+
+
+

{track.name}

+ {track.explicit && ( + EXPLICIT + )} +
+
+ {track.artists.map((artist, index) => ( + + + {artist.name} + + {index < track.artists.length - 1 && ", "} + + ))} +
+

+ From the album{" "} + + {track.album.name} + +

+
+

Release Date: {track.album.release_date}

+

Duration: {formatDuration(track.duration_ms)}

+
+
+

Popularity:

+
+
+
+
+
+
+ + + + Listen on Spotify + +
+
+
); }; diff --git a/spotizerr-ui/src/routes/watchlist.tsx b/spotizerr-ui/src/routes/watchlist.tsx index e0b0bad..d5819c4 100644 --- a/spotizerr-ui/src/routes/watchlist.tsx +++ b/spotizerr-ui/src/routes/watchlist.tsx @@ -3,28 +3,16 @@ import apiClient from "../lib/api-client"; import { toast } from "sonner"; import { useSettings } from "../contexts/settings-context"; import { Link } from "@tanstack/react-router"; +import type { ArtistType, PlaylistType } from "../types/spotify"; +import { FaRegTrashAlt, FaSearch } from "react-icons/fa"; // --- Type Definitions --- -interface Image { - url: string; -} - -interface WatchedArtist { - itemType: "artist"; +interface BaseWatched { + itemType: "artist" | "playlist"; spotify_id: string; - name: string; - images?: Image[]; - total_albums?: number; -} - -interface WatchedPlaylist { - itemType: "playlist"; - spotify_id: string; - name: string; - images?: Image[]; - owner?: { display_name?: string }; - total_tracks?: number; } +type WatchedArtist = ArtistType & { itemType: "artist" }; +type WatchedPlaylist = PlaylistType & { itemType: "playlist" }; type WatchedItem = WatchedArtist | WatchedPlaylist; @@ -37,12 +25,28 @@ export const Watchlist = () => { setIsLoading(true); try { const [artistsRes, playlistsRes] = await Promise.all([ - apiClient.get[]>("/artist/watch/list"), - apiClient.get[]>("/playlist/watch/list"), + apiClient.get("/artist/watch/list"), + apiClient.get("/playlist/watch/list"), ]); - const artists: WatchedItem[] = artistsRes.data.map((a) => ({ ...a, itemType: "artist" })); - const playlists: WatchedItem[] = playlistsRes.data.map((p) => ({ ...p, itemType: "playlist" })); + const artistDetailsPromises = artistsRes.data.map((artist) => + apiClient.get(`/artist/info?id=${artist.spotify_id}`), + ); + const playlistDetailsPromises = playlistsRes.data.map((playlist) => + apiClient.get(`/playlist/info?id=${playlist.spotify_id}`), + ); + + const [artistDetailsRes, playlistDetailsRes] = await Promise.all([ + Promise.all(artistDetailsPromises), + Promise.all(playlistDetailsPromises), + ]); + + const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" })); + const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({ + ...res.data, + itemType: "playlist", + spotify_id: res.data.id, + })); setItems([...artists, ...playlists]); } catch { @@ -61,10 +65,10 @@ export const Watchlist = () => { }, [settings, settingsLoading, fetchWatchlist]); const handleUnwatch = async (item: WatchedItem) => { - toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), { + toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.id}`), { loading: `Unwatching ${item.name}...`, success: () => { - setItems((prev) => prev.filter((i) => i.spotify_id !== item.spotify_id)); + setItems((prev) => prev.filter((i) => i.id !== item.id)); return `${item.name} has been unwatched.`; }, error: `Failed to unwatch ${item.name}.`, @@ -72,7 +76,7 @@ export const Watchlist = () => { }; const handleCheck = async (item: WatchedItem) => { - toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), { + toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.id}`), { loading: `Checking ${item.name} for updates...`, success: (res: { data: { message?: string } }) => res.data.message || `Check triggered for ${item.name}.`, error: `Failed to trigger check for ${item.name}.`, @@ -119,14 +123,17 @@ export const Watchlist = () => {

Watched Artists & Playlists

-
{items.map((item) => ( -
- + diff --git a/spotizerr-ui/src/types/spotify.ts b/spotizerr-ui/src/types/spotify.ts index 371a310..9cecaf8 100644 --- a/spotizerr-ui/src/types/spotify.ts +++ b/spotizerr-ui/src/types/spotify.ts @@ -7,11 +7,14 @@ export interface ImageType { export interface ArtistType { id: string; name: string; - images: ImageType[]; + images?: ImageType[]; } export interface TrackAlbumInfo { + id: string; + name: string; images: ImageType[]; + release_date: string; } export interface TrackType { @@ -21,11 +24,16 @@ export interface TrackType { duration_ms: number; explicit: boolean; album: TrackAlbumInfo; + popularity: number; + external_urls: { + spotify: string; + }; } export interface AlbumType { id: string; name: string; + album_type: "album" | "single" | "compilation"; artists: ArtistType[]; images: ImageType[]; release_date: string; @@ -39,9 +47,16 @@ export interface AlbumType { } export interface PlaylistItemType { + added_at: string; + is_local: boolean; track: TrackType | null; } +export interface PlaylistOwnerType { + id: string; + display_name: string; +} + export interface PlaylistType { id: string; name: string; @@ -49,8 +64,14 @@ export interface PlaylistType { images: ImageType[]; tracks: { items: PlaylistItemType[]; + total: number; }; - owner: { - display_name: string; + owner: PlaylistOwnerType; + followers: { + total: number; }; } + +export type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & { + model: "track" | "album" | "artist" | "playlist"; +};