Some checks failed
Test and deploy staging / staging (push) Has been cancelled
189 lines
6.5 KiB
TypeScript
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;
|
|
}
|
|
}
|