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

This commit is contained in:
2026-10-24 16:15:56 +00:00
parent 0aa14cebf3
commit 46703f1a6c
21 changed files with 21050 additions and 178 deletions

View File

@@ -19,7 +19,7 @@ schema in the `personal` database:
URIs `https://stanrunge.dev/api/spotify/callback` and
`https://dev.stanrunge.dev/api/spotify/callback`.
2. Logged in as the owner, open <https://stanrunge.dev/api/spotify/connect> once. Scopes:
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read`.
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read user-library-read playlist-read-private`.
3. That's it: the **app itself** polls every minute (`src/lib/server/music/poller.ts`, started
from `hooks.server.ts` `init`) with its own role and Spotify credentials. Status (last poll,
last error) is shown to the owner on `/trackers/music`.

View File

@@ -0,0 +1,7 @@
CREATE TABLE "music"."osu_match_cache" (
"key" text PRIMARY KEY,
"result" jsonb NOT NULL,
"searched_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN IF current_user = 'personal' THEN GRANT SELECT, INSERT, UPDATE, DELETE ON music.osu_match_cache TO personal_app; END IF; END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View 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();
});
});

View 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'
};

View File

@@ -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
};
}

View File

@@ -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)
);
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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(),

View 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'`;
}
};

View 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: '' }
};
}

View 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(' ') : [];
}

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,37 +1,64 @@
import type { Actions } from './$types';
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { parseSpotifyInput } from '#lib/map-finder/input.js';
import type { TrackMatchResult } from '#lib/map-finder/types.js';
import { parseSpotifyInput, parseArtistTitle } from '#lib/map-finder/input.js';
import type { SpotifyCollection, SpotifyTrack, TrackMatchResult } from '#lib/map-finder/types.js';
import { getSpotifyMetadata } from '#lib/server/spotify.js';
import { getOsuMaps, MATCH_CONCURRENCY } from '#lib/server/osu.js';
import { getOsuMaps, MATCH_CONCURRENCY, osuConfigured, type MatchStore } from '#lib/server/osu.js';
import { ApiError, publicError, logApiError, mapConcurrent } from '#lib/server/finder-http.js';
import { acquireSearch } from '#lib/server/finder-limit.js';
import { isOwner } from '#lib/server/owner.js';
import type { Source } from '#lib/server/map-finder/sources.js';
// Loaded lazily so the action stays testable without a database.
const server = () => import('./finder.server');
export const load: PageServerLoad = async ({ locals }) => {
const owner = isOwner({ locals });
const { availability } = await server();
return { ...(await availability(owner)), owner };
};
type Found = { tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean };
export const actions: Actions = {
default: async ({ request, fetch, getClientAddress }) => {
default: async ({ request, fetch, getClientAddress, locals }) => {
const data = await request.formData();
const value = data.get('query');
const sourceValue = data.get('source');
const query = typeof value === 'string' ? value.trim().slice(0, 501) : '';
let input;
try {
input = parseSpotifyInput(query);
} catch (error) {
return fail(400, { query, error: (error as Error).message });
}
if (input.kind === 'unsupported')
return fail(400, {
query,
error: `Unsupported Spotify resource: ${input.resource}. Use a track, playlist, or album.`
});
const owner = isOwner({ locals });
// Database-backed helpers (quick lists, the osu! result cache); optional for plain searches.
const srv = await server().catch(() => null);
const source: Source | null = srv && srv.isSource(sourceValue) ? sourceValue : null;
if (sourceValue && !source) return fail(400, { query, error: 'Unknown list.' });
let input: ReturnType<typeof parseSpotifyInput> | null = null;
if (!source) {
try {
input = parseSpotifyInput(query);
} catch (error) {
return fail(400, { query, error: (error as Error).message });
}
if (input.kind === 'unsupported')
return fail(400, {
query,
error: `Unsupported Spotify resource: ${input.resource}. Use a track, playlist, or album.`
});
} else if (srv!.SOURCES[source].owner && !owner)
return fail(404, { query, error: 'Not found.' });
if (!osuConfigured())
return fail(503, { query, error: 'osu! is not configured on this server.' });
let address: string;
try {
address = getClientAddress();
} catch {
address = 'shared-unknown';
}
const release = acquireSearch(
address,
input.kind === 'playlist' || input.kind === 'album' ? 6 : 1
);
const cost = source
? srv!.SOURCES[source].cost
: input!.kind === 'playlist' || input!.kind === 'album'
? 6
: 1;
const release = acquireSearch(address, cost);
if (!release)
return fail(429, {
query,
@@ -39,13 +66,30 @@ export const actions: Actions = {
'Site search limit reached. Wait up to 10 minutes, and finish any pending search before trying again.'
});
try {
const metadata = await getSpotifyMetadata(fetch, input);
let found: Found;
if (source) found = { ...(await srv!.getSourceTracks(source)), truncated: false };
else if (input!.kind === 'search') {
// Spotify search gives canonical names and artwork; "Artist - Title" works without it.
const typed = parseArtistTitle(query);
try {
found = await getSpotifyMetadata(fetch, input!, { owner });
} catch (error) {
if (!typed || !(error instanceof ApiError)) throw error;
found = { tracks: [typed], truncated: false };
}
} else
found = await getSpotifyMetadata(
fetch,
input as Exclude<typeof input, null | { kind: 'unsupported' }>,
{ owner }
);
const store: MatchStore | undefined = srv?.matchStore;
const results = await mapConcurrent(
metadata.tracks,
found.tracks,
MATCH_CONCURRENCY,
async (track): Promise<TrackMatchResult> => {
try {
const matches = await getOsuMaps(fetch, track);
const matches = await getOsuMaps(fetch, track, store);
return {
track,
matches,
@@ -57,16 +101,40 @@ export const actions: Actions = {
}
}
);
return { query, results, collection: metadata.collection, truncated: metadata.truncated };
if (source && !results.length)
return fail(404, {
query,
source,
error:
source === 'now'
? 'Nothing is playing and no plays are recorded yet.'
: 'No tracks in this list yet. The listening history fills up once Spotify is connected.'
});
return {
query,
source,
results,
collection: found.collection,
truncated: found.truncated
};
} catch (error) {
logApiError(error);
const spotifyMissing =
error instanceof ApiError && error.provider === 'Spotify' && error.status === 503;
return fail(
error instanceof ApiError && error.status === 429
? 429
: error instanceof ApiError && [403, 404].includes(error.status)
? 404
: 502,
{ query, error: publicError(error) }
{
query,
source,
error:
spotifyMissing && input?.kind === 'search'
? 'Spotify search is unavailable right now. Type it as "Artist - Title" to search osu! directly.'
: publicError(error)
}
);
} finally {
release();

View File

@@ -1,102 +1,237 @@
<script lang="ts">
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { enhance } from '$app/forms';
import { browser } from '$app/env';
import type { PageProps } from './$types';
import Artwork from '#lib/map-finder/Artwork.svelte';
import MapCard from '#lib/map-finder/MapCard.svelte';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import * as Card from '#lib/components/ui/card/index.js';
let { form }: PageProps = $props();
let pending = $state(false);
let filter = $state('all');
const filters = [
{ key: 'all', label: 'All' },
{ key: 'matched', label: 'Matched' },
{ key: 'no_match', label: 'No match' },
{ key: 'error', label: 'Errors' }
import {
DEFAULT_FILTERS,
MODES,
STATUSES,
filterMatches,
isDefault,
type Filters
} from '#lib/map-finder/filters.js';
import type { TrackMatchResult } from '#lib/map-finder/types.js';
let { data, form }: PageProps = $props();
let pending = $state<string | null>(null);
let show = $state('all');
const FILTER_KEY = 'map-finder-filters';
function savedFilters(): Filters {
if (!browser) return { ...DEFAULT_FILTERS };
try {
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTER_KEY) ?? '{}') };
} catch {
return { ...DEFAULT_FILTERS };
}
}
let filters = $state<Filters>(savedFilters());
$effect(() => {
localStorage.setItem(FILTER_KEY, JSON.stringify(filters));
});
const shows = [
{ key: 'all', label: 'all tracks' },
{ key: 'matched', label: 'with a match' },
{ key: 'no_match', label: 'no match' },
{ key: 'error', label: 'errors' }
];
let results = $derived(form?.results ?? []);
let visible = $derived(results.filter((result) => filter === 'all' || result.status === filter));
let matched = $derived(results.filter((result) => result.status === 'matched').length);
const stars = (value: string) => {
const n = Number(value);
return value.trim() === '' || !Number.isFinite(n) ? null : n;
};
type Shown = TrackMatchResult & {
shown: TrackMatchResult['matches'];
totals: Map<number, number>;
};
let results = $derived(form && 'results' in form ? (form.results ?? []) : []);
let filtered = $derived<Shown[]>(
results.map((result) => ({
...result,
shown: filterMatches(result.matches, filters),
totals: new Map(result.matches.map((m) => [m.id, m.beatmaps?.length ?? 0]))
}))
);
let hasMatch = (r: Shown) => r.shown.some((m) => m.confidence !== 'low');
let visible = $derived(
filtered.filter(
(r) =>
show === 'all' ||
(show === 'matched' && hasMatch(r)) ||
(show === 'no_match' && r.status !== 'error' && !hasMatch(r)) ||
(show === 'error' && r.status === 'error')
)
);
let matched = $derived(filtered.filter(hasMatch).length);
const collection = $derived(form && 'collection' in form ? form.collection : undefined);
const spotifyLink = (id: string, kind: string) =>
kind === 'library' ? undefined : `https://open.spotify.com/${kind}/${id}`;
</script>
<div class="mx-auto max-w-5xl px-4 py-6 sm:py-12">
<Card.Root class="p-5 sm:p-10">
<h1 class="mb-6 text-center text-3xl font-semibold lg:text-4xl">Spotify Map Finder</h1>
<h1 class="mb-2 text-center text-3xl font-semibold lg:text-4xl">osu! map finder</h1>
<p class="mb-6 text-center text-sm text-muted-foreground">
Find osu! beatmaps for a song, a Spotify link, or the music I have been listening to.
</p>
{#if !data.configured.osu}
<p role="alert" class="mb-4 rounded border border-destructive p-3 text-sm text-destructive">
osu! search is offline: this server has no osu! API credentials configured.
</p>
{:else if !data.configured.spotify}
<p class="mb-4 rounded border border-border p-3 text-sm text-muted-foreground">
Spotify links are unavailable right now. Type a song as <em>Artist - Title</em> instead.
</p>
{/if}
<form
method="POST"
class="space-y-4"
aria-busy={pending}
use:enhance={() => {
pending = true;
aria-busy={!!pending}
use:enhance={({ submitter }) => {
pending = submitter?.getAttribute('value') || 'search';
return async ({ update }) => {
try {
filter = 'all';
show = 'all';
await update({ reset: false });
} finally {
pending = false;
pending = null;
}
};
}}
>
<label for="query" class="sr-only">Song or Spotify URL</label>
<Input
id="query"
name="query"
type="text"
value={form?.query ?? ''}
required
maxlength={500}
placeholder="Deftones Change or a Spotify URL"
aria-describedby="input-help"
class="h-11"
/>
<div class="flex flex-col gap-2 sm:flex-row">
<Input
id="query"
name="query"
type="text"
value={form?.query ?? ''}
required
maxlength={500}
placeholder="Deftones - Change, or a Spotify track/album/playlist link"
aria-describedby="input-help"
class="h-11"
/>
<Button type="submit" disabled={!!pending} class="h-11 sm:w-32"
>{pending === 'search' ? 'Searching…' : 'Search'}</Button
>
</div>
<p id="input-help" class="text-sm text-muted-foreground">
Search a song, or enter a Spotify track, playlist or album URL/URI. Up to 50 tracks.
A song (<em>Artist - Title</em> works best), or a Spotify track, album or playlist (up to 50 tracks).
Spotify only shares the contents of my own playlists with this site.
</p>
<Button type="submit" disabled={pending} class="w-full"
>{pending ? 'Searching…' : 'Search'}</Button
>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">or maps for my</span>
{#each data.sources as source (source.key)}
<button
type="submit"
name="source"
value={source.key}
formnovalidate
disabled={!!pending || !source.available || !data.configured.osu}
title={source.available ? undefined : 'Available once Spotify is connected'}
class="rounded-full border border-border px-3 py-1 hover:border-foreground disabled:cursor-not-allowed disabled:opacity-40"
>{pending === source.key ? 'loading…' : source.label}</button
>
{/each}
</div>
</form>
<div role="status">
{#if pending}<p class="mt-4 text-sm text-muted-foreground">
Searching for maps. Playlists and albums may take a few minutes.
Searching osu! for maps. Lists and playlists can take a minute the first time; results are
cached after that.
</p>{/if}
</div>
{#if form?.error}<p role="alert" class="mt-4 text-destructive">{form.error}</p>{/if}
{#if form?.results}
<section class="mt-8" aria-label="Search results" aria-busy={pending}>
{#if form.collection}
<fieldset class="mt-6 flex flex-wrap items-end gap-3 text-sm">
<legend class="sr-only">Filter maps</legend>
<label class="flex flex-col gap-1"
><span class="text-xs text-muted-foreground">mode</span>
<NativeSelect
bind:value={filters.mode}
class="rounded border border-border bg-background p-2"
>
{#each MODES as option (option.key)}<option value={option.key}>{option.label}</option
>{/each}
</NativeSelect></label
>
<label class="flex flex-col gap-1"
><span class="text-xs text-muted-foreground">status</span>
<NativeSelect
bind:value={filters.status}
class="rounded border border-border bg-background p-2"
>
{#each STATUSES as option (option.key)}<option value={option.key}>{option.label}</option
>{/each}
</NativeSelect></label
>
<label class="flex flex-col gap-1"
><span class="text-xs text-muted-foreground">min ★</span>
<Input
type="number"
min="0"
max="15"
step="0.5"
class="h-9 w-20"
value={filters.minStars ?? ''}
oninput={(e) => (filters.minStars = stars(e.currentTarget.value))}
/></label
>
<label class="flex flex-col gap-1"
><span class="text-xs text-muted-foreground">max ★</span>
<Input
type="number"
min="0"
max="15"
step="0.5"
class="h-9 w-20"
value={filters.maxStars ?? ''}
oninput={(e) => (filters.maxStars = stars(e.currentTarget.value))}
/></label
>
{#if !isDefault(filters)}<button
type="button"
class="pb-2 text-xs underline"
onclick={() => (filters = { ...DEFAULT_FILTERS })}>reset filters</button
>{/if}
</fieldset>
{#if form && 'results' in form && form.results}
<section class="mt-8" aria-label="Search results" aria-busy={!!pending}>
{#if collection}
<div class="mb-4 flex items-start gap-3">
<Artwork src={form.collection.image} class="h-16 w-16 shrink-0" />
{#if collection.image}<Artwork src={collection.image} class="h-16 w-16 shrink-0" />{/if}
<div class="min-w-0 break-words">
<h2 class="text-xl font-semibold">{form.collection.name}</h2>
{#if form.collection.owner}<p class="text-sm text-muted-foreground">
{form.collection.owner}
<h2 class="text-xl font-semibold">{collection.name}</h2>
{#if collection.owner}<p class="text-sm text-muted-foreground">
{collection.owner}
</p>{/if}
<p class="text-sm">{results.length} tracks processed · {matched} matched</p>
<a
class="text-sm text-foreground underline"
href={`https://open.spotify.com/${form.collection.kind}/${form.collection.id}`}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>
<p class="text-sm">{results.length} tracks · {matched} with a match</p>
{#if spotifyLink(collection.id, collection.kind)}<a
class="text-sm text-foreground underline"
href={spotifyLink(collection.id, collection.kind)}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>{/if}
</div>
</div>
{#if form.truncated}<p class="mb-4 text-sm text-muted-foreground">
Showing the first {results.length} usable tracks. The collection reached the processing
limit.
</p>{/if}
{/if}
{#if results.length > 1}
<label class="mb-6 block text-sm"
>Show
<NativeSelect
bind:value={filter}
bind:value={show}
class="ml-2 rounded border border-border bg-background p-2"
>
{#each filters as option (option.key)}<option value={option.key}
>{option.label}</option
{#each shows as option (option.key)}<option value={option.key}>{option.label}</option
>{/each}
</NativeSelect>
</label>
@@ -108,6 +243,8 @@
</p>{/if}
<ul class="space-y-8">
{#each visible as result (result.track.id)}
{@const good = result.shown.filter((map) => map.confidence !== 'low')}
{@const low = result.shown.filter((map) => map.confidence === 'low')}
<li>
<div class="mb-4 flex items-start gap-3">
<Artwork src={result.track.album.images[0]?.url} class="h-16 w-16 shrink-0" />
@@ -116,36 +253,36 @@
{result.track.artists.map((artist) => artist.name).join(', ')} – {result.track
.name}
</h3>
<p class="text-sm text-muted-foreground">{result.track.album.name}</p>
<a
class="text-sm text-foreground underline"
href={`https://open.spotify.com/track/${result.track.id}`}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>
<p class="text-sm text-muted-foreground">
{[result.track.album.name, result.track.note].filter(Boolean).join(' · ')}
</p>
{#if result.track.url}<a
class="text-sm text-foreground underline"
href={result.track.url}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>{/if}
</div>
</div>
{#if result.status === 'error'}<p class="text-destructive">{result.error}</p>{:else}
{#if result.status === 'no_match'}<p class="mb-3 text-muted-foreground">
No confident match found.
{#if !good.length}<p class="mb-3 text-muted-foreground">
{result.matches.some((m) => m.confidence !== 'low')
? 'Matches exist, but none pass the filters.'
: 'No confident match found.'}
</p>{/if}
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each result.matches.filter((map) => map.confidence !== 'low') as map (map.id)}<li
>
<MapCard {map} />
{#each good as map (map.id)}<li>
<MapCard {map} total={result.totals.get(map.id)} />
</li>{/each}
</ul>
{#if result.matches.some((map) => map.confidence === 'low')}
{#if low.length}
<details class="mt-4">
<summary class="cursor-pointer text-sm text-muted-foreground"
>Low-confidence candidates ({result.matches.filter(
(map) => map.confidence === 'low'
).length})</summary
>Low-confidence candidates ({low.length})</summary
>
<ul class="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each result.matches.filter((map) => map.confidence === 'low') as map (map.id)}<li
>
<MapCard {map} />
{#each low as map (map.id)}<li>
<MapCard {map} total={result.totals.get(map.id)} />
</li>{/each}
</ul>
</details>

View File

@@ -1,17 +1,29 @@
import { expect, it, vi } from 'vitest';
vi.mock('#lib/server/spotify.js', () => ({ getSpotifyMetadata: vi.fn() }));
vi.mock('#lib/server/osu.js', () => ({ getOsuMaps: vi.fn(), MATCH_CONCURRENCY: 3 }));
vi.mock('#lib/server/osu.js', () => ({
getOsuMaps: vi.fn(),
osuConfigured: () => true,
MATCH_CONCURRENCY: 3
}));
vi.mock('./finder.server', () => ({
isSource: (value: unknown) => value === 'top',
SOURCES: { top: { label: 'top tracks', cost: 6, owner: false } },
getSourceTracks: vi.fn(async () => ({ tracks: [], collection: undefined })),
matchStore: undefined
}));
import { actions } from './+page.server';
import { getSpotifyMetadata } from '#lib/server/spotify.js';
import { getOsuMaps } from '#lib/server/osu.js';
import type { RequestEvent } from './$types';
const event = (query: string, address = 'validation-test') => {
const event = (query: string, address = 'validation-test', source?: string) => {
const data = new FormData();
data.set('query', query);
if (source) data.set('source', source);
return {
request: new Request('https://site.test', { method: 'POST', body: data }),
fetch: vi.fn(),
getClientAddress: () => address
getClientAddress: () => address,
locals: { user: null }
} as unknown as RequestEvent;
};
it.each(['', 'spotify:track:bad', 'https://open.spotify.com/artist/6PUzxtIHkv346yP89NzP9X'])(
@@ -59,3 +71,20 @@ it('returns site-level 429 after the per-client budget is exhausted', async () =
data: { query: 'Song', error: expect.stringContaining('Site search limit') }
});
});
it('falls back to a typed "Artist - Title" when Spotify is unavailable', async () => {
const { ApiError } = await import('#lib/server/finder-http.js');
vi.mocked(getSpotifyMetadata).mockRejectedValueOnce(
new ApiError('Spotify', 503, 'Spotify is not configured on this server.')
);
vi.mocked(getOsuMaps).mockResolvedValueOnce([]);
const result = await actions.default(event('Camellia - Ghost', 'typed-test'));
expect(result).toMatchObject({
results: [{ track: { name: 'Ghost', artists: [{ name: 'Camellia' }] }, status: 'no_match' }]
});
});
it('rejects unknown quick lists and reports empty ones', async () => {
expect(await actions.default(event('', 'source-test', 'nope'))).toMatchObject({ status: 400 });
expect(await actions.default(event('', 'source-test', 'top'))).toMatchObject({ status: 404 });
});

View File

@@ -0,0 +1,32 @@
import { client as sql } from '#lib/server/db/index.js';
import { osuConfigured } from '#lib/server/osu.js';
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from '$app/env/private';
import { SOURCES, type Source } from '#lib/server/map-finder/sources.js';
export { isSource, SOURCES, getSourceTracks } from '#lib/server/map-finder/sources.js';
export { matchStore } from '#lib/server/map-finder/cache.js';
/** Which quick lists can work right now (the page greys out the others). */
export async function availability(owner: boolean) {
const [row] = await sql`
select exists (select 1 from music.plays) as history,
exists (select 1 from music.spotify_auth where not needs_reauth) as connected,
(select scope from music.spotify_auth where id = 1) as scope`.catch(() => [
{ history: false, connected: false, scope: null }
]);
const spotify = !!(SPOTIFY_CLIENT_ID && SPOTIFY_CLIENT_SECRET);
const connected = spotify && !!row.connected;
const scopes = typeof row.scope === 'string' ? row.scope.split(' ') : [];
const sources = (Object.entries(SOURCES) as [Source, (typeof SOURCES)[Source]][])
.filter(([, s]) => !s.owner || owner)
.map(([key, s]) => ({
key,
label: s.label,
available:
key === 'spotify-top'
? connected
: key === 'liked'
? connected && scopes.includes('user-library-read')
: !!row.history
}));
return { sources, configured: { spotify, osu: osuConfigured(), connected } };
}