This commit is contained in:
75
src/routes/projects/spotify-beatmap-finder/+page.server.ts
Normal file
75
src/routes/projects/spotify-beatmap-finder/+page.server.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Actions } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { parseSpotifyInput } from '$lib/map-finder/input';
|
||||
import type { TrackMatchResult } from '$lib/map-finder/types';
|
||||
import { getSpotifyMetadata } from '$lib/server/spotify';
|
||||
import { getOsuMaps, MATCH_CONCURRENCY } from '$lib/server/osu';
|
||||
import { ApiError, publicError, logApiError, mapConcurrent } from '$lib/server/finder-http';
|
||||
import { acquireSearch } from '$lib/server/finder-limit';
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, fetch, getClientAddress }) => {
|
||||
const data = await request.formData();
|
||||
const value = data.get('query');
|
||||
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.`
|
||||
});
|
||||
let address: string;
|
||||
try {
|
||||
address = getClientAddress();
|
||||
} catch {
|
||||
address = 'shared-unknown';
|
||||
}
|
||||
const release = acquireSearch(
|
||||
address,
|
||||
input.kind === 'playlist' || input.kind === 'album' ? 6 : 1
|
||||
);
|
||||
if (!release)
|
||||
return fail(429, {
|
||||
query,
|
||||
error:
|
||||
'Site search limit reached. Wait up to 10 minutes, and finish any pending search before trying again.'
|
||||
});
|
||||
try {
|
||||
const metadata = await getSpotifyMetadata(fetch, input);
|
||||
const results = await mapConcurrent(
|
||||
metadata.tracks,
|
||||
MATCH_CONCURRENCY,
|
||||
async (track): Promise<TrackMatchResult> => {
|
||||
try {
|
||||
const matches = await getOsuMaps(fetch, track);
|
||||
return {
|
||||
track,
|
||||
matches,
|
||||
status: matches.some((m) => m.confidence !== 'low') ? 'matched' : 'no_match'
|
||||
};
|
||||
} catch (error) {
|
||||
logApiError(error);
|
||||
return { track, matches: [], status: 'error', error: publicError(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
return { query, results, collection: metadata.collection, truncated: metadata.truncated };
|
||||
} catch (error) {
|
||||
logApiError(error);
|
||||
return fail(
|
||||
error instanceof ApiError && error.status === 429
|
||||
? 429
|
||||
: error instanceof ApiError && [403, 404].includes(error.status)
|
||||
? 404
|
||||
: 502,
|
||||
{ query, error: publicError(error) }
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
};
|
||||
153
src/routes/projects/spotify-beatmap-finder/+page.svelte
Normal file
153
src/routes/projects/spotify-beatmap-finder/+page.svelte
Normal file
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageProps } from './$types';
|
||||
import Artwork from '$lib/map-finder/Artwork.svelte';
|
||||
import MapCard from '$lib/map-finder/MapCard.svelte';
|
||||
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' }
|
||||
];
|
||||
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);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-6 sm:py-12">
|
||||
<div class="rounded-3xl bg-white p-5 text-gray-800 shadow sm:p-10">
|
||||
<h1 class="mb-6 text-center text-3xl font-semibold lg:text-4xl">Spotify Map Finder</h1>
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-4"
|
||||
aria-busy={pending}
|
||||
use:enhance={() => {
|
||||
pending = true;
|
||||
return async ({ update }) => {
|
||||
try {
|
||||
filter = 'all';
|
||||
await update({ reset: false });
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<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="block w-full rounded border border-gray-300 bg-gray-100 p-3 focus-visible:outline-2 focus-visible:outline-blue-600"
|
||||
/>
|
||||
<p id="input-help" class="text-sm text-gray-600">
|
||||
Search a song, or enter a Spotify track, playlist or album URL/URI. Up to 50 tracks.
|
||||
</p>
|
||||
<button
|
||||
disabled={pending}
|
||||
class="w-full rounded bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-60"
|
||||
>{pending ? 'Searching…' : 'Search'}</button
|
||||
>
|
||||
</form>
|
||||
<div role="status">
|
||||
{#if pending}<p class="mt-4 text-sm text-gray-600">
|
||||
Searching for maps. Playlists and albums may take a few minutes.
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if form?.error}<p role="alert" class="mt-4 text-red-700">{form.error}</p>{/if}
|
||||
|
||||
{#if form?.results}
|
||||
<section class="mt-8" aria-label="Search results" aria-busy={pending}>
|
||||
{#if form.collection}
|
||||
<div class="mb-4 flex items-start gap-3">
|
||||
<Artwork src={form.collection.image} class="h-16 w-16 shrink-0" />
|
||||
<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-gray-600">
|
||||
{form.collection.owner}
|
||||
</p>{/if}
|
||||
<p class="text-sm">{results.length} tracks processed · {matched} matched</p>
|
||||
<a
|
||||
class="text-sm text-blue-700 underline"
|
||||
href={`https://open.spotify.com/${form.collection.kind}/${form.collection.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Open in Spotify</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if form.truncated}<p class="mb-4 text-sm text-amber-800">
|
||||
Showing the first {results.length} usable tracks. The collection reached the processing
|
||||
limit.
|
||||
</p>{/if}
|
||||
<label class="mb-6 block text-sm"
|
||||
>Show
|
||||
<select bind:value={filter} class="ml-2 rounded border border-gray-300 bg-white p-2">
|
||||
{#each filters as option (option.key)}<option value={option.key}
|
||||
>{option.label}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if !results.length}<p class="text-gray-600">
|
||||
No usable tracks found.
|
||||
</p>{:else if !visible.length}<p class="text-gray-600">No tracks in this filter.</p>{/if}
|
||||
<ul class="space-y-8">
|
||||
{#each visible as result (result.track.id)}
|
||||
<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" />
|
||||
<div class="min-w-0 break-words">
|
||||
<h3 class="font-semibold">
|
||||
{result.track.artists.map((artist) => artist.name).join(', ')} – {result.track
|
||||
.name}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">{result.track.album.name}</p>
|
||||
<a
|
||||
class="text-sm text-blue-700 underline"
|
||||
href={`https://open.spotify.com/track/${result.track.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Open in Spotify</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if result.status === 'error'}<p class="text-red-700">{result.error}</p>{:else}
|
||||
{#if result.status === 'no_match'}<p class="mb-3 text-gray-600">
|
||||
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} />
|
||||
</li>{/each}
|
||||
</ul>
|
||||
{#if result.matches.some((map) => map.confidence === 'low')}
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm text-gray-600"
|
||||
>Low-confidence candidates ({result.matches.filter(
|
||||
(map) => map.confidence === '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} />
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
61
src/routes/projects/spotify-beatmap-finder/action.test.ts
Normal file
61
src/routes/projects/spotify-beatmap-finder/action.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
vi.mock('$lib/server/spotify', () => ({ getSpotifyMetadata: vi.fn() }));
|
||||
vi.mock('$lib/server/osu', () => ({ getOsuMaps: vi.fn(), MATCH_CONCURRENCY: 3 }));
|
||||
import { actions } from './+page.server';
|
||||
import { getSpotifyMetadata } from '$lib/server/spotify';
|
||||
import { getOsuMaps } from '$lib/server/osu';
|
||||
import type { RequestEvent } from './$types';
|
||||
const event = (query: string, address = 'validation-test') => {
|
||||
const data = new FormData();
|
||||
data.set('query', query);
|
||||
return {
|
||||
request: new Request('https://site.test', { method: 'POST', body: data }),
|
||||
fetch: vi.fn(),
|
||||
getClientAddress: () => address
|
||||
} as unknown as RequestEvent;
|
||||
};
|
||||
it.each(['', 'spotify:track:bad', 'https://open.spotify.com/artist/6PUzxtIHkv346yP89NzP9X'])(
|
||||
'validates before making API calls: %s',
|
||||
async (query) => {
|
||||
const result = await actions.default(event(query));
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(getSpotifyMetadata).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('keeps successful tracks when another match fails', async () => {
|
||||
const track = {
|
||||
id: '6PUzxtIHkv346yP89NzP9X',
|
||||
name: 'Song',
|
||||
artists: [{ name: 'Artist' }],
|
||||
album: { name: '', images: [] }
|
||||
};
|
||||
vi.mocked(getSpotifyMetadata).mockResolvedValue({
|
||||
tracks: [track, { ...track, id: '2PUzxtIHkv346yP89NzP9X' }],
|
||||
truncated: false
|
||||
});
|
||||
vi.mocked(getOsuMaps)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 1, title: 'Song', artist: 'Artist', score: 100, confidence: 'high', reasons: [] }
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('sensitive upstream error'));
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const result = await actions.default(event('Song', 'partial-test'));
|
||||
expect(result).toMatchObject({
|
||||
results: [
|
||||
{ status: 'matched' },
|
||||
{ status: 'error', error: 'Search could not be completed. Please try again.' }
|
||||
]
|
||||
});
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('returns site-level 429 after the per-client budget is exhausted', async () => {
|
||||
vi.mocked(getSpotifyMetadata).mockResolvedValue({ tracks: [], truncated: false });
|
||||
for (let i = 0; i < 2; i++)
|
||||
await actions.default(event('spotify:album:6PUzxtIHkv346yP89NzP9X', 'limited-test'));
|
||||
const result = await actions.default(event('Song', 'limited-test'));
|
||||
expect(result).toMatchObject({
|
||||
status: 429,
|
||||
data: { query: 'Song', error: expect.stringContaining('Site search limit') }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user