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

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