map finder: quick lists from my listening (top/recent/now/Spotify top/liked), Artist - Title without Spotify, owned playlists via the user token, rich beatmap cards (cover, mapper, status, BPM, length, star chips, preview, osu!direct), mode/status/star filters, DB result cache (music.osu_match_cache)
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -1,26 +1,147 @@
|
||||
<script module lang="ts">
|
||||
// One preview at a time across all cards.
|
||||
let playing: HTMLAudioElement | null = null;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ScoredBeatmapset } from './types';
|
||||
import Artwork from './Artwork.svelte';
|
||||
let { map }: { map: ScoredBeatmapset } = $props();
|
||||
import { formatLength, MODE_LABEL, starColor, starRange } from './filters';
|
||||
import Play from 'lucide-svelte/icons/play';
|
||||
import Pause from 'lucide-svelte/icons/pause';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Download from 'lucide-svelte/icons/download';
|
||||
let { map, total }: { map: ScoredBeatmapset; total?: number } = $props();
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
let isPlaying = $state(false);
|
||||
const diffs = $derived(map.beatmaps ?? []);
|
||||
const range = $derived(starRange(map));
|
||||
const length = $derived(diffs.length ? Math.max(...diffs.map((d) => d.length)) : 0);
|
||||
const bpm = $derived(map.bpm ?? diffs.find((d) => d.bpm)?.bpm);
|
||||
const modes = $derived([...new Set(diffs.map((d) => d.mode))]);
|
||||
const statusClass = $derived(
|
||||
map.status === 'ranked' || map.status === 'approved'
|
||||
? 'bg-sky-600 text-white'
|
||||
: map.status === 'loved'
|
||||
? 'bg-pink-600 text-white'
|
||||
: map.status === 'qualified'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-100'
|
||||
);
|
||||
const compact = (n?: number) =>
|
||||
n === undefined ? '' : Intl.NumberFormat('en', { notation: 'compact' }).format(n);
|
||||
function toggle() {
|
||||
if (!map.preview_url) return;
|
||||
if (isPlaying && audio) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
playing?.pause();
|
||||
audio ??= new Audio(map.preview_url);
|
||||
audio.volume = 0.5;
|
||||
audio.onpause = audio.onended = () => (isPlaying = false);
|
||||
audio.onplay = () => (isPlaying = true);
|
||||
playing = audio;
|
||||
void audio.play().catch(() => (isPlaying = false));
|
||||
}
|
||||
$effect(() => () => audio?.pause());
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block h-full overflow-hidden rounded border border-border hover:border-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
<article
|
||||
class="flex h-full flex-col overflow-hidden rounded border border-border bg-background text-sm"
|
||||
>
|
||||
<Artwork src={map.covers?.card} class="h-24 rounded-none" />
|
||||
<div class="space-y-1 p-3">
|
||||
<h4 class="font-semibold">
|
||||
{map.artist_unicode || map.artist} – {map.title_unicode || map.title}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">{map.score}/100 · {map.confidence} confidence</p>
|
||||
<p class="text-xs text-muted-foreground">{map.reasons.join(' · ')}</p>
|
||||
{#if map.status || map.beatmaps}<p class="text-xs text-muted-foreground">
|
||||
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps
|
||||
? `${map.beatmaps.length} difficulties`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
<div class="relative">
|
||||
<Artwork src={map.covers?.['card@2x'] ?? map.covers?.card} class="h-24 rounded-none" />
|
||||
{#if map.status}<span
|
||||
class={`absolute top-2 left-2 rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase ${statusClass}`}
|
||||
>{map.status}</span
|
||||
>{/if}
|
||||
{#if map.preview_url}<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
class="absolute right-2 bottom-2 flex h-8 w-8 items-center justify-center rounded-full bg-black/70 text-white hover:bg-black/90 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label={isPlaying ? 'Pause preview' : 'Play preview'}
|
||||
>{#if isPlaying}<Pause size={14} />{:else}<Play size={14} />{/if}</button
|
||||
>{/if}
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<h4 class="leading-tight font-semibold">
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:underline">{map.title_unicode || map.title}</a
|
||||
>
|
||||
</h4>
|
||||
<p class="text-muted-foreground">
|
||||
{map.artist_unicode || map.artist}{#if map.creator}
|
||||
· mapped by
|
||||
<a
|
||||
class="hover:underline"
|
||||
href={`https://osu.ppy.sh/users/${encodeURIComponent(map.creator)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{map.creator}</a
|
||||
>{/if}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{[
|
||||
bpm ? `${Math.round(bpm)} BPM` : '',
|
||||
length ? formatLength(length) : '',
|
||||
range
|
||||
? range[0] === range[1]
|
||||
? `${range[0].toFixed(2)}★`
|
||||
: `${range[0].toFixed(1)}–${range[1].toFixed(1)}★`
|
||||
: '',
|
||||
modes.length ? modes.map((m) => MODE_LABEL[m]).join('/') : '',
|
||||
map.play_count !== undefined ? `${compact(map.play_count)} plays` : '',
|
||||
map.favourite_count !== undefined ? `♥ ${compact(map.favourite_count)}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{#if diffs.length}
|
||||
<ul class="flex flex-wrap gap-1" aria-label="Difficulties">
|
||||
{#each diffs.slice(0, 10) as diff (diff.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}#${diff.mode}/${diff.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${diff.version} (${MODE_LABEL[diff.mode]}, ${diff.stars.toFixed(2)}★)`}
|
||||
class="inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold text-black"
|
||||
style={`background-color: ${starColor(diff.stars)}`}>{diff.stars.toFixed(1)}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
{#if diffs.length > 10}<li class="text-xs text-muted-foreground">
|
||||
+{diffs.length - 10}
|
||||
</li>{/if}
|
||||
</ul>
|
||||
{#if total !== undefined && total > diffs.length}<p class="text-xs text-muted-foreground">
|
||||
{diffs.length} of {total} difficulties match the filters
|
||||
</p>{/if}
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground" title={map.reasons.join(' · ')}>
|
||||
match {map.score}/100 · {map.confidence}
|
||||
</p>
|
||||
<div class="mt-auto flex flex-wrap gap-3 pt-1 text-xs">
|
||||
<a
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"><ExternalLink size={12} /> osu! page</a
|
||||
>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
href={`osu://s/${map.id}`}
|
||||
title="osu!direct (supporters): open in the game">osu!direct</a
|
||||
>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"><Download size={12} /> .osz</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
68
src/lib/map-finder/filters.test.ts
Normal file
68
src/lib/map-finder/filters.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_FILTERS, filterMatches, formatLength, starRange } from './filters';
|
||||
import { parseArtistTitle } from './input';
|
||||
import type { ScoredBeatmapset } from './types';
|
||||
|
||||
const set = (id: number, status: string, diffs: [string, number][]): ScoredBeatmapset => ({
|
||||
id,
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
status,
|
||||
score: 90,
|
||||
confidence: 'high',
|
||||
reasons: [],
|
||||
beatmaps: diffs.map(([mode, stars], i) => ({
|
||||
id: id * 10 + i,
|
||||
version: `d${i}`,
|
||||
mode: mode as 'osu',
|
||||
stars,
|
||||
length: 120
|
||||
}))
|
||||
});
|
||||
const maps = [
|
||||
set(1, 'ranked', [
|
||||
['osu', 2.1],
|
||||
['osu', 5.4],
|
||||
['mania', 3]
|
||||
]),
|
||||
set(2, 'graveyard', [['osu', 6.2]]),
|
||||
set(3, 'loved', [['taiko', 4]])
|
||||
];
|
||||
|
||||
describe('filterMatches', () => {
|
||||
it('passes everything with default filters', () => {
|
||||
expect(filterMatches(maps, DEFAULT_FILTERS)).toBe(maps);
|
||||
});
|
||||
it('filters by status, mode and star range and narrows difficulties', () => {
|
||||
const out = filterMatches(maps, {
|
||||
mode: 'osu',
|
||||
status: 'leaderboard',
|
||||
minStars: 4,
|
||||
maxStars: 6
|
||||
});
|
||||
expect(out.map((m) => m.id)).toEqual([1]);
|
||||
expect(out[0].beatmaps?.map((d) => d.stars)).toEqual([5.4]);
|
||||
expect(filterMatches(maps, { ...DEFAULT_FILTERS, status: 'loved' }).map((m) => m.id)).toEqual([
|
||||
3
|
||||
]);
|
||||
expect(filterMatches(maps, { ...DEFAULT_FILTERS, minStars: 6 }).map((m) => m.id)).toEqual([2]);
|
||||
});
|
||||
it('formats helpers', () => {
|
||||
expect(starRange(maps[0])).toEqual([2.1, 5.4]);
|
||||
expect(formatLength(125)).toBe('2:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArtistTitle', () => {
|
||||
it('splits Artist - Title and Title by Artist', () => {
|
||||
expect(parseArtistTitle('Camellia - Ghost')).toMatchObject({
|
||||
name: 'Ghost',
|
||||
artists: [{ name: 'Camellia' }]
|
||||
});
|
||||
expect(parseArtistTitle('Ghost by Camellia')).toMatchObject({
|
||||
name: 'Ghost',
|
||||
artists: [{ name: 'Camellia' }]
|
||||
});
|
||||
expect(parseArtistTitle('just a title')).toBeNull();
|
||||
});
|
||||
});
|
||||
102
src/lib/map-finder/filters.ts
Normal file
102
src/lib/map-finder/filters.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { OsuMode, ScoredBeatmapset } from './types';
|
||||
|
||||
// Client-side filters for map finder results: the server returns every candidate with all
|
||||
// difficulties, so changing a filter never needs another osu! search.
|
||||
export type StatusFilter = 'any' | 'ranked' | 'loved' | 'leaderboard';
|
||||
export type Filters = {
|
||||
mode: OsuMode | 'any';
|
||||
status: StatusFilter;
|
||||
minStars: number | null;
|
||||
maxStars: number | null;
|
||||
};
|
||||
export const DEFAULT_FILTERS: Filters = {
|
||||
mode: 'any',
|
||||
status: 'any',
|
||||
minStars: null,
|
||||
maxStars: null
|
||||
};
|
||||
|
||||
export const MODES: { key: Filters['mode']; label: string }[] = [
|
||||
{ key: 'any', label: 'any mode' },
|
||||
{ key: 'osu', label: 'osu!' },
|
||||
{ key: 'taiko', label: 'taiko' },
|
||||
{ key: 'fruits', label: 'catch' },
|
||||
{ key: 'mania', label: 'mania' }
|
||||
];
|
||||
export const STATUSES: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'any', label: 'any status' },
|
||||
{ key: 'leaderboard', label: 'has leaderboard' },
|
||||
{ key: 'ranked', label: 'ranked only' },
|
||||
{ key: 'loved', label: 'loved only' }
|
||||
];
|
||||
|
||||
const STATUS_SETS: Record<Exclude<StatusFilter, 'any'>, string[]> = {
|
||||
ranked: ['ranked', 'approved'],
|
||||
loved: ['loved'],
|
||||
leaderboard: ['ranked', 'approved', 'loved', 'qualified']
|
||||
};
|
||||
|
||||
export function isDefault(filters: Filters) {
|
||||
return (
|
||||
filters.mode === 'any' &&
|
||||
filters.status === 'any' &&
|
||||
filters.minStars === null &&
|
||||
filters.maxStars === null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the sets that have at least one difficulty passing the filters, with their
|
||||
* difficulties narrowed to the passing ones. Sets without difficulty data only pass when no
|
||||
* difficulty filter is active.
|
||||
*/
|
||||
export function filterMatches(matches: ScoredBeatmapset[], filters: Filters): ScoredBeatmapset[] {
|
||||
if (isDefault(filters)) return matches;
|
||||
const statuses = filters.status === 'any' ? null : STATUS_SETS[filters.status];
|
||||
const result: ScoredBeatmapset[] = [];
|
||||
for (const map of matches) {
|
||||
if (statuses && !statuses.includes(map.status ?? '')) continue;
|
||||
const diffFilter =
|
||||
filters.mode !== 'any' || filters.minStars !== null || filters.maxStars !== null;
|
||||
if (!diffFilter) {
|
||||
result.push(map);
|
||||
continue;
|
||||
}
|
||||
const beatmaps = (map.beatmaps ?? []).filter(
|
||||
(d) =>
|
||||
(filters.mode === 'any' || d.mode === filters.mode) &&
|
||||
(filters.minStars === null || d.stars >= filters.minStars) &&
|
||||
(filters.maxStars === null || d.stars <= filters.maxStars)
|
||||
);
|
||||
if (beatmaps.length) result.push({ ...map, beatmaps });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function starRange(map: ScoredBeatmapset): [number, number] | null {
|
||||
const stars = (map.beatmaps ?? []).map((d) => d.stars);
|
||||
return stars.length ? [Math.min(...stars), Math.max(...stars)] : null;
|
||||
}
|
||||
|
||||
export function formatLength(seconds: number) {
|
||||
const s = Math.round(seconds);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** osu!-site difficulty colours, coarsened to the familiar bands. */
|
||||
export function starColor(stars: number) {
|
||||
if (stars < 2) return '#4fc0ff';
|
||||
if (stars < 2.7) return '#4fffd5';
|
||||
if (stars < 4) return '#7cff4f';
|
||||
if (stars < 5.3) return '#f6f05c';
|
||||
if (stars < 6.5) return '#ff4e6f';
|
||||
if (stars < 8) return '#c645b8';
|
||||
return '#6563de';
|
||||
}
|
||||
|
||||
export const MODE_LABEL: Record<OsuMode, string> = {
|
||||
osu: 'osu!',
|
||||
taiko: 'taiko',
|
||||
fruits: 'catch',
|
||||
mania: 'mania'
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SpotifyTrack } from './types';
|
||||
export type SpotifyInput =
|
||||
| { kind: 'track'; id: string }
|
||||
| { kind: 'playlist'; id: string }
|
||||
@@ -42,3 +43,26 @@ export function parseSpotifyInput(value: string): SpotifyInput {
|
||||
);
|
||||
return { kind: resource, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Artist - Title" (or "Title by Artist") typed by hand, so a search works without Spotify.
|
||||
* Returns null when the text has no clear artist/title split.
|
||||
*/
|
||||
export function parseArtistTitle(value: string): SpotifyTrack | null {
|
||||
const text = value.trim().replace(/\s+/g, ' ');
|
||||
let artist: string | undefined, title: string | undefined;
|
||||
const dash = /^(.+?)\s+[-–—]\s+(.+)$/.exec(text);
|
||||
if (dash) [, artist, title] = dash;
|
||||
else {
|
||||
const by = /^(.+?)\s+by\s+(.+)$/i.exec(text);
|
||||
if (by) [, title, artist] = by;
|
||||
}
|
||||
if (!artist?.trim() || !title?.trim()) return null;
|
||||
return {
|
||||
id: `text:${artist.toLowerCase()}|${title.toLowerCase()}`,
|
||||
name: title.trim(),
|
||||
artists: [{ name: artist.trim() }],
|
||||
album: { name: '', images: [] },
|
||||
url: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,14 +74,31 @@ export function scoreBeatmapset(track: SpotifyTrack, map: OsuBeatmapset): Scored
|
||||
reasons
|
||||
};
|
||||
}
|
||||
const statusRank = (status?: string) =>
|
||||
status === 'ranked' || status === 'approved'
|
||||
? 3
|
||||
: status === 'loved'
|
||||
? 2
|
||||
: status === 'qualified'
|
||||
? 1
|
||||
: 0;
|
||||
export function rankCandidates(track: SpotifyTrack, maps: OsuBeatmapset[]): ScoredBeatmapset[] {
|
||||
const merged = new Map<number, ScoredBeatmapset>();
|
||||
for (const map of maps) {
|
||||
const scored = scoreBeatmapset(track, map);
|
||||
if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored);
|
||||
}
|
||||
return [...merged.values()]
|
||||
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
|
||||
.sort((a, b) => b.score - a.score || a.id - b.id)
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
return (
|
||||
[...merged.values()]
|
||||
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
|
||||
// Equal scores (many maps of one song): prefer maps with a leaderboard, then popular ones.
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
statusRank(b.status) - statusRank(a.status) ||
|
||||
(b.play_count ?? 0) - (a.play_count ?? 0) ||
|
||||
a.id - b.id
|
||||
)
|
||||
.slice(0, MAX_CANDIDATES)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
export type SpotifyTrack = {
|
||||
/** Spotify track id, or `db:<id>` for a track from the listening history without one. */
|
||||
id: string;
|
||||
name: string;
|
||||
artists: { name: string }[];
|
||||
album: { name: string; images: { url: string }[] };
|
||||
/** Link to the track on Spotify, when known. */
|
||||
url?: string | null;
|
||||
/** Extra context shown with the track, e.g. "12 plays" or "listening now". */
|
||||
note?: string;
|
||||
};
|
||||
export type SpotifyCollection = {
|
||||
kind: 'playlist' | 'album';
|
||||
kind: 'playlist' | 'album' | 'library';
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
};
|
||||
export type OsuMode = 'osu' | 'taiko' | 'fruits' | 'mania';
|
||||
export type OsuDifficulty = {
|
||||
id: number;
|
||||
version: string;
|
||||
mode: OsuMode;
|
||||
stars: number;
|
||||
/** Drain-inclusive length in seconds. */
|
||||
length: number;
|
||||
bpm?: number;
|
||||
status?: string;
|
||||
};
|
||||
export type OsuBeatmapset = {
|
||||
id: number;
|
||||
@@ -17,9 +34,17 @@ export type OsuBeatmapset = {
|
||||
title: string;
|
||||
artist_unicode?: string;
|
||||
title_unicode?: string;
|
||||
covers?: { card?: string; 'card@2x'?: string };
|
||||
covers?: { card?: string; 'card@2x'?: string; list?: string };
|
||||
status?: string;
|
||||
beatmaps?: { id: number }[];
|
||||
creator?: string;
|
||||
bpm?: number;
|
||||
play_count?: number;
|
||||
favourite_count?: number;
|
||||
preview_url?: string;
|
||||
ranked_date?: string | null;
|
||||
video?: boolean;
|
||||
nsfw?: boolean;
|
||||
beatmaps?: OsuDifficulty[];
|
||||
};
|
||||
export type ScoredBeatmapset = OsuBeatmapset & {
|
||||
score: number;
|
||||
|
||||
@@ -15,7 +15,10 @@ export const SPOTIFY_SCOPES = [
|
||||
'user-read-currently-playing',
|
||||
'user-read-recently-played',
|
||||
'user-read-playback-state',
|
||||
'user-top-read'
|
||||
'user-top-read',
|
||||
// Map finder: liked songs and Stan's own playlists.
|
||||
'user-library-read',
|
||||
'playlist-read-private'
|
||||
] as const;
|
||||
|
||||
export class SpotifyError extends Error {
|
||||
|
||||
@@ -172,6 +172,13 @@ export const musicSpotifyAuth = musicSchema.table(
|
||||
(t) => [check('spotify_auth_single_row', sql`${t.id} = 1`)]
|
||||
);
|
||||
|
||||
/** Map finder (/projects/spotify-beatmap-finder): osu! search results per normalised track. */
|
||||
export const musicOsuMatchCache = musicSchema.table('osu_match_cache', {
|
||||
key: text('key').primaryKey(),
|
||||
result: jsonb('result').notNull(),
|
||||
searchedAt: timestamptz('searched_at').notNull().defaultNow()
|
||||
});
|
||||
|
||||
/** Resumable backfill state, e.g. key 'lastfm:stanrunge' (see scripts/music-import-lastfm.ts). */
|
||||
export const musicImportState = musicSchema.table('import_state', {
|
||||
key: text('key').primaryKey(),
|
||||
|
||||
28
src/lib/server/map-finder/cache.ts
Normal file
28
src/lib/server/map-finder/cache.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import type { MatchStore } from '#lib/server/osu.js';
|
||||
import type { ScoredBeatmapset } from '#lib/map-finder/types.js';
|
||||
|
||||
// osu! search results per normalised track, kept for a few days so repeat lookups (your
|
||||
// top tracks, a playlist you check again) are instant and cost no osu! API calls.
|
||||
const TTL_DAYS = 3;
|
||||
|
||||
export const matchStore: MatchStore = {
|
||||
async get(key) {
|
||||
const [row] = await sql`
|
||||
select result from music.osu_match_cache
|
||||
where key = ${key} and searched_at > now() - make_interval(days => ${TTL_DAYS})`;
|
||||
if (!row) return null;
|
||||
return (
|
||||
typeof row.result === 'string' ? JSON.parse(row.result) : row.result
|
||||
) as ScoredBeatmapset[];
|
||||
},
|
||||
async set(key, result) {
|
||||
await sql`
|
||||
insert into music.osu_match_cache (key, result, searched_at)
|
||||
values (${key}, ${JSON.stringify(result)}::jsonb, now())
|
||||
on conflict (key) do update set result = excluded.result, searched_at = now()`;
|
||||
// Keep the table small: drop entries well past their TTL now and then.
|
||||
if (Math.random() < 0.02)
|
||||
await sql`delete from music.osu_match_cache where searched_at < now() - interval '30 days'`;
|
||||
}
|
||||
};
|
||||
146
src/lib/server/map-finder/sources.ts
Normal file
146
src/lib/server/map-finder/sources.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import type { SpotifyCollection, SpotifyTrack } from '#lib/map-finder/types.js';
|
||||
import { usableTrack } from '#lib/server/spotify.js';
|
||||
import { ApiError } from '#lib/server/finder-http.js';
|
||||
import { spotifyUserGet } from '#lib/server/music/user-token.js';
|
||||
import { SpotifyError } from '#lib/music/collector.server.js';
|
||||
|
||||
// "Find maps for what I listen to": track lists from the listening tracker (music.* in the
|
||||
// database, filled by the Spotify poller and the Last.fm import) and from Stan's Spotify
|
||||
// account. History sources need no Spotify API call at all, only osu!.
|
||||
export const SOURCES = {
|
||||
top: { label: 'top tracks · 30 days', cost: 6, owner: false },
|
||||
recent: { label: 'recently played', cost: 6, owner: false },
|
||||
now: { label: 'now playing', cost: 1, owner: false },
|
||||
'spotify-top': { label: 'all-time top (Spotify)', cost: 6, owner: false },
|
||||
liked: { label: 'liked songs', cost: 6, owner: true }
|
||||
} as const;
|
||||
export type Source = keyof typeof SOURCES;
|
||||
export const isSource = (value: unknown): value is Source =>
|
||||
typeof value === 'string' && Object.hasOwn(SOURCES, value);
|
||||
export const SOURCE_TRACKS = 30;
|
||||
|
||||
type Row = {
|
||||
id: number;
|
||||
spotify_id: string | null;
|
||||
name: string;
|
||||
artists: string[] | null;
|
||||
album: string | null;
|
||||
image: string | null;
|
||||
note: string | null;
|
||||
};
|
||||
const artistsOf = () => sql`coalesce((select array_agg(ar.name order by ta.position)
|
||||
from music.track_artists ta join music.artists ar on ar.id = ta.artist_id
|
||||
where ta.track_id = t.id), array_remove(array[a.name], null))`;
|
||||
|
||||
function fromRow(row: Row): SpotifyTrack | null {
|
||||
const artists = (row.artists ?? []).filter(Boolean).map((name) => ({ name }));
|
||||
if (!artists.length || !row.name) return null;
|
||||
return {
|
||||
id: row.spotify_id ?? `db:${row.id}`,
|
||||
name: row.name,
|
||||
artists,
|
||||
album: { name: row.album ?? '', images: row.image ? [{ url: row.image }] : [] },
|
||||
url: row.spotify_id ? `https://open.spotify.com/track/${row.spotify_id}` : null,
|
||||
note: row.note ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function history(source: 'top' | 'recent' | 'now'): Promise<SpotifyTrack[]> {
|
||||
let rows: Row[];
|
||||
if (source === 'top')
|
||||
rows = await sql<Row[]>`
|
||||
select t.id, t.spotify_id, t.name, ${artistsOf()} as artists, al.name as album,
|
||||
coalesce(al.image_url_small, al.image_url) as image,
|
||||
count(*)::text || case when count(*) = 1 then ' play' else ' plays' end as note
|
||||
from music.plays_deduped p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
where p.played_at >= now() - interval '30 days'
|
||||
group by t.id, a.name, al.id
|
||||
order by count(*) desc, max(p.played_at) desc limit ${SOURCE_TRACKS}`;
|
||||
else if (source === 'recent')
|
||||
rows = await sql<Row[]>`
|
||||
select t.id, t.spotify_id, t.name, ${artistsOf()} as artists, al.name as album,
|
||||
coalesce(al.image_url_small, al.image_url) as image, null as note
|
||||
from (select track_id, max(played_at) as at from music.plays_deduped
|
||||
group by track_id order by at desc limit ${SOURCE_TRACKS}) p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
order by p.at desc`;
|
||||
else
|
||||
rows = await sql<Row[]>`
|
||||
select * from (
|
||||
select t.id, t.spotify_id, t.name, ${artistsOf()} as artists, al.name as album,
|
||||
coalesce(al.image_url_small, al.image_url) as image, 'listening now' as note, 0 as rank
|
||||
from music.now_playing n
|
||||
join music.tracks t on t.id = n.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
where n.is_playing and n.updated_at > now() - interval '3 minutes'
|
||||
union all
|
||||
(select t.id, t.spotify_id, t.name, ${artistsOf()}, al.name,
|
||||
coalesce(al.image_url_small, al.image_url), 'last played', 1
|
||||
from music.plays_deduped p
|
||||
join music.tracks t on t.id = p.track_id
|
||||
left join music.artists a on a.id = t.artist_id
|
||||
left join music.albums al on al.id = t.album_id
|
||||
order by p.played_at desc limit 1)
|
||||
) x order by rank limit 1`;
|
||||
return rows.map(fromRow).filter((t): t is SpotifyTrack => t !== null);
|
||||
}
|
||||
|
||||
async function spotifyUser(source: 'spotify-top' | 'liked'): Promise<SpotifyTrack[]> {
|
||||
let response: Record<string, unknown> | null;
|
||||
try {
|
||||
response = await spotifyUserGet(
|
||||
source === 'liked'
|
||||
? `me/tracks?limit=${SOURCE_TRACKS}`
|
||||
: `me/top/tracks?time_range=long_term&limit=${SOURCE_TRACKS}`
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof SpotifyError && (e.status === 401 || e.status === 403))
|
||||
throw new ApiError(
|
||||
'Spotify',
|
||||
403,
|
||||
source === 'liked'
|
||||
? 'Liked songs need Spotify reconnected with library access (/api/spotify/connect).'
|
||||
: 'Spotify refused the request. Reconnect Spotify at /api/spotify/connect.'
|
||||
);
|
||||
throw new ApiError('Spotify', 502, 'Spotify request failed. Please try again later.');
|
||||
}
|
||||
if (!response)
|
||||
throw new ApiError(
|
||||
'Spotify',
|
||||
503,
|
||||
'Spotify is not connected yet, so this list is unavailable.'
|
||||
);
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const seen = new Set<string>();
|
||||
const tracks: SpotifyTrack[] = [];
|
||||
for (const item of items) {
|
||||
const track = usableTrack(
|
||||
source === 'liked' ? (item as Record<string, unknown> | null)?.track : item
|
||||
);
|
||||
if (track && !seen.has(track.id)) {
|
||||
seen.add(track.id);
|
||||
tracks.push({ ...track, url: `https://open.spotify.com/track/${track.id}` });
|
||||
}
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
export async function getSourceTracks(
|
||||
source: Source
|
||||
): Promise<{ tracks: SpotifyTrack[]; collection: SpotifyCollection }> {
|
||||
const tracks =
|
||||
source === 'spotify-top' || source === 'liked'
|
||||
? await spotifyUser(source)
|
||||
: await history(source);
|
||||
return {
|
||||
tracks,
|
||||
collection: { kind: 'library', id: source, name: `Stan's ${SOURCES[source].label}`, owner: '' }
|
||||
};
|
||||
}
|
||||
45
src/lib/server/music/user-token.ts
Normal file
45
src/lib/server/music/user-token.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import { refreshAccessToken, spotifyGet } from '#lib/music/collector.server.js';
|
||||
import { spotifyCredentials } from './poller';
|
||||
|
||||
// Spotify calls on Stan's behalf (his own playlists, liked songs, top tracks), using the
|
||||
// authorization stored by /api/spotify/connect. Shares the access token with the poller.
|
||||
let pending: Promise<string | null> | null = null;
|
||||
|
||||
async function fresh(): Promise<string | null> {
|
||||
const creds = spotifyCredentials();
|
||||
if (!creds.clientId || !creds.clientSecret) return null;
|
||||
const [auth] = await sql`
|
||||
select refresh_token, access_token, access_expires_at, needs_reauth
|
||||
from music.spotify_auth where id = 1`.catch(() => []);
|
||||
if (!auth || auth.needs_reauth) return null;
|
||||
const expires = auth.access_expires_at ? new Date(auth.access_expires_at).getTime() : 0;
|
||||
if (auth.access_token && expires > Date.now() + 60_000) return auth.access_token as string;
|
||||
const token = await refreshAccessToken(fetch, creds, auth.refresh_token);
|
||||
await sql`
|
||||
update music.spotify_auth set access_token = ${token.access_token},
|
||||
access_expires_at = now() + make_interval(secs => ${token.expires_in}),
|
||||
refresh_token = coalesce(${token.refresh_token ?? null}, refresh_token),
|
||||
scope = coalesce(${token.scope ?? null}, scope), updated_at = now()
|
||||
where id = 1`;
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
/** A user access token, or null when Spotify is not connected (or needs reconnecting). */
|
||||
export function spotifyUserToken(): Promise<string | null> {
|
||||
pending ??= fresh().finally(() => (pending = null));
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** GET an API path with the user token; null when not connected. */
|
||||
export async function spotifyUserGet(path: string): Promise<Record<string, unknown> | null> {
|
||||
const token = await spotifyUserToken();
|
||||
if (!token) return null;
|
||||
return ((await spotifyGet(fetch, token, path)) ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Scopes granted at connect time (to tell the owner to reconnect for new features). */
|
||||
export async function grantedScopes(): Promise<string[]> {
|
||||
const [row] = await sql`select scope from music.spotify_auth where id = 1`.catch(() => []);
|
||||
return typeof row?.scope === 'string' ? row.scope.split(' ') : [];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('$app/env/private', () => ({ OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' }));
|
||||
import { getOsuMaps } from './osu';
|
||||
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => {
|
||||
@@ -32,3 +32,31 @@ it('falls back deliberately, merges IDs, reuses OAuth and caches matching result
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
describe('parseBeatmapset', () => {
|
||||
it('keeps the shown fields, fixes preview URLs and sorts difficulties', async () => {
|
||||
const { parseBeatmapset } = await import('./osu');
|
||||
const set = parseBeatmapset({
|
||||
id: 7,
|
||||
title: 'T',
|
||||
artist: 'A',
|
||||
creator: 'mapper',
|
||||
status: 'ranked',
|
||||
preview_url: '//b.ppy.sh/preview/7.mp3',
|
||||
covers: { card: 'https://assets.ppy.sh/c.jpg', list: 'javascript:alert(1)' },
|
||||
beatmaps: [
|
||||
{ id: 2, mode: 'osu', difficulty_rating: 5.123, total_length: 90, version: 'Hard' },
|
||||
{ id: 1, mode: 'osu', difficulty_rating: 2, total_length: 90, version: 'Easy' },
|
||||
{ id: 3, mode: 'bogus' }
|
||||
]
|
||||
});
|
||||
expect(set).toMatchObject({
|
||||
id: 7,
|
||||
creator: 'mapper',
|
||||
preview_url: 'https://b.ppy.sh/preview/7.mp3'
|
||||
});
|
||||
expect(set?.covers?.list).toBeUndefined();
|
||||
expect(set?.beatmaps?.map((d) => d.stars)).toEqual([2, 5.12]);
|
||||
expect(parseBeatmapset({ id: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { OSU_CLIENT_ID, OSU_CLIENT_SECRET } from '$app/env/private';
|
||||
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '#lib/map-finder/types.js';
|
||||
import type {
|
||||
OsuBeatmapset,
|
||||
OsuDifficulty,
|
||||
OsuMode,
|
||||
ScoredBeatmapset,
|
||||
SpotifyTrack
|
||||
} from '#lib/map-finder/types.js';
|
||||
import {
|
||||
cleanTitle,
|
||||
normalize,
|
||||
@@ -11,6 +17,12 @@ const token = createTokenCache();
|
||||
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
|
||||
const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
|
||||
export const MATCH_CONCURRENCY = 3;
|
||||
/** Optional persistent cache (music.osu_match_cache) behind the in-memory one. */
|
||||
export type MatchStore = {
|
||||
get(key: string): Promise<ScoredBeatmapset[] | null>;
|
||||
set(key: string, result: ScoredBeatmapset[]): Promise<void>;
|
||||
};
|
||||
export const osuConfigured = () => !!(OSU_CLIENT_ID && OSU_CLIENT_SECRET);
|
||||
// Global permits also bound outbound work across simultaneous visitors.
|
||||
let active = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
@@ -25,10 +37,72 @@ async function limited<T>(work: () => Promise<T>): Promise<T> {
|
||||
else active--;
|
||||
}
|
||||
}
|
||||
const MODES = new Set<OsuMode>(['osu', 'taiko', 'fruits', 'mania']);
|
||||
const str = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
||||
const num = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
const https = (value: unknown) => {
|
||||
const url = str(value);
|
||||
// The API sends previews protocol-relative (//b.ppy.sh/preview/1.mp3).
|
||||
if (url?.startsWith('//')) return `https:${url}`;
|
||||
return url?.startsWith('https://') ? url : undefined;
|
||||
};
|
||||
function difficulty(value: unknown): OsuDifficulty | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const d = value as Record<string, unknown>;
|
||||
const mode = str(d.mode) as OsuMode | undefined;
|
||||
if (!Number.isInteger(d.id) || !mode || !MODES.has(mode)) return null;
|
||||
return {
|
||||
id: d.id as number,
|
||||
version: str(d.version) ?? '',
|
||||
mode,
|
||||
stars: Math.round((num(d.difficulty_rating) ?? 0) * 100) / 100,
|
||||
length: num(d.total_length) ?? 0,
|
||||
bpm: num(d.bpm),
|
||||
status: str(d.status)
|
||||
};
|
||||
}
|
||||
/** Keep only the fields the finder shows (the search response is large). */
|
||||
export function parseBeatmapset(item: unknown): OsuBeatmapset | null {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const b = item as Record<string, unknown>;
|
||||
if (!Number.isInteger(b.id) || typeof b.title !== 'string' || typeof b.artist !== 'string')
|
||||
return null;
|
||||
const covers = (b.covers && typeof b.covers === 'object' ? b.covers : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
id: b.id as number,
|
||||
title: b.title,
|
||||
artist: b.artist,
|
||||
title_unicode: str(b.title_unicode),
|
||||
artist_unicode: str(b.artist_unicode),
|
||||
status: str(b.status),
|
||||
creator: str(b.creator),
|
||||
bpm: num(b.bpm),
|
||||
play_count: num(b.play_count),
|
||||
favourite_count: num(b.favourite_count),
|
||||
preview_url: https(b.preview_url),
|
||||
ranked_date: str(b.ranked_date) ?? null,
|
||||
video: b.video === true,
|
||||
nsfw: b.nsfw === true,
|
||||
covers: {
|
||||
card: https(covers.card),
|
||||
'card@2x': https(covers['card@2x']),
|
||||
list: https(covers.list)
|
||||
},
|
||||
beatmaps: Array.isArray(b.beatmaps)
|
||||
? b.beatmaps
|
||||
.map(difficulty)
|
||||
.filter((d): d is OsuDifficulty => d !== null)
|
||||
.sort((x, y) => x.mode.localeCompare(y.mode) || x.stars - y.stars)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
async function search(fetchFn: Fetch, query: string): Promise<OsuBeatmapset[]> {
|
||||
const access = await token(() => {
|
||||
if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET)
|
||||
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
|
||||
if (!osuConfigured()) throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
|
||||
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
@@ -49,44 +123,45 @@ async function search(fetchFn: Fetch, query: string): Promise<OsuBeatmapset[]> {
|
||||
if (!Array.isArray(response?.beatmapsets))
|
||||
throw new ApiError('osu!', 502, 'osu! returned an invalid search response.');
|
||||
return response.beatmapsets
|
||||
.filter(
|
||||
(item): item is OsuBeatmapset =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
Number.isInteger((item as OsuBeatmapset).id) &&
|
||||
typeof (item as OsuBeatmapset).title === 'string' &&
|
||||
typeof (item as OsuBeatmapset).artist === 'string'
|
||||
)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
title_unicode: typeof item.title_unicode === 'string' ? item.title_unicode : undefined,
|
||||
artist_unicode: typeof item.artist_unicode === 'string' ? item.artist_unicode : undefined,
|
||||
status: typeof item.status === 'string' ? item.status : undefined,
|
||||
covers: { card: typeof item.covers?.card === 'string' ? item.covers.card : undefined },
|
||||
beatmaps: Array.isArray(item.beatmaps) ? item.beatmaps.map(() => ({ id: 0 })) : undefined
|
||||
}));
|
||||
.map(parseBeatmapset)
|
||||
.filter((item): item is OsuBeatmapset => item !== null);
|
||||
}
|
||||
export async function getOsuMaps(fetchFn: Fetch, track: SpotifyTrack): Promise<ScoredBeatmapset[]> {
|
||||
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
|
||||
const cacheError = (error: unknown) => {
|
||||
console.warn('osu! match cache unavailable:', (error as Error)?.message);
|
||||
return null;
|
||||
};
|
||||
export const matchCacheKey = (track: SpotifyTrack) =>
|
||||
JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
|
||||
export async function getOsuMaps(
|
||||
fetchFn: Fetch,
|
||||
track: SpotifyTrack,
|
||||
store?: MatchStore
|
||||
): Promise<ScoredBeatmapset[]> {
|
||||
const key = matchCacheKey(track);
|
||||
for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit.result;
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing;
|
||||
const work = limited(async () => {
|
||||
const maps = await search(fetchFn, `${track.artists[0].name} ${track.name}`);
|
||||
let ranked = rankCandidates(track, maps);
|
||||
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
|
||||
ranked = rankCandidates(track, [
|
||||
...maps,
|
||||
...(await search(fetchFn, cleanTitle(track.name) || track.name))
|
||||
]);
|
||||
const remember = (result: ScoredBeatmapset[]) => {
|
||||
if (cache.size >= 500) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, { expires: Date.now() + 5 * 60_000, result: ranked });
|
||||
return ranked;
|
||||
}).finally(() => pending.delete(key));
|
||||
cache.set(key, { expires: Date.now() + 5 * 60_000, result });
|
||||
return result;
|
||||
};
|
||||
const work = (async () => {
|
||||
const stored = await store?.get(key).catch(cacheError);
|
||||
if (stored) return remember(stored);
|
||||
const ranked = await limited(async () => {
|
||||
const title = cleanTitle(track.name) || track.name;
|
||||
const maps = await search(fetchFn, `${track.artists[0].name} ${title}`);
|
||||
let ranked = rankCandidates(track, maps);
|
||||
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
|
||||
ranked = rankCandidates(track, [...maps, ...(await search(fetchFn, title))]);
|
||||
return ranked;
|
||||
});
|
||||
await store?.set(key, ranked).catch(cacheError);
|
||||
return remember(ranked);
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ export function usableTrack(value: unknown): SpotifyTrack | null {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
artists,
|
||||
album: { name: typeof album.name === 'string' ? album.name : '', images }
|
||||
album: { name: typeof album.name === 'string' ? album.name : '', images },
|
||||
url: `https://open.spotify.com/track/${track.id}`
|
||||
};
|
||||
}
|
||||
export function playlistTracks(items: unknown[], seen = new Set<string>()): SpotifyTrack[] {
|
||||
@@ -55,19 +56,31 @@ export function playlistTracks(items: unknown[], seen = new Set<string>()): Spot
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function spotify(fetchFn: Fetch, path: string) {
|
||||
const access = await token(() => {
|
||||
if (!SPOTIFY_CLIENT_ID || !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(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
||||
});
|
||||
});
|
||||
// Development-mode Spotify apps (2026) only return playlist contents to a user token, and
|
||||
// only for playlists that user owns or collaborates on: use Stan's connected account.
|
||||
async function userToken(): Promise<string | null> {
|
||||
try {
|
||||
const { spotifyUserToken } = await import('./music/user-token');
|
||||
return await spotifyUserToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function spotify(fetchFn: Fetch, path: string, user: string | null = null) {
|
||||
const access =
|
||||
user ??
|
||||
(await token(() => {
|
||||
if (!SPOTIFY_CLIENT_ID || !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(`${SPOTIFY_CLIENT_ID}:${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}` }
|
||||
@@ -78,7 +91,8 @@ export async function getSpotifyMetadata(
|
||||
fetchFn: Fetch,
|
||||
input:
|
||||
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
|
||||
| Extract<SpotifyInput, { kind: 'search' }>
|
||||
| Extract<SpotifyInput, { kind: 'search' }>,
|
||||
options: { owner?: boolean } = {}
|
||||
): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> {
|
||||
if (input.kind === 'track' || input.kind === 'search') {
|
||||
let response: RecordValue;
|
||||
@@ -137,7 +151,14 @@ export async function getSpotifyMetadata(
|
||||
: undefined
|
||||
};
|
||||
// Extended-quota responses use tracks/track; newer development-mode responses use items/item.
|
||||
const modern = metadata.items !== undefined;
|
||||
let user: string | null = null;
|
||||
if (!isAlbum && metadata.items === undefined && metadata.tracks === undefined) {
|
||||
if (metadata.public === false && !options.owner)
|
||||
throw new ApiError('Spotify', 403, 'Private playlist.');
|
||||
user = await userToken();
|
||||
if (!user) throw new ApiError('Spotify', 403, 'Playlist contents need a user token.');
|
||||
}
|
||||
const modern = metadata.items !== undefined || user !== null;
|
||||
let page = record(modern ? metadata.items : metadata.tracks);
|
||||
let offset = 0;
|
||||
const tracks: SpotifyTrack[] = [];
|
||||
@@ -146,7 +167,8 @@ export async function getSpotifyMetadata(
|
||||
if (!Array.isArray(page.items))
|
||||
page = await spotify(
|
||||
fetchFn,
|
||||
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`
|
||||
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`,
|
||||
user
|
||||
);
|
||||
if (!Array.isArray(page.items))
|
||||
throw new ApiError(
|
||||
@@ -181,7 +203,7 @@ export async function getSpotifyMetadata(
|
||||
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.'
|
||||
: "Spotify only lets this site read playlists that Stan owns (Spotify's 2026 API limits for small apps). Try a track or album link, or one of the quick lists below."
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user