import { env } from '$env/dynamic/private'; import type { SpotifyInput } from '$lib/map-finder/input'; import type { SpotifyTrack, SpotifyCollection } from '$lib/map-finder/types'; import { ApiError, createTokenCache, requestJson } from './finder-http'; export const MAX_PLAYLIST_TRACKS = 50; export const MAX_PLAYLIST_PAGES = 10; const token = createTokenCache(); type RecordValue = Record; function record(value: unknown): RecordValue { return value && typeof value === 'object' ? (value as RecordValue) : {}; } export function usableTrack(value: unknown): SpotifyTrack | null { const track = record(value), album = record(track.album); if ( track.type !== 'track' || track.is_local || track.is_playable === false || typeof track.id !== 'string' || !/^[a-zA-Z0-9]{22}$/.test(track.id) || typeof track.name !== 'string' || !track.name.trim() ) return null; const artists = Array.isArray(track.artists) ? track.artists .map(record) .filter((a) => typeof a.name === 'string' && a.name.trim()) .map((a) => ({ name: a.name as string })) : []; if (!artists.length) return null; const images = Array.isArray(album.images) ? album.images .map(record) .filter((i) => typeof i.url === 'string' && i.url.startsWith('https://')) .map((i) => ({ url: i.url as string })) : []; return { id: track.id, name: track.name, artists, album: { name: typeof album.name === 'string' ? album.name : '', images } }; } export function playlistTracks(items: unknown[], seen = new Set()): SpotifyTrack[] { const result: SpotifyTrack[] = []; for (const entry of items) { const item = record(entry); if (item.is_local) continue; const track = usableTrack(item.track ?? item.item); if (track && !seen.has(track.id)) { seen.add(track.id); result.push(track); } } return result; } async function spotify(fetchFn: typeof fetch, path: string) { const access = await token(() => { if (!env.SPOTIFY_CLIENT_ID || !env.SPOTIFY_CLIENT_SECRET) throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.'); return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', { method: 'POST', headers: { Authorization: `Basic ${btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials' }) }); }); return record( await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, { headers: { Authorization: `Bearer ${access}` } }) ); } export async function getSpotifyMetadata( fetchFn: typeof fetch, input: | Extract | Extract ): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> { if (input.kind === 'track' || input.kind === 'search') { let response: RecordValue; try { response = await spotify( fetchFn, input.kind === 'track' ? `tracks/${input.id}` : `search?${new URLSearchParams({ q: input.query, type: 'track', limit: '1' })}` ); } catch (error) { if (error instanceof ApiError && error.status === 404) throw new ApiError('Spotify', 404, 'Spotify track not found.'); throw error; } const track = usableTrack( input.kind === 'track' ? response : (record(response.tracks).items as unknown[] | undefined)?.[0] ); if (!track) throw new ApiError( 'Spotify', 404, input.kind === 'track' ? 'Spotify track is unavailable.' : 'No Spotify text-search result. Try an artist and song title.' ); return { tracks: [track], truncated: false }; } try { const isAlbum = input.kind === 'album'; const resource = isAlbum ? 'albums' : 'playlists'; const metadata = await spotify(fetchFn, `${resource}/${input.id}`); if (typeof metadata.name !== 'string') throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist metadata.'); const owner = record(metadata.owner), images = metadata.images; const collection: SpotifyCollection = { id: input.id, kind: input.kind, name: metadata.name, owner: isAlbum && Array.isArray(metadata.artists) ? metadata.artists .map(record) .map((a) => (typeof a.name === 'string' ? a.name : '')) .filter(Boolean) .join(', ') : typeof owner.display_name === 'string' ? owner.display_name : '', image: Array.isArray(images) && typeof record(images[0]).url === 'string' ? (record(images[0]).url as string) : undefined }; // Extended-quota responses use tracks/track; newer development-mode responses use items/item. const modern = metadata.items !== undefined; let page = record(modern ? metadata.items : metadata.tracks); let offset = 0; const tracks: SpotifyTrack[] = []; const seen = new Set(); for (let pages = 0; pages < MAX_PLAYLIST_PAGES; pages++) { if (!Array.isArray(page.items)) page = await spotify( fetchFn, `${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}` ); if (!Array.isArray(page.items)) throw new ApiError( 'Spotify', 502, 'Spotify playlist tracks are unavailable for this application.' ); const usable = playlistTracks( isAlbum ? page.items.map((item) => ({ track: { ...record(item), album: { name: metadata.name, images: metadata.images } } })) : page.items, seen ); const room = MAX_PLAYLIST_TRACKS - tracks.length; tracks.push(...usable.slice(0, room)); offset += page.items.length; const more = Boolean(page.next) || (typeof page.total === 'number' && offset < page.total); if (tracks.length >= MAX_PLAYLIST_TRACKS || !more || pages === MAX_PLAYLIST_PAGES - 1) return { tracks, collection, truncated: usable.length > room || more }; if (!page.items.length) throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist tracks.'); // Construct pagination locally: never forward credentials to an upstream-supplied URL. page = {}; } return { tracks, collection, truncated: true }; } catch (error) { if (error instanceof ApiError && [401, 403, 404].includes(error.status)) throw new ApiError( 'Spotify', error.status, input.kind === 'album' ? 'Spotify album unavailable or not found. Try an individual track.' : 'Spotify playlist unavailable or private, or this application does not have playlist access. Try a public playlist or an individual track.' ); throw error; } }