map finder: quick lists from my listening (top/recent/now/Spotify top/liked), Artist - Title without Spotify, owned playlists via the user token, rich beatmap cards (cover, mapper, status, BPM, length, star chips, preview, osu!direct), mode/status/star filters, DB result cache (music.osu_match_cache)
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -1,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
32
src/routes/projects/spotify-beatmap-finder/finder.server.ts
Normal file
32
src/routes/projects/spotify-beatmap-finder/finder.server.ts
Normal 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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user