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:
@@ -19,7 +19,7 @@ schema in the `personal` database:
|
||||
URIs `https://stanrunge.dev/api/spotify/callback` and
|
||||
`https://dev.stanrunge.dev/api/spotify/callback`.
|
||||
2. Logged in as the owner, open <https://stanrunge.dev/api/spotify/connect> once. Scopes:
|
||||
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read`.
|
||||
`user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read user-library-read playlist-read-private`.
|
||||
3. That's it: the **app itself** polls every minute (`src/lib/server/music/poller.ts`, started
|
||||
from `hooks.server.ts` `init`) with its own role and Spotify credentials. Status (last poll,
|
||||
last error) is shown to the owner on `/trackers/music`.
|
||||
|
||||
7
drizzle/20261024160937_osu_match_cache/migration.sql
Normal file
7
drizzle/20261024160937_osu_match_cache/migration.sql
Normal 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 $$;
|
||||
19888
drizzle/20261024160937_osu_match_cache/snapshot.json
Normal file
19888
drizzle/20261024160937_osu_match_cache/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,147 @@
|
||||
<script module lang="ts">
|
||||
// One preview at a time across all cards.
|
||||
let playing: HTMLAudioElement | null = null;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ScoredBeatmapset } from './types';
|
||||
import Artwork from './Artwork.svelte';
|
||||
let { map }: { map: ScoredBeatmapset } = $props();
|
||||
import { formatLength, MODE_LABEL, starColor, starRange } from './filters';
|
||||
import Play from 'lucide-svelte/icons/play';
|
||||
import Pause from 'lucide-svelte/icons/pause';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Download from 'lucide-svelte/icons/download';
|
||||
let { map, total }: { map: ScoredBeatmapset; total?: number } = $props();
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
let isPlaying = $state(false);
|
||||
const diffs = $derived(map.beatmaps ?? []);
|
||||
const range = $derived(starRange(map));
|
||||
const length = $derived(diffs.length ? Math.max(...diffs.map((d) => d.length)) : 0);
|
||||
const bpm = $derived(map.bpm ?? diffs.find((d) => d.bpm)?.bpm);
|
||||
const modes = $derived([...new Set(diffs.map((d) => d.mode))]);
|
||||
const statusClass = $derived(
|
||||
map.status === 'ranked' || map.status === 'approved'
|
||||
? 'bg-sky-600 text-white'
|
||||
: map.status === 'loved'
|
||||
? 'bg-pink-600 text-white'
|
||||
: map.status === 'qualified'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-100'
|
||||
);
|
||||
const compact = (n?: number) =>
|
||||
n === undefined ? '' : Intl.NumberFormat('en', { notation: 'compact' }).format(n);
|
||||
function toggle() {
|
||||
if (!map.preview_url) return;
|
||||
if (isPlaying && audio) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
playing?.pause();
|
||||
audio ??= new Audio(map.preview_url);
|
||||
audio.volume = 0.5;
|
||||
audio.onpause = audio.onended = () => (isPlaying = false);
|
||||
audio.onplay = () => (isPlaying = true);
|
||||
playing = audio;
|
||||
void audio.play().catch(() => (isPlaying = false));
|
||||
}
|
||||
$effect(() => () => audio?.pause());
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block h-full overflow-hidden rounded border border-border hover:border-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
<article
|
||||
class="flex h-full flex-col overflow-hidden rounded border border-border bg-background text-sm"
|
||||
>
|
||||
<Artwork src={map.covers?.card} class="h-24 rounded-none" />
|
||||
<div class="space-y-1 p-3">
|
||||
<h4 class="font-semibold">
|
||||
{map.artist_unicode || map.artist} – {map.title_unicode || map.title}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">{map.score}/100 · {map.confidence} confidence</p>
|
||||
<p class="text-xs text-muted-foreground">{map.reasons.join(' · ')}</p>
|
||||
{#if map.status || map.beatmaps}<p class="text-xs text-muted-foreground">
|
||||
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps
|
||||
? `${map.beatmaps.length} difficulties`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
<div class="relative">
|
||||
<Artwork src={map.covers?.['card@2x'] ?? map.covers?.card} class="h-24 rounded-none" />
|
||||
{#if map.status}<span
|
||||
class={`absolute top-2 left-2 rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase ${statusClass}`}
|
||||
>{map.status}</span
|
||||
>{/if}
|
||||
{#if map.preview_url}<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
class="absolute right-2 bottom-2 flex h-8 w-8 items-center justify-center rounded-full bg-black/70 text-white hover:bg-black/90 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label={isPlaying ? 'Pause preview' : 'Play preview'}
|
||||
>{#if isPlaying}<Pause size={14} />{:else}<Play size={14} />{/if}</button
|
||||
>{/if}
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<h4 class="leading-tight font-semibold">
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:underline">{map.title_unicode || map.title}</a
|
||||
>
|
||||
</h4>
|
||||
<p class="text-muted-foreground">
|
||||
{map.artist_unicode || map.artist}{#if map.creator}
|
||||
· mapped by
|
||||
<a
|
||||
class="hover:underline"
|
||||
href={`https://osu.ppy.sh/users/${encodeURIComponent(map.creator)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{map.creator}</a
|
||||
>{/if}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{[
|
||||
bpm ? `${Math.round(bpm)} BPM` : '',
|
||||
length ? formatLength(length) : '',
|
||||
range
|
||||
? range[0] === range[1]
|
||||
? `${range[0].toFixed(2)}★`
|
||||
: `${range[0].toFixed(1)}–${range[1].toFixed(1)}★`
|
||||
: '',
|
||||
modes.length ? modes.map((m) => MODE_LABEL[m]).join('/') : '',
|
||||
map.play_count !== undefined ? `${compact(map.play_count)} plays` : '',
|
||||
map.favourite_count !== undefined ? `♥ ${compact(map.favourite_count)}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{#if diffs.length}
|
||||
<ul class="flex flex-wrap gap-1" aria-label="Difficulties">
|
||||
{#each diffs.slice(0, 10) as diff (diff.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}#${diff.mode}/${diff.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${diff.version} (${MODE_LABEL[diff.mode]}, ${diff.stars.toFixed(2)}★)`}
|
||||
class="inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold text-black"
|
||||
style={`background-color: ${starColor(diff.stars)}`}>{diff.stars.toFixed(1)}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
{#if diffs.length > 10}<li class="text-xs text-muted-foreground">
|
||||
+{diffs.length - 10}
|
||||
</li>{/if}
|
||||
</ul>
|
||||
{#if total !== undefined && total > diffs.length}<p class="text-xs text-muted-foreground">
|
||||
{diffs.length} of {total} difficulties match the filters
|
||||
</p>{/if}
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground" title={map.reasons.join(' · ')}>
|
||||
match {map.score}/100 · {map.confidence}
|
||||
</p>
|
||||
<div class="mt-auto flex flex-wrap gap-3 pt-1 text-xs">
|
||||
<a
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"><ExternalLink size={12} /> osu! page</a
|
||||
>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
href={`osu://s/${map.id}`}
|
||||
title="osu!direct (supporters): open in the game">osu!direct</a
|
||||
>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"><Download size={12} /> .osz</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
68
src/lib/map-finder/filters.test.ts
Normal file
68
src/lib/map-finder/filters.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
102
src/lib/map-finder/filters.ts
Normal file
102
src/lib/map-finder/filters.ts
Normal 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'
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SpotifyTrack } from './types';
|
||||
export type SpotifyInput =
|
||||
| { kind: 'track'; id: string }
|
||||
| { kind: 'playlist'; id: string }
|
||||
@@ -42,3 +43,26 @@ export function parseSpotifyInput(value: string): SpotifyInput {
|
||||
);
|
||||
return { kind: resource, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Artist - Title" (or "Title by Artist") typed by hand, so a search works without Spotify.
|
||||
* Returns null when the text has no clear artist/title split.
|
||||
*/
|
||||
export function parseArtistTitle(value: string): SpotifyTrack | null {
|
||||
const text = value.trim().replace(/\s+/g, ' ');
|
||||
let artist: string | undefined, title: string | undefined;
|
||||
const dash = /^(.+?)\s+[-–—]\s+(.+)$/.exec(text);
|
||||
if (dash) [, artist, title] = dash;
|
||||
else {
|
||||
const by = /^(.+?)\s+by\s+(.+)$/i.exec(text);
|
||||
if (by) [, title, artist] = by;
|
||||
}
|
||||
if (!artist?.trim() || !title?.trim()) return null;
|
||||
return {
|
||||
id: `text:${artist.toLowerCase()}|${title.toLowerCase()}`,
|
||||
name: title.trim(),
|
||||
artists: [{ name: artist.trim() }],
|
||||
album: { name: '', images: [] },
|
||||
url: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,14 +74,31 @@ export function scoreBeatmapset(track: SpotifyTrack, map: OsuBeatmapset): Scored
|
||||
reasons
|
||||
};
|
||||
}
|
||||
const statusRank = (status?: string) =>
|
||||
status === 'ranked' || status === 'approved'
|
||||
? 3
|
||||
: status === 'loved'
|
||||
? 2
|
||||
: status === 'qualified'
|
||||
? 1
|
||||
: 0;
|
||||
export function rankCandidates(track: SpotifyTrack, maps: OsuBeatmapset[]): ScoredBeatmapset[] {
|
||||
const merged = new Map<number, ScoredBeatmapset>();
|
||||
for (const map of maps) {
|
||||
const scored = scoreBeatmapset(track, map);
|
||||
if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored);
|
||||
}
|
||||
return [...merged.values()]
|
||||
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
|
||||
.sort((a, b) => b.score - a.score || a.id - b.id)
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
return (
|
||||
[...merged.values()]
|
||||
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
|
||||
// Equal scores (many maps of one song): prefer maps with a leaderboard, then popular ones.
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
statusRank(b.status) - statusRank(a.status) ||
|
||||
(b.play_count ?? 0) - (a.play_count ?? 0) ||
|
||||
a.id - b.id
|
||||
)
|
||||
.slice(0, MAX_CANDIDATES)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
export type SpotifyTrack = {
|
||||
/** Spotify track id, or `db:<id>` for a track from the listening history without one. */
|
||||
id: string;
|
||||
name: string;
|
||||
artists: { name: string }[];
|
||||
album: { name: string; images: { url: string }[] };
|
||||
/** Link to the track on Spotify, when known. */
|
||||
url?: string | null;
|
||||
/** Extra context shown with the track, e.g. "12 plays" or "listening now". */
|
||||
note?: string;
|
||||
};
|
||||
export type SpotifyCollection = {
|
||||
kind: 'playlist' | 'album';
|
||||
kind: 'playlist' | 'album' | 'library';
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
};
|
||||
export type OsuMode = 'osu' | 'taiko' | 'fruits' | 'mania';
|
||||
export type OsuDifficulty = {
|
||||
id: number;
|
||||
version: string;
|
||||
mode: OsuMode;
|
||||
stars: number;
|
||||
/** Drain-inclusive length in seconds. */
|
||||
length: number;
|
||||
bpm?: number;
|
||||
status?: string;
|
||||
};
|
||||
export type OsuBeatmapset = {
|
||||
id: number;
|
||||
@@ -17,9 +34,17 @@ export type OsuBeatmapset = {
|
||||
title: string;
|
||||
artist_unicode?: string;
|
||||
title_unicode?: string;
|
||||
covers?: { card?: string; 'card@2x'?: string };
|
||||
covers?: { card?: string; 'card@2x'?: string; list?: string };
|
||||
status?: string;
|
||||
beatmaps?: { id: number }[];
|
||||
creator?: string;
|
||||
bpm?: number;
|
||||
play_count?: number;
|
||||
favourite_count?: number;
|
||||
preview_url?: string;
|
||||
ranked_date?: string | null;
|
||||
video?: boolean;
|
||||
nsfw?: boolean;
|
||||
beatmaps?: OsuDifficulty[];
|
||||
};
|
||||
export type ScoredBeatmapset = OsuBeatmapset & {
|
||||
score: number;
|
||||
|
||||
@@ -15,7 +15,10 @@ export const SPOTIFY_SCOPES = [
|
||||
'user-read-currently-playing',
|
||||
'user-read-recently-played',
|
||||
'user-read-playback-state',
|
||||
'user-top-read'
|
||||
'user-top-read',
|
||||
// Map finder: liked songs and Stan's own playlists.
|
||||
'user-library-read',
|
||||
'playlist-read-private'
|
||||
] as const;
|
||||
|
||||
export class SpotifyError extends Error {
|
||||
|
||||
@@ -172,6 +172,13 @@ export const musicSpotifyAuth = musicSchema.table(
|
||||
(t) => [check('spotify_auth_single_row', sql`${t.id} = 1`)]
|
||||
);
|
||||
|
||||
/** Map finder (/projects/spotify-beatmap-finder): osu! search results per normalised track. */
|
||||
export const musicOsuMatchCache = musicSchema.table('osu_match_cache', {
|
||||
key: text('key').primaryKey(),
|
||||
result: jsonb('result').notNull(),
|
||||
searchedAt: timestamptz('searched_at').notNull().defaultNow()
|
||||
});
|
||||
|
||||
/** Resumable backfill state, e.g. key 'lastfm:stanrunge' (see scripts/music-import-lastfm.ts). */
|
||||
export const musicImportState = musicSchema.table('import_state', {
|
||||
key: text('key').primaryKey(),
|
||||
|
||||
28
src/lib/server/map-finder/cache.ts
Normal file
28
src/lib/server/map-finder/cache.ts
Normal 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'`;
|
||||
}
|
||||
};
|
||||
146
src/lib/server/map-finder/sources.ts
Normal file
146
src/lib/server/map-finder/sources.ts
Normal 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: '' }
|
||||
};
|
||||
}
|
||||
45
src/lib/server/music/user-token.ts
Normal file
45
src/lib/server/music/user-token.ts
Normal 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(' ') : [];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('$app/env/private', () => ({ OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' }));
|
||||
import { getOsuMaps } from './osu';
|
||||
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => {
|
||||
@@ -32,3 +32,31 @@ it('falls back deliberately, merges IDs, reuses OAuth and caches matching result
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
describe('parseBeatmapset', () => {
|
||||
it('keeps the shown fields, fixes preview URLs and sorts difficulties', async () => {
|
||||
const { parseBeatmapset } = await import('./osu');
|
||||
const set = parseBeatmapset({
|
||||
id: 7,
|
||||
title: 'T',
|
||||
artist: 'A',
|
||||
creator: 'mapper',
|
||||
status: 'ranked',
|
||||
preview_url: '//b.ppy.sh/preview/7.mp3',
|
||||
covers: { card: 'https://assets.ppy.sh/c.jpg', list: 'javascript:alert(1)' },
|
||||
beatmaps: [
|
||||
{ id: 2, mode: 'osu', difficulty_rating: 5.123, total_length: 90, version: 'Hard' },
|
||||
{ id: 1, mode: 'osu', difficulty_rating: 2, total_length: 90, version: 'Easy' },
|
||||
{ id: 3, mode: 'bogus' }
|
||||
]
|
||||
});
|
||||
expect(set).toMatchObject({
|
||||
id: 7,
|
||||
creator: 'mapper',
|
||||
preview_url: 'https://b.ppy.sh/preview/7.mp3'
|
||||
});
|
||||
expect(set?.covers?.list).toBeUndefined();
|
||||
expect(set?.beatmaps?.map((d) => d.stars)).toEqual([2, 5.12]);
|
||||
expect(parseBeatmapset({ id: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { OSU_CLIENT_ID, OSU_CLIENT_SECRET } from '$app/env/private';
|
||||
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '#lib/map-finder/types.js';
|
||||
import type {
|
||||
OsuBeatmapset,
|
||||
OsuDifficulty,
|
||||
OsuMode,
|
||||
ScoredBeatmapset,
|
||||
SpotifyTrack
|
||||
} from '#lib/map-finder/types.js';
|
||||
import {
|
||||
cleanTitle,
|
||||
normalize,
|
||||
@@ -11,6 +17,12 @@ const token = createTokenCache();
|
||||
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
|
||||
const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
|
||||
export const MATCH_CONCURRENCY = 3;
|
||||
/** Optional persistent cache (music.osu_match_cache) behind the in-memory one. */
|
||||
export type MatchStore = {
|
||||
get(key: string): Promise<ScoredBeatmapset[] | null>;
|
||||
set(key: string, result: ScoredBeatmapset[]): Promise<void>;
|
||||
};
|
||||
export const osuConfigured = () => !!(OSU_CLIENT_ID && OSU_CLIENT_SECRET);
|
||||
// Global permits also bound outbound work across simultaneous visitors.
|
||||
let active = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
@@ -25,10 +37,72 @@ async function limited<T>(work: () => Promise<T>): Promise<T> {
|
||||
else active--;
|
||||
}
|
||||
}
|
||||
const MODES = new Set<OsuMode>(['osu', 'taiko', 'fruits', 'mania']);
|
||||
const str = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
||||
const num = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
const https = (value: unknown) => {
|
||||
const url = str(value);
|
||||
// The API sends previews protocol-relative (//b.ppy.sh/preview/1.mp3).
|
||||
if (url?.startsWith('//')) return `https:${url}`;
|
||||
return url?.startsWith('https://') ? url : undefined;
|
||||
};
|
||||
function difficulty(value: unknown): OsuDifficulty | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const d = value as Record<string, unknown>;
|
||||
const mode = str(d.mode) as OsuMode | undefined;
|
||||
if (!Number.isInteger(d.id) || !mode || !MODES.has(mode)) return null;
|
||||
return {
|
||||
id: d.id as number,
|
||||
version: str(d.version) ?? '',
|
||||
mode,
|
||||
stars: Math.round((num(d.difficulty_rating) ?? 0) * 100) / 100,
|
||||
length: num(d.total_length) ?? 0,
|
||||
bpm: num(d.bpm),
|
||||
status: str(d.status)
|
||||
};
|
||||
}
|
||||
/** Keep only the fields the finder shows (the search response is large). */
|
||||
export function parseBeatmapset(item: unknown): OsuBeatmapset | null {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const b = item as Record<string, unknown>;
|
||||
if (!Number.isInteger(b.id) || typeof b.title !== 'string' || typeof b.artist !== 'string')
|
||||
return null;
|
||||
const covers = (b.covers && typeof b.covers === 'object' ? b.covers : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
id: b.id as number,
|
||||
title: b.title,
|
||||
artist: b.artist,
|
||||
title_unicode: str(b.title_unicode),
|
||||
artist_unicode: str(b.artist_unicode),
|
||||
status: str(b.status),
|
||||
creator: str(b.creator),
|
||||
bpm: num(b.bpm),
|
||||
play_count: num(b.play_count),
|
||||
favourite_count: num(b.favourite_count),
|
||||
preview_url: https(b.preview_url),
|
||||
ranked_date: str(b.ranked_date) ?? null,
|
||||
video: b.video === true,
|
||||
nsfw: b.nsfw === true,
|
||||
covers: {
|
||||
card: https(covers.card),
|
||||
'card@2x': https(covers['card@2x']),
|
||||
list: https(covers.list)
|
||||
},
|
||||
beatmaps: Array.isArray(b.beatmaps)
|
||||
? b.beatmaps
|
||||
.map(difficulty)
|
||||
.filter((d): d is OsuDifficulty => d !== null)
|
||||
.sort((x, y) => x.mode.localeCompare(y.mode) || x.stars - y.stars)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
async function search(fetchFn: Fetch, query: string): Promise<OsuBeatmapset[]> {
|
||||
const access = await token(() => {
|
||||
if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET)
|
||||
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
|
||||
if (!osuConfigured()) throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
|
||||
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
@@ -49,44 +123,45 @@ async function search(fetchFn: Fetch, query: string): Promise<OsuBeatmapset[]> {
|
||||
if (!Array.isArray(response?.beatmapsets))
|
||||
throw new ApiError('osu!', 502, 'osu! returned an invalid search response.');
|
||||
return response.beatmapsets
|
||||
.filter(
|
||||
(item): item is OsuBeatmapset =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
Number.isInteger((item as OsuBeatmapset).id) &&
|
||||
typeof (item as OsuBeatmapset).title === 'string' &&
|
||||
typeof (item as OsuBeatmapset).artist === 'string'
|
||||
)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
title_unicode: typeof item.title_unicode === 'string' ? item.title_unicode : undefined,
|
||||
artist_unicode: typeof item.artist_unicode === 'string' ? item.artist_unicode : undefined,
|
||||
status: typeof item.status === 'string' ? item.status : undefined,
|
||||
covers: { card: typeof item.covers?.card === 'string' ? item.covers.card : undefined },
|
||||
beatmaps: Array.isArray(item.beatmaps) ? item.beatmaps.map(() => ({ id: 0 })) : undefined
|
||||
}));
|
||||
.map(parseBeatmapset)
|
||||
.filter((item): item is OsuBeatmapset => item !== null);
|
||||
}
|
||||
export async function getOsuMaps(fetchFn: Fetch, track: SpotifyTrack): Promise<ScoredBeatmapset[]> {
|
||||
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
|
||||
const cacheError = (error: unknown) => {
|
||||
console.warn('osu! match cache unavailable:', (error as Error)?.message);
|
||||
return null;
|
||||
};
|
||||
export const matchCacheKey = (track: SpotifyTrack) =>
|
||||
JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
|
||||
export async function getOsuMaps(
|
||||
fetchFn: Fetch,
|
||||
track: SpotifyTrack,
|
||||
store?: MatchStore
|
||||
): Promise<ScoredBeatmapset[]> {
|
||||
const key = matchCacheKey(track);
|
||||
for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit.result;
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing;
|
||||
const work = limited(async () => {
|
||||
const maps = await search(fetchFn, `${track.artists[0].name} ${track.name}`);
|
||||
let ranked = rankCandidates(track, maps);
|
||||
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
|
||||
ranked = rankCandidates(track, [
|
||||
...maps,
|
||||
...(await search(fetchFn, cleanTitle(track.name) || track.name))
|
||||
]);
|
||||
const remember = (result: ScoredBeatmapset[]) => {
|
||||
if (cache.size >= 500) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, { expires: Date.now() + 5 * 60_000, result: ranked });
|
||||
return ranked;
|
||||
}).finally(() => pending.delete(key));
|
||||
cache.set(key, { expires: Date.now() + 5 * 60_000, result });
|
||||
return result;
|
||||
};
|
||||
const work = (async () => {
|
||||
const stored = await store?.get(key).catch(cacheError);
|
||||
if (stored) return remember(stored);
|
||||
const ranked = await limited(async () => {
|
||||
const title = cleanTitle(track.name) || track.name;
|
||||
const maps = await search(fetchFn, `${track.artists[0].name} ${title}`);
|
||||
let ranked = rankCandidates(track, maps);
|
||||
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
|
||||
ranked = rankCandidates(track, [...maps, ...(await search(fetchFn, title))]);
|
||||
return ranked;
|
||||
});
|
||||
await store?.set(key, ranked).catch(cacheError);
|
||||
return remember(ranked);
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ export function usableTrack(value: unknown): SpotifyTrack | null {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
artists,
|
||||
album: { name: typeof album.name === 'string' ? album.name : '', images }
|
||||
album: { name: typeof album.name === 'string' ? album.name : '', images },
|
||||
url: `https://open.spotify.com/track/${track.id}`
|
||||
};
|
||||
}
|
||||
export function playlistTracks(items: unknown[], seen = new Set<string>()): SpotifyTrack[] {
|
||||
@@ -55,19 +56,31 @@ export function playlistTracks(items: unknown[], seen = new Set<string>()): Spot
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function spotify(fetchFn: Fetch, path: string) {
|
||||
const access = await token(() => {
|
||||
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET)
|
||||
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
|
||||
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
||||
});
|
||||
});
|
||||
// Development-mode Spotify apps (2026) only return playlist contents to a user token, and
|
||||
// only for playlists that user owns or collaborates on: use Stan's connected account.
|
||||
async function userToken(): Promise<string | null> {
|
||||
try {
|
||||
const { spotifyUserToken } = await import('./music/user-token');
|
||||
return await spotifyUserToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function spotify(fetchFn: Fetch, path: string, user: string | null = null) {
|
||||
const access =
|
||||
user ??
|
||||
(await token(() => {
|
||||
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET)
|
||||
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
|
||||
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
||||
});
|
||||
}));
|
||||
return record(
|
||||
await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, {
|
||||
headers: { Authorization: `Bearer ${access}` }
|
||||
@@ -78,7 +91,8 @@ export async function getSpotifyMetadata(
|
||||
fetchFn: Fetch,
|
||||
input:
|
||||
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
|
||||
| Extract<SpotifyInput, { kind: 'search' }>
|
||||
| Extract<SpotifyInput, { kind: 'search' }>,
|
||||
options: { owner?: boolean } = {}
|
||||
): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> {
|
||||
if (input.kind === 'track' || input.kind === 'search') {
|
||||
let response: RecordValue;
|
||||
@@ -137,7 +151,14 @@ export async function getSpotifyMetadata(
|
||||
: undefined
|
||||
};
|
||||
// Extended-quota responses use tracks/track; newer development-mode responses use items/item.
|
||||
const modern = metadata.items !== undefined;
|
||||
let user: string | null = null;
|
||||
if (!isAlbum && metadata.items === undefined && metadata.tracks === undefined) {
|
||||
if (metadata.public === false && !options.owner)
|
||||
throw new ApiError('Spotify', 403, 'Private playlist.');
|
||||
user = await userToken();
|
||||
if (!user) throw new ApiError('Spotify', 403, 'Playlist contents need a user token.');
|
||||
}
|
||||
const modern = metadata.items !== undefined || user !== null;
|
||||
let page = record(modern ? metadata.items : metadata.tracks);
|
||||
let offset = 0;
|
||||
const tracks: SpotifyTrack[] = [];
|
||||
@@ -146,7 +167,8 @@ export async function getSpotifyMetadata(
|
||||
if (!Array.isArray(page.items))
|
||||
page = await spotify(
|
||||
fetchFn,
|
||||
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`
|
||||
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`,
|
||||
user
|
||||
);
|
||||
if (!Array.isArray(page.items))
|
||||
throw new ApiError(
|
||||
@@ -181,7 +203,7 @@ export async function getSpotifyMetadata(
|
||||
error.status,
|
||||
input.kind === 'album'
|
||||
? 'Spotify album unavailable or not found. Try an individual track.'
|
||||
: 'Spotify playlist unavailable or private, or this application does not have playlist access. Try a public playlist or an individual track.'
|
||||
: "Spotify only lets this site read playlists that Stan owns (Spotify's 2026 API limits for small apps). Try a track or album link, or one of the quick lists below."
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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