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 URIs `https://stanrunge.dev/api/spotify/callback` and
`https://dev.stanrunge.dev/api/spotify/callback`. `https://dev.stanrunge.dev/api/spotify/callback`.
2. Logged in as the owner, open <https://stanrunge.dev/api/spotify/connect> once. Scopes: 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 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, 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`. 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"> <script lang="ts">
import type { ScoredBeatmapset } from './types'; import type { ScoredBeatmapset } from './types';
import Artwork from './Artwork.svelte'; 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> </script>
<article
class="flex h-full flex-col overflow-hidden rounded border border-border bg-background text-sm"
>
<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>
<div class="flex flex-1 flex-col gap-1.5 p-3">
<h4 class="leading-tight font-semibold">
<a <a
href={`https://osu.ppy.sh/beatmapsets/${map.id}`} href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="block h-full overflow-hidden rounded border border-border hover:border-foreground focus-visible:outline-2 focus-visible:outline-ring" class="hover:underline">{map.title_unicode || map.title}</a
> >
<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> </h4>
<p class="text-sm text-muted-foreground">{map.score}/100 · {map.confidence} confidence</p> <p class="text-muted-foreground">
<p class="text-xs text-muted-foreground">{map.reasons.join(' · ')}</p> {map.artist_unicode || map.artist}{#if map.creator}
{#if map.status || map.beatmaps}<p class="text-xs text-muted-foreground"> · mapped by
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps <a
? `${map.beatmaps.length} difficulties` 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} </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>
</a> </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 = export type SpotifyInput =
| { kind: 'track'; id: string } | { kind: 'track'; id: string }
| { kind: 'playlist'; id: string } | { kind: 'playlist'; id: string }
@@ -42,3 +43,26 @@ export function parseSpotifyInput(value: string): SpotifyInput {
); );
return { kind: resource, id }; 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 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[] { export function rankCandidates(track: SpotifyTrack, maps: OsuBeatmapset[]): ScoredBeatmapset[] {
const merged = new Map<number, ScoredBeatmapset>(); const merged = new Map<number, ScoredBeatmapset>();
for (const map of maps) { for (const map of maps) {
const scored = scoreBeatmapset(track, map); const scored = scoreBeatmapset(track, map);
if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored); if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored);
} }
return [...merged.values()] return (
[...merged.values()]
.filter((m) => m.score >= MIN_CANDIDATE_SCORE) .filter((m) => m.score >= MIN_CANDIDATE_SCORE)
.sort((a, b) => b.score - a.score || a.id - b.id) // Equal scores (many maps of one song): prefer maps with a leaderboard, then popular ones.
.slice(0, MAX_CANDIDATES); .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 = { export type SpotifyTrack = {
/** Spotify track id, or `db:<id>` for a track from the listening history without one. */
id: string; id: string;
name: string; name: string;
artists: { name: string }[]; artists: { name: string }[];
album: { name: string; images: { url: 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 = { export type SpotifyCollection = {
kind: 'playlist' | 'album'; kind: 'playlist' | 'album' | 'library';
id: string; id: string;
name: string; name: string;
owner: string; owner: string;
image?: 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 = { export type OsuBeatmapset = {
id: number; id: number;
@@ -17,9 +34,17 @@ export type OsuBeatmapset = {
title: string; title: string;
artist_unicode?: string; artist_unicode?: string;
title_unicode?: string; title_unicode?: string;
covers?: { card?: string; 'card@2x'?: string }; covers?: { card?: string; 'card@2x'?: string; list?: string };
status?: 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 & { export type ScoredBeatmapset = OsuBeatmapset & {
score: number; score: number;

View File

@@ -15,7 +15,10 @@ export const SPOTIFY_SCOPES = [
'user-read-currently-playing', 'user-read-currently-playing',
'user-read-recently-played', 'user-read-recently-played',
'user-read-playback-state', '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; ] as const;
export class SpotifyError extends Error { 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`)] (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). */ /** Resumable backfill state, e.g. key 'lastfm:stanrunge' (see scripts/music-import-lastfm.ts). */
export const musicImportState = musicSchema.table('import_state', { export const musicImportState = musicSchema.table('import_state', {
key: text('key').primaryKey(), 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' })); vi.mock('$app/env/private', () => ({ OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' }));
import { getOsuMaps } from './osu'; import { getOsuMaps } from './osu';
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => { 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 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 { 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 { import {
cleanTitle, cleanTitle,
normalize, normalize,
@@ -11,6 +17,12 @@ const token = createTokenCache();
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>(); const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
const pending = new Map<string, Promise<ScoredBeatmapset[]>>(); const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
export const MATCH_CONCURRENCY = 3; 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. // Global permits also bound outbound work across simultaneous visitors.
let active = 0; let active = 0;
const queue: (() => void)[] = []; const queue: (() => void)[] = [];
@@ -25,10 +37,72 @@ async function limited<T>(work: () => Promise<T>): Promise<T> {
else active--; 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[]> { async function search(fetchFn: Fetch, query: string): Promise<OsuBeatmapset[]> {
const access = await token(() => { const access = await token(() => {
if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET) if (!osuConfigured()) throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', { return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, 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)) if (!Array.isArray(response?.beatmapsets))
throw new ApiError('osu!', 502, 'osu! returned an invalid search response.'); throw new ApiError('osu!', 502, 'osu! returned an invalid search response.');
return response.beatmapsets return response.beatmapsets
.filter( .map(parseBeatmapset)
(item): item is OsuBeatmapset => .filter((item): item is OsuBeatmapset => item !== null);
!!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
}));
} }
export async function getOsuMaps(fetchFn: Fetch, track: SpotifyTrack): Promise<ScoredBeatmapset[]> { const cacheError = (error: unknown) => {
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]); 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); for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
const hit = cache.get(key); const hit = cache.get(key);
if (hit) return hit.result; if (hit) return hit.result;
const existing = pending.get(key); const existing = pending.get(key);
if (existing) return existing; if (existing) return existing;
const work = limited(async () => { const remember = (result: ScoredBeatmapset[]) => {
const maps = await search(fetchFn, `${track.artists[0].name} ${track.name}`); if (cache.size >= 500) cache.delete(cache.keys().next().value!);
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); let ranked = rankCandidates(track, maps);
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE)) if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
ranked = rankCandidates(track, [ ranked = rankCandidates(track, [...maps, ...(await search(fetchFn, title))]);
...maps,
...(await search(fetchFn, cleanTitle(track.name) || track.name))
]);
if (cache.size >= 500) cache.delete(cache.keys().next().value!);
cache.set(key, { expires: Date.now() + 5 * 60_000, result: ranked });
return ranked; return ranked;
}).finally(() => pending.delete(key)); });
await store?.set(key, ranked).catch(cacheError);
return remember(ranked);
})().finally(() => pending.delete(key));
pending.set(key, work); pending.set(key, work);
return work; return work;
} }

View File

@@ -39,7 +39,8 @@ export function usableTrack(value: unknown): SpotifyTrack | null {
id: track.id, id: track.id,
name: track.name, name: track.name,
artists, 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[] { export function playlistTracks(items: unknown[], seen = new Set<string>()): SpotifyTrack[] {
@@ -55,8 +56,20 @@ export function playlistTracks(items: unknown[], seen = new Set<string>()): Spot
} }
return result; return result;
} }
async function spotify(fetchFn: Fetch, path: string) { // Development-mode Spotify apps (2026) only return playlist contents to a user token, and
const access = await token(() => { // 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) if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET)
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.'); throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', { return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
@@ -67,7 +80,7 @@ async function spotify(fetchFn: Fetch, path: string) {
}, },
body: new URLSearchParams({ grant_type: 'client_credentials' }) body: new URLSearchParams({ grant_type: 'client_credentials' })
}); });
}); }));
return record( return record(
await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, { await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, {
headers: { Authorization: `Bearer ${access}` } headers: { Authorization: `Bearer ${access}` }
@@ -78,7 +91,8 @@ export async function getSpotifyMetadata(
fetchFn: Fetch, fetchFn: Fetch,
input: input:
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }> | Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
| Extract<SpotifyInput, { kind: 'search' }> | Extract<SpotifyInput, { kind: 'search' }>,
options: { owner?: boolean } = {}
): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> { ): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> {
if (input.kind === 'track' || input.kind === 'search') { if (input.kind === 'track' || input.kind === 'search') {
let response: RecordValue; let response: RecordValue;
@@ -137,7 +151,14 @@ export async function getSpotifyMetadata(
: undefined : undefined
}; };
// Extended-quota responses use tracks/track; newer development-mode responses use items/item. // 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 page = record(modern ? metadata.items : metadata.tracks);
let offset = 0; let offset = 0;
const tracks: SpotifyTrack[] = []; const tracks: SpotifyTrack[] = [];
@@ -146,7 +167,8 @@ export async function getSpotifyMetadata(
if (!Array.isArray(page.items)) if (!Array.isArray(page.items))
page = await spotify( page = await spotify(
fetchFn, 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)) if (!Array.isArray(page.items))
throw new ApiError( throw new ApiError(
@@ -181,7 +203,7 @@ export async function getSpotifyMetadata(
error.status, error.status,
input.kind === 'album' input.kind === 'album'
? 'Spotify album unavailable or not found. Try an individual track.' ? '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; throw error;
} }

View File

@@ -1,17 +1,38 @@
import type { Actions } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { parseSpotifyInput } from '#lib/map-finder/input.js'; import { parseSpotifyInput, parseArtistTitle } from '#lib/map-finder/input.js';
import type { TrackMatchResult } from '#lib/map-finder/types.js'; import type { SpotifyCollection, SpotifyTrack, TrackMatchResult } from '#lib/map-finder/types.js';
import { getSpotifyMetadata } from '#lib/server/spotify.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 { ApiError, publicError, logApiError, mapConcurrent } from '#lib/server/finder-http.js';
import { acquireSearch } from '#lib/server/finder-limit.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 = { export const actions: Actions = {
default: async ({ request, fetch, getClientAddress }) => { default: async ({ request, fetch, getClientAddress, locals }) => {
const data = await request.formData(); const data = await request.formData();
const value = data.get('query'); const value = data.get('query');
const sourceValue = data.get('source');
const query = typeof value === 'string' ? value.trim().slice(0, 501) : ''; const query = typeof value === 'string' ? value.trim().slice(0, 501) : '';
let input; 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 { try {
input = parseSpotifyInput(query); input = parseSpotifyInput(query);
} catch (error) { } catch (error) {
@@ -22,16 +43,22 @@ export const actions: Actions = {
query, query,
error: `Unsupported Spotify resource: ${input.resource}. Use a track, playlist, or album.` 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; let address: string;
try { try {
address = getClientAddress(); address = getClientAddress();
} catch { } catch {
address = 'shared-unknown'; address = 'shared-unknown';
} }
const release = acquireSearch( const cost = source
address, ? srv!.SOURCES[source].cost
input.kind === 'playlist' || input.kind === 'album' ? 6 : 1 : input!.kind === 'playlist' || input!.kind === 'album'
); ? 6
: 1;
const release = acquireSearch(address, cost);
if (!release) if (!release)
return fail(429, { return fail(429, {
query, 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.' 'Site search limit reached. Wait up to 10 minutes, and finish any pending search before trying again.'
}); });
try { 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( const results = await mapConcurrent(
metadata.tracks, found.tracks,
MATCH_CONCURRENCY, MATCH_CONCURRENCY,
async (track): Promise<TrackMatchResult> => { async (track): Promise<TrackMatchResult> => {
try { try {
const matches = await getOsuMaps(fetch, track); const matches = await getOsuMaps(fetch, track, store);
return { return {
track, track,
matches, 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) { } catch (error) {
logApiError(error); logApiError(error);
const spotifyMissing =
error instanceof ApiError && error.provider === 'Spotify' && error.status === 503;
return fail( return fail(
error instanceof ApiError && error.status === 429 error instanceof ApiError && error.status === 429
? 429 ? 429
: error instanceof ApiError && [403, 404].includes(error.status) : error instanceof ApiError && [403, 404].includes(error.status)
? 404 ? 404
: 502, : 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 { } finally {
release(); release();

View File

@@ -1,46 +1,109 @@
<script lang="ts"> <script lang="ts">
import { NativeSelect } from '#lib/components/ui/native-select/index.js'; import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { browser } from '$app/env';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import Artwork from '#lib/map-finder/Artwork.svelte'; import Artwork from '#lib/map-finder/Artwork.svelte';
import MapCard from '#lib/map-finder/MapCard.svelte'; import MapCard from '#lib/map-finder/MapCard.svelte';
import { Button } from '#lib/components/ui/button/index.js'; import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js'; import { Input } from '#lib/components/ui/input/index.js';
import * as Card from '#lib/components/ui/card/index.js'; import * as Card from '#lib/components/ui/card/index.js';
let { form }: PageProps = $props(); import {
let pending = $state(false); DEFAULT_FILTERS,
let filter = $state('all'); MODES,
const filters = [ STATUSES,
{ key: 'all', label: 'All' }, filterMatches,
{ key: 'matched', label: 'Matched' }, isDefault,
{ key: 'no_match', label: 'No match' }, type Filters
{ key: 'error', label: 'Errors' } } 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 ?? []); const stars = (value: string) => {
let visible = $derived(results.filter((result) => filter === 'all' || result.status === filter)); const n = Number(value);
let matched = $derived(results.filter((result) => result.status === 'matched').length); 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> </script>
<div class="mx-auto max-w-5xl px-4 py-6 sm:py-12"> <div class="mx-auto max-w-5xl px-4 py-6 sm:py-12">
<Card.Root class="p-5 sm:p-10"> <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 <form
method="POST" method="POST"
class="space-y-4" class="space-y-4"
aria-busy={pending} aria-busy={!!pending}
use:enhance={() => { use:enhance={({ submitter }) => {
pending = true; pending = submitter?.getAttribute('value') || 'search';
return async ({ update }) => { return async ({ update }) => {
try { try {
filter = 'all'; show = 'all';
await update({ reset: false }); await update({ reset: false });
} finally { } finally {
pending = false; pending = null;
} }
}; };
}} }}
> >
<label for="query" class="sr-only">Song or Spotify URL</label> <label for="query" class="sr-only">Song or Spotify URL</label>
<div class="flex flex-col gap-2 sm:flex-row">
<Input <Input
id="query" id="query"
name="query" name="query"
@@ -48,55 +111,127 @@
value={form?.query ?? ''} value={form?.query ?? ''}
required required
maxlength={500} maxlength={500}
placeholder="Deftones Change or a Spotify URL" placeholder="Deftones - Change, or a Spotify track/album/playlist link"
aria-describedby="input-help" aria-describedby="input-help"
class="h-11" class="h-11"
/> />
<p id="input-help" class="text-sm text-muted-foreground"> <Button type="submit" disabled={!!pending} class="h-11 sm:w-32"
Search a song, or enter a Spotify track, playlist or album URL/URI. Up to 50 tracks. >{pending === 'search' ? 'Searching…' : 'Search'}</Button
</p>
<Button type="submit" disabled={pending} class="w-full"
>{pending ? 'Searching…' : 'Search'}</Button
> >
</div>
<p id="input-help" class="text-sm text-muted-foreground">
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>
<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> </form>
<div role="status"> <div role="status">
{#if pending}<p class="mt-4 text-sm text-muted-foreground"> {#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} </p>{/if}
</div> </div>
{#if form?.error}<p role="alert" class="mt-4 text-destructive">{form.error}</p>{/if} {#if form?.error}<p role="alert" class="mt-4 text-destructive">{form.error}</p>{/if}
{#if form?.results} <fieldset class="mt-6 flex flex-wrap items-end gap-3 text-sm">
<section class="mt-8" aria-label="Search results" aria-busy={pending}> <legend class="sr-only">Filter maps</legend>
{#if form.collection} <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"> <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"> <div class="min-w-0 break-words">
<h2 class="text-xl font-semibold">{form.collection.name}</h2> <h2 class="text-xl font-semibold">{collection.name}</h2>
{#if form.collection.owner}<p class="text-sm text-muted-foreground"> {#if collection.owner}<p class="text-sm text-muted-foreground">
{form.collection.owner} {collection.owner}
</p>{/if} </p>{/if}
<p class="text-sm">{results.length} tracks processed · {matched} matched</p> <p class="text-sm">{results.length} tracks · {matched} with a match</p>
<a {#if spotifyLink(collection.id, collection.kind)}<a
class="text-sm text-foreground underline" class="text-sm text-foreground underline"
href={`https://open.spotify.com/${form.collection.kind}/${form.collection.id}`} href={spotifyLink(collection.id, collection.kind)}
target="_blank" target="_blank"
rel="noopener noreferrer">Open in Spotify</a rel="noopener noreferrer">Open in Spotify</a
> >{/if}
</div> </div>
</div> </div>
{#if form.truncated}<p class="mb-4 text-sm text-muted-foreground"> {#if form.truncated}<p class="mb-4 text-sm text-muted-foreground">
Showing the first {results.length} usable tracks. The collection reached the processing Showing the first {results.length} usable tracks. The collection reached the processing
limit. limit.
</p>{/if} </p>{/if}
{/if}
{#if results.length > 1}
<label class="mb-6 block text-sm" <label class="mb-6 block text-sm"
>Show >Show
<NativeSelect <NativeSelect
bind:value={filter} bind:value={show}
class="ml-2 rounded border border-border bg-background p-2" class="ml-2 rounded border border-border bg-background p-2"
> >
{#each filters as option (option.key)}<option value={option.key} {#each shows as option (option.key)}<option value={option.key}>{option.label}</option
>{option.label}</option
>{/each} >{/each}
</NativeSelect> </NativeSelect>
</label> </label>
@@ -108,6 +243,8 @@
</p>{/if} </p>{/if}
<ul class="space-y-8"> <ul class="space-y-8">
{#each visible as result (result.track.id)} {#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> <li>
<div class="mb-4 flex items-start gap-3"> <div class="mb-4 flex items-start gap-3">
<Artwork src={result.track.album.images[0]?.url} class="h-16 w-16 shrink-0" /> <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 {result.track.artists.map((artist) => artist.name).join(', ')} – {result.track
.name} .name}
</h3> </h3>
<p class="text-sm text-muted-foreground">{result.track.album.name}</p> <p class="text-sm text-muted-foreground">
<a {[result.track.album.name, result.track.note].filter(Boolean).join(' · ')}
</p>
{#if result.track.url}<a
class="text-sm text-foreground underline" class="text-sm text-foreground underline"
href={`https://open.spotify.com/track/${result.track.id}`} href={result.track.url}
target="_blank" target="_blank"
rel="noopener noreferrer">Open in Spotify</a rel="noopener noreferrer">Open in Spotify</a
> >{/if}
</div> </div>
</div> </div>
{#if result.status === 'error'}<p class="text-destructive">{result.error}</p>{:else} {#if result.status === 'error'}<p class="text-destructive">{result.error}</p>{:else}
{#if result.status === 'no_match'}<p class="mb-3 text-muted-foreground"> {#if !good.length}<p class="mb-3 text-muted-foreground">
No confident match found. {result.matches.some((m) => m.confidence !== 'low')
? 'Matches exist, but none pass the filters.'
: 'No confident match found.'}
</p>{/if} </p>{/if}
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <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 {#each good as map (map.id)}<li>
> <MapCard {map} total={result.totals.get(map.id)} />
<MapCard {map} />
</li>{/each} </li>{/each}
</ul> </ul>
{#if result.matches.some((map) => map.confidence === 'low')} {#if low.length}
<details class="mt-4"> <details class="mt-4">
<summary class="cursor-pointer text-sm text-muted-foreground" <summary class="cursor-pointer text-sm text-muted-foreground"
>Low-confidence candidates ({result.matches.filter( >Low-confidence candidates ({low.length})</summary
(map) => map.confidence === 'low'
).length})</summary
> >
<ul class="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <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 {#each low as map (map.id)}<li>
> <MapCard {map} total={result.totals.get(map.id)} />
<MapCard {map} />
</li>{/each} </li>{/each}
</ul> </ul>
</details> </details>

View File

@@ -1,17 +1,29 @@
import { expect, it, vi } from 'vitest'; import { expect, it, vi } from 'vitest';
vi.mock('#lib/server/spotify.js', () => ({ getSpotifyMetadata: vi.fn() })); 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 { actions } from './+page.server';
import { getSpotifyMetadata } from '#lib/server/spotify.js'; import { getSpotifyMetadata } from '#lib/server/spotify.js';
import { getOsuMaps } from '#lib/server/osu.js'; import { getOsuMaps } from '#lib/server/osu.js';
import type { RequestEvent } from './$types'; 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(); const data = new FormData();
data.set('query', query); data.set('query', query);
if (source) data.set('source', source);
return { return {
request: new Request('https://site.test', { method: 'POST', body: data }), request: new Request('https://site.test', { method: 'POST', body: data }),
fetch: vi.fn(), fetch: vi.fn(),
getClientAddress: () => address getClientAddress: () => address,
locals: { user: null }
} as unknown as RequestEvent; } as unknown as RequestEvent;
}; };
it.each(['', 'spotify:track:bad', 'https://open.spotify.com/artist/6PUzxtIHkv346yP89NzP9X'])( 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') } 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 } };
}