/** * React Query Hooks for API Caching * * This file provides custom React Query hooks for the most frequently called APIs * in the music streaming app. These hooks implement smart caching strategies to: * - Reduce unnecessary API calls * - Improve perceived performance * - Provide automatic background refetching * - Handle loading and error states consistently * * Stale time configuration rationale: * - Artist data: 10 minutes (rarely changes) * - Album data: 10 minutes (rarely changes) * - Library/Home data: 2 minutes (may change as user adds music) * - Search results: 5 minutes (relatively static for same query) * - Playlists: 1 minute (user may be actively modifying) */ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; // ============================================================================ // QUERY KEY FACTORIES // ============================================================================ // These functions generate consistent query keys for caching // See: https://tanstack.com/query/latest/docs/react/guides/query-keys export const queryKeys = { // Artist queries artist: (id: string) => ["artist", id] as const, artistLibrary: (id: string) => ["artist", "library", id] as const, artistDiscovery: (id: string) => ["artist", "discovery", id] as const, // Album queries album: (id: string) => ["album", id] as const, albumLibrary: (id: string) => ["album", "library", id] as const, albumDiscovery: (id: string) => ["album", "discovery", id] as const, albums: (filters?: Record) => ["albums", filters] as const, // Library queries library: () => ["library"] as const, recentlyListened: (limit?: number) => ["library", "recently-listened", limit] as const, recentlyAdded: (limit?: number) => ["library", "recently-added", limit] as const, // Recommendations recommendations: (limit?: number) => ["recommendations", limit] as const, similarArtists: (seedArtistId: string, limit?: number) => ["recommendations", "artists", seedArtistId, limit] as const, similarAlbums: (seedAlbumId: string, limit?: number) => ["recommendations", "albums", seedAlbumId, limit] as const, // Search search: (query: string, type?: string, limit?: number) => ["search", query, type, limit] as const, discoverSearch: (query: string, type?: string, limit?: number) => ["search", "discover", query, type, limit] as const, // Playlists playlists: () => ["playlists"] as const, playlist: (id: string) => ["playlist", id] as const, // Mixes mixes: () => ["mixes"] as const, mix: (id: string) => ["mix", id] as const, // Popular artists popularArtists: (limit?: number) => ["popular-artists", limit] as const, // Audiobooks audiobooks: () => ["audiobooks"] as const, audiobook: (id: string) => ["audiobook", id] as const, // Podcasts podcasts: () => ["podcasts"] as const, podcast: (id: string) => ["podcast", id] as const, topPodcasts: (limit?: number, genreId?: number) => ["podcasts", "top", limit, genreId] as const, // Browse (Deezer playlists/radios) browseAll: () => ["browse", "all"] as const, browseFeatured: (limit?: number) => ["browse", "featured", limit] as const, browseRadios: (limit?: number) => ["browse", "radios", limit] as const, }; // ============================================================================ // ARTIST QUERIES // ============================================================================ /** * Hook to fetch artist data with automatic library/discovery fallback * * Tries library first, falls back to discovery if not found. * Cache time: 10 minutes (artist data rarely changes) * * @param id - Artist ID or MusicBrainz ID * @returns Query result with artist data * * @example * const { data: artist, isLoading, error } = useArtistQuery("artist-123"); */ export function useArtistQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.artist(id || ""), queryFn: async () => { if (!id) throw new Error("Artist ID is required"); // Try library first try { return await api.getArtist(id); } catch (error) { // Fallback to discovery return await api.getArtistDiscovery(id); } }, enabled: !!id, staleTime: 10 * 60 * 1000, // 10 minutes retry: 1, }); } /** * Hook to fetch artist data from library only * * @param id - Artist ID * @returns Query result with artist data from library */ export function useArtistLibraryQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.artistLibrary(id || ""), queryFn: async () => { if (!id) throw new Error("Artist ID is required"); return await api.getArtist(id); }, enabled: !!id, staleTime: 10 * 60 * 1000, // 10 minutes }); } /** * Hook to fetch artist data from discovery only * * @param id - Artist name or MusicBrainz ID * @returns Query result with artist data from Last.fm */ export function useArtistDiscoveryQuery(nameOrMbid: string | undefined) { return useQuery({ queryKey: queryKeys.artistDiscovery(nameOrMbid || ""), queryFn: async () => { if (!nameOrMbid) throw new Error("Artist name or MBID is required"); return await api.getArtistDiscovery(nameOrMbid); }, enabled: !!nameOrMbid, staleTime: 10 * 60 * 1000, // 10 minutes }); } // ============================================================================ // ALBUM QUERIES // ============================================================================ /** * Hook to fetch album data with automatic library/discovery fallback * * Cache time: 10 minutes (album data rarely changes) * * @param id - Album ID or Release Group MBID * @returns Query result with album data * * @example * const { data: album, isLoading, error } = useAlbumQuery("album-123"); */ export function useAlbumQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.album(id || ""), queryFn: async () => { if (!id) throw new Error("Album ID is required"); // Try library first try { return await api.getAlbum(id); } catch (error) { // Fallback to discovery return await api.getAlbumDiscovery(id); } }, enabled: !!id, staleTime: 10 * 60 * 1000, // 10 minutes retry: 1, }); } /** * Hook to fetch album data from library only * * @param id - Album ID * @returns Query result with album data from library */ export function useAlbumLibraryQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.albumLibrary(id || ""), queryFn: async () => { if (!id) throw new Error("Album ID is required"); return await api.getAlbum(id); }, enabled: !!id, staleTime: 10 * 60 * 1000, // 10 minutes }); } /** * Hook to fetch album data from discovery only * * @param rgMbid - Release Group MusicBrainz ID * @returns Query result with album data from Last.fm */ export function useAlbumDiscoveryQuery(rgMbid: string | undefined) { return useQuery({ queryKey: queryKeys.albumDiscovery(rgMbid || ""), queryFn: async () => { if (!rgMbid) throw new Error("Album MBID is required"); return await api.getAlbumDiscovery(rgMbid); }, enabled: !!rgMbid, staleTime: 10 * 60 * 1000, // 10 minutes }); } /** * Hook to fetch albums list with optional filters * * @param params - Filter parameters (artistId, limit, offset) * @returns Query result with albums array * * @example * const { data } = useAlbumsQuery({ artistId: "123", limit: 20 }); */ export function useAlbumsQuery(params?: { artistId?: string; limit?: number; offset?: number; }) { return useQuery({ queryKey: queryKeys.albums(params), queryFn: () => api.getAlbums(params), staleTime: 5 * 60 * 1000, // 5 minutes }); } // ============================================================================ // LIBRARY QUERIES // ============================================================================ /** * Hook to fetch recently listened items (Continue Listening) * * Cache time: 2 minutes (may change frequently) * * @param limit - Number of items to fetch (default: 10) * @returns Query result with recently listened items * * @example * const { data } = useRecentlyListenedQuery(10); */ export function useRecentlyListenedQuery(limit: number = 10) { return useQuery({ queryKey: queryKeys.recentlyListened(limit), queryFn: () => api.getRecentlyListened(limit), staleTime: 2 * 60 * 1000, // 2 minutes }); } /** * Hook to fetch recently added artists * * Cache time: 2 minutes (may change as user adds music) * * @param limit - Number of items to fetch (default: 10) * @returns Query result with recently added artists * * @example * const { data } = useRecentlyAddedQuery(10); */ export function useRecentlyAddedQuery(limit: number = 10) { return useQuery({ queryKey: queryKeys.recentlyAdded(limit), queryFn: () => api.getRecentlyAdded(limit), staleTime: 2 * 60 * 1000, // 2 minutes }); } // ============================================================================ // RECOMMENDATION QUERIES // ============================================================================ /** * Hook to fetch personalized recommendations * * Cache time: 5 minutes * * @param limit - Number of recommendations (default: 10) * @returns Query result with recommended artists * * @example * const { data } = useRecommendationsQuery(10); */ export function useRecommendationsQuery(limit: number = 10) { return useQuery({ queryKey: queryKeys.recommendations(limit), queryFn: () => api.getRecommendationsForYou(limit), staleTime: 5 * 60 * 1000, // 5 minutes }); } /** * Hook to fetch similar artists based on a seed artist * * @param seedArtistId - Artist ID to find similar artists for * @param limit - Number of recommendations (default: 20) * @returns Query result with similar artists * * @example * const { data } = useSimilarArtistsQuery("artist-123", 20); */ export function useSimilarArtistsQuery(seedArtistId: string | undefined, limit: number = 20) { return useQuery({ queryKey: queryKeys.similarArtists(seedArtistId || "", limit), queryFn: async () => { if (!seedArtistId) throw new Error("Seed artist ID is required"); return await api.getSimilarArtists(seedArtistId, limit); }, enabled: !!seedArtistId, staleTime: 10 * 60 * 1000, // 10 minutes }); } /** * Hook to fetch similar albums based on a seed album * * @param seedAlbumId - Album ID to find similar albums for * @param limit - Number of recommendations (default: 20) * @returns Query result with similar albums */ export function useSimilarAlbumsQuery(seedAlbumId: string | undefined, limit: number = 20) { return useQuery({ queryKey: queryKeys.similarAlbums(seedAlbumId || "", limit), queryFn: async () => { if (!seedAlbumId) throw new Error("Seed album ID is required"); return await api.getSimilarAlbums(seedAlbumId, limit); }, enabled: !!seedAlbumId, staleTime: 10 * 60 * 1000, // 10 minutes }); } // ============================================================================ // SEARCH QUERIES // ============================================================================ /** * Hook to search library with debouncing * * Cache time: 5 minutes (search results are relatively static) * * @param query - Search query string * @param type - Type filter (all, artists, albums, tracks, audiobooks, podcasts) * @param limit - Number of results per type (default: 20) * @returns Query result with search results * * @example * const { data } = useSearchQuery("radiohead", "all", 20); */ export function useSearchQuery( query: string, type: "all" | "artists" | "albums" | "tracks" | "audiobooks" | "podcasts" = "all", limit: number = 20 ) { return useQuery({ queryKey: queryKeys.search(query, type, limit), queryFn: () => api.search(query, type, limit), enabled: query.length >= 2, // Only search if query is at least 2 characters staleTime: 5 * 60 * 1000, // 5 minutes }); } /** * Hook to search discovery/Last.fm with debouncing * * @param query - Search query string * @param type - Type filter (music, podcasts, all) * @param limit - Number of results (default: 20) * @returns Query result with discovery search results * * @example * const { data } = useDiscoverSearchQuery("radiohead", "music", 20); */ export function useDiscoverSearchQuery( query: string, type: "music" | "podcasts" | "all" = "music", limit: number = 20 ) { return useQuery({ queryKey: queryKeys.discoverSearch(query, type, limit), queryFn: () => api.discoverSearch(query, type, limit), enabled: query.length >= 2, staleTime: 5 * 60 * 1000, // 5 minutes }); } // ============================================================================ // PLAYLIST QUERIES // ============================================================================ /** * Hook to fetch all playlists * * Cache time: 1 minute (playlists may be actively modified) * * @returns Query result with playlists array * * @example * const { data: playlists } = usePlaylistsQuery(); */ export function usePlaylistsQuery() { return useQuery({ queryKey: queryKeys.playlists(), queryFn: () => api.getPlaylists(), staleTime: 1 * 60 * 1000, // 1 minute }); } /** * Hook to fetch a single playlist * * @param id - Playlist ID * @returns Query result with playlist data * * @example * const { data: playlist } = usePlaylistQuery("playlist-123"); */ export function usePlaylistQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.playlist(id || ""), queryFn: async () => { if (!id) throw new Error("Playlist ID is required"); return await api.getPlaylist(id); }, enabled: !!id, staleTime: 1 * 60 * 1000, // 1 minute }); } // ============================================================================ // MIX QUERIES // ============================================================================ /** * Hook to fetch all mixes (Made For You) * * Cache time: 5 minutes * * @returns Query result with mixes array * * @example * const { data: mixes } = useMixesQuery(); */ export function useMixesQuery() { return useQuery({ queryKey: queryKeys.mixes(), queryFn: () => api.getMixes(), staleTime: 5 * 60 * 1000, // 5 minutes }); } /** * Hook to fetch a single mix * * @param id - Mix ID * @returns Query result with mix data */ export function useMixQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.mix(id || ""), queryFn: async () => { if (!id) throw new Error("Mix ID is required"); return await api.getMix(id); }, enabled: !!id, staleTime: 5 * 60 * 1000, // 5 minutes }); } // ============================================================================ // POPULAR ARTISTS QUERY // ============================================================================ /** * Hook to fetch popular artists from Last.fm * * Cache time: 10 minutes (popular charts don't change frequently) * * @param limit - Number of artists to fetch (default: 20) * @returns Query result with popular artists * * @example * const { data } = usePopularArtistsQuery(20); */ export function usePopularArtistsQuery(limit: number = 20) { return useQuery({ queryKey: queryKeys.popularArtists(limit), queryFn: () => api.getPopularArtists(limit), staleTime: 10 * 60 * 1000, // 10 minutes }); } // ============================================================================ // AUDIOBOOK QUERIES // ============================================================================ /** * Hook to fetch all audiobooks * * @returns Query result with audiobooks array */ export function useAudiobooksQuery() { return useQuery({ queryKey: queryKeys.audiobooks(), queryFn: () => api.getAudiobooks(), staleTime: 5 * 60 * 1000, // 5 minutes }); } /** * Hook to fetch a single audiobook * * @param id - Audiobook ID * @returns Query result with audiobook data */ export function useAudiobookQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.audiobook(id || ""), queryFn: async () => { if (!id) throw new Error("Audiobook ID is required"); return await api.getAudiobook(id); }, enabled: !!id, staleTime: 5 * 60 * 1000, // 5 minutes }); } // ============================================================================ // PODCAST QUERIES // ============================================================================ /** * Hook to fetch all subscribed podcasts * * @returns Query result with podcasts array */ export function usePodcastsQuery() { return useQuery({ queryKey: queryKeys.podcasts(), queryFn: () => api.getPodcasts(), staleTime: 5 * 60 * 1000, // 5 minutes }); } /** * Hook to fetch a single podcast * * Returns null if podcast is not found (404), allowing the page to handle preview mode. * * @param id - Podcast ID * @returns Query result with podcast data */ export function usePodcastQuery(id: string | undefined) { return useQuery({ queryKey: queryKeys.podcast(id || ""), queryFn: async () => { if (!id) throw new Error("Podcast ID is required"); try { return await api.getPodcast(id); } catch (error: any) { // If podcast not found (404), return null to allow preview mode if (error?.status === 404 || error?.message?.includes('not found') || error?.message?.includes('not subscribed')) { return null; } // For other errors, throw to trigger error state throw error; } }, enabled: !!id, staleTime: 5 * 60 * 1000, // 5 minutes retry: false, // Don't retry 404 errors }); } /** * Hook to fetch top podcasts * * @param limit - Number of podcasts (default: 20) * @param genreId - Optional genre ID filter * @returns Query result with top podcasts */ export function useTopPodcastsQuery(limit: number = 20, genreId?: number) { return useQuery({ queryKey: queryKeys.topPodcasts(limit, genreId), queryFn: () => api.getTopPodcasts(limit, genreId), staleTime: 10 * 60 * 1000, // 10 minutes }); } // ============================================================================ // MUTATION HOOKS // ============================================================================ // These hooks handle data modifications and automatically invalidate related queries /** * Hook to refresh mixes with cache invalidation * * @returns Mutation object with mutate function * * @example * const { mutate: refreshMixes, isPending } = useRefreshMixesMutation(); * refreshMixes(); */ export function useRefreshMixesMutation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => api.refreshMixes(), onSuccess: () => { // Invalidate mixes query to refetch queryClient.invalidateQueries({ queryKey: queryKeys.mixes() }); }, }); } /** * Hook to add track to playlist with cache invalidation * * @returns Mutation object with mutate function * * @example * const { mutate: addToPlaylist } = useAddToPlaylistMutation(); * addToPlaylist({ playlistId: "123", trackId: "456" }); */ export function useAddToPlaylistMutation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ playlistId, trackId }: { playlistId: string; trackId: string }) => api.addTrackToPlaylist(playlistId, trackId), onSuccess: (_, variables) => { // Invalidate the specific playlist query queryClient.invalidateQueries({ queryKey: queryKeys.playlist(variables.playlistId) }); // Also invalidate the playlists list queryClient.invalidateQueries({ queryKey: queryKeys.playlists() }); }, }); } /** * Hook to create a new playlist with cache invalidation * * @returns Mutation object with mutate function * * @example * const { mutate: createPlaylist } = useCreatePlaylistMutation(); * createPlaylist({ name: "My Playlist", isPublic: false }); */ export function useCreatePlaylistMutation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ name, isPublic }: { name: string; isPublic?: boolean }) => api.createPlaylist(name, isPublic), onSuccess: () => { // Invalidate playlists list to show new playlist queryClient.invalidateQueries({ queryKey: queryKeys.playlists() }); }, }); } /** * Hook to delete a playlist with cache invalidation * * @returns Mutation object with mutate function * * @example * const { mutate: deletePlaylist } = useDeletePlaylistMutation(); * deletePlaylist("playlist-123"); */ export function useDeletePlaylistMutation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (playlistId: string) => api.deletePlaylist(playlistId), onSuccess: () => { // Invalidate playlists list queryClient.invalidateQueries({ queryKey: queryKeys.playlists() }); }, }); } // ============================================================================ // BROWSE QUERIES (Deezer Playlists/Radios) // ============================================================================ interface PlaylistPreview { id: string; source: string; type: string; title: string; description: string | null; creator: string; imageUrl: string | null; trackCount: number; url: string; } interface Genre { id: number; name: string; picture?: string; } interface GenreWithRadios { id: number; name: string; radios: PlaylistPreview[]; } interface BrowseAllResponse { playlists: PlaylistPreview[]; radios: PlaylistPreview[]; genres: Genre[]; radiosByGenre: GenreWithRadios[]; } /** * Hook to fetch all browse content (playlists, radios, genres) from Deezer * * @returns Query result with all browse content */ export function useBrowseAllQuery() { return useQuery({ queryKey: queryKeys.browseAll(), queryFn: async (): Promise => { return api.get("/browse/all"); }, staleTime: 10 * 60 * 1000, // 10 minutes - playlists don't change often }); } /** * Hook to fetch featured playlists from Deezer * * @param limit - Maximum number of playlists to fetch * @returns Query result with featured playlists */ export function useFeaturedPlaylistsQuery(limit: number = 50) { return useQuery({ queryKey: queryKeys.browseFeatured(limit), queryFn: async (): Promise => { const response = await api.get<{ playlists: PlaylistPreview[] }>( `/browse/playlists/featured?limit=${limit}` ); return response.playlists; }, staleTime: 10 * 60 * 1000, // 10 minutes }); } /** * Hook to fetch radio stations from Deezer * * @param limit - Maximum number of radios to fetch * @returns Query result with radio stations */ export function useRadiosQuery(limit: number = 50) { return useQuery({ queryKey: queryKeys.browseRadios(limit), queryFn: async (): Promise => { const response = await api.get<{ radios: PlaylistPreview[] }>( `/browse/radios?limit=${limit}` ); return response.radios; }, staleTime: 10 * 60 * 1000, // 10 minutes }); }