This commit is contained in:
188
src/lib/server/spotify.ts
Normal file
188
src/lib/server/spotify.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user