import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useRoute } from "@react-navigation/native"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HorizontalScroll } from "@/components/common/HorizontalScroll"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { MusicAlbumCard } from "@/components/music/MusicAlbumCard"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { writeDebugLog } from "@/utils/log"; export default function SuggestionsScreen() { const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>(); const route = useRoute(); const libraryId = (Array.isArray(localParams.libraryId) ? localParams.libraryId[0] : localParams.libraryId) ?? route?.params?.libraryId; const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); const { t } = useTranslation(); const isReady = Boolean(api && user?.Id && libraryId); writeDebugLog("Music suggestions params", { libraryId, localParams, routeParams: route?.params, isReady, }); // Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest // This returns the most recently added albums const { data: latestAlbums, isLoading: loadingLatest, isError: isLatestError, error: latestError, refetch: refetchLatest, } = useQuery({ queryKey: ["music-latest", libraryId, user?.Id], queryFn: async () => { // Prefer the exact endpoint the Web client calls (HAR): // /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=... // IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached. const res = await api!.get( `/Users/${user!.Id}/Items/Latest`, { params: { IncludeItemTypes: "Audio", Limit: 20, Fields: "PrimaryImageAspectRatio", ParentId: libraryId, ImageTypeLimit: 1, EnableImageTypes: "Primary,Backdrop,Banner,Thumb", EnableTotalRecordCount: false, }, }, ); if (Array.isArray(res.data) && res.data.length > 0) { return res.data; } // Fallback: ask for albums directly via /Items (more reliable across server variants) const fallback = await getItemsApi(api!).getItems({ userId: user!.Id, parentId: libraryId, includeItemTypes: ["MusicAlbum"], sortBy: ["DateCreated"], sortOrder: ["Descending"], limit: 20, recursive: true, fields: ["PrimaryImageAspectRatio", "SortName"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableTotalRecordCount: false, }); return fallback.data.Items || []; }, enabled: isReady, }); // Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed const { data: recentlyPlayed, isLoading: loadingRecentlyPlayed, isError: isRecentlyPlayedError, error: recentlyPlayedError, refetch: refetchRecentlyPlayed, } = useQuery({ queryKey: ["music-recently-played", libraryId, user?.Id], queryFn: async () => { const response = await getItemsApi(api!).getItems({ userId: user?.Id, parentId: libraryId, includeItemTypes: ["Audio"], sortBy: ["DatePlayed"], sortOrder: ["Descending"], limit: 10, recursive: true, fields: ["PrimaryImageAspectRatio", "SortName"], filters: ["IsPlayed"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableTotalRecordCount: false, }); return response.data.Items || []; }, enabled: isReady, }); // Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed const { data: frequentlyPlayed, isLoading: loadingFrequent, isError: isFrequentError, error: frequentError, refetch: refetchFrequent, } = useQuery({ queryKey: ["music-frequently-played", libraryId, user?.Id], queryFn: async () => { const response = await getItemsApi(api!).getItems({ userId: user?.Id, parentId: libraryId, includeItemTypes: ["Audio"], sortBy: ["PlayCount"], sortOrder: ["Descending"], limit: 10, recursive: true, fields: ["PrimaryImageAspectRatio", "SortName"], filters: ["IsPlayed"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableTotalRecordCount: false, }); return response.data.Items || []; }, enabled: isReady, }); const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent; const handleRefresh = useCallback(() => { refetchLatest(); refetchRecentlyPlayed(); refetchFrequent(); }, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]); const sections = useMemo(() => { const result: { title: string; data: BaseItemDto[]; type: "albums" | "tracks"; }[] = []; // Latest albums section if (latestAlbums && latestAlbums.length > 0) { result.push({ title: t("music.recently_added"), data: latestAlbums, type: "albums", }); } // Recently played tracks if (recentlyPlayed && recentlyPlayed.length > 0) { result.push({ title: t("music.recently_played"), data: recentlyPlayed, type: "tracks", }); } // Frequently played tracks if (frequentlyPlayed && frequentlyPlayed.length > 0) { result.push({ title: t("music.frequently_played"), data: frequentlyPlayed, type: "tracks", }); } return result; }, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]); if (!api || !user?.Id) { return ( ); } if (!libraryId) { return ( Missing music library id. ); } if (isLoading) { return ( ); } if (isLatestError || isRecentlyPlayedError || isFrequentError) { const msg = (latestError as Error | undefined)?.message || (recentlyPlayedError as Error | undefined)?.message || (frequentError as Error | undefined)?.message || "Unknown error"; return ( Failed to load music: {msg} ); } if (sections.length === 0) { return ( {t("music.no_suggestions")} ); } return ( } renderItem={({ item: section }) => ( {section.title} {section.type === "albums" ? ( item.Id!} renderItem={(item) => } /> ) : ( {section.data.slice(0, 5).map((track, index, _tracks) => ( ))} )} )} keyExtractor={(item) => item.title} /> ); }