Files
personal-website/src/lib/server/spotify.ts
Stan Runge d0c7b6548b
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
beeeeg update
2026-09-23 04:16:50 +00:00

189 lines
6.5 KiB
TypeScript

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<string, unknown>;
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<string>()): 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<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
| Extract<SpotifyInput, { kind: 'search' }>
): 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<string>();
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;
}
}