This commit is contained in:
23
src/lib/map-finder/Artwork.svelte
Normal file
23
src/lib/map-finder/Artwork.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { src, class: className = '' }: { src?: string; class?: string } = $props();
|
||||
let failed = $state(false);
|
||||
$effect(() => {
|
||||
void src;
|
||||
failed = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`overflow-hidden rounded bg-gray-200 ${className}`}>
|
||||
{#if src?.startsWith('https://') && !failed}<img
|
||||
{src}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={() => (failed = true)}
|
||||
/>{:else}<div
|
||||
class="flex h-full items-center justify-center text-2xl text-gray-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
♫
|
||||
</div>{/if}
|
||||
</div>
|
||||
26
src/lib/map-finder/MapCard.svelte
Normal file
26
src/lib/map-finder/MapCard.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { ScoredBeatmapset } from './types';
|
||||
import Artwork from './Artwork.svelte';
|
||||
let { map }: { map: ScoredBeatmapset } = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block h-full overflow-hidden rounded border border-gray-300 hover:border-blue-600 focus-visible:outline-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<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-gray-600">{map.score}/100 · {map.confidence} confidence</p>
|
||||
<p class="text-xs text-gray-600">{map.reasons.join(' · ')}</p>
|
||||
{#if map.status || map.beatmaps}<p class="text-xs text-gray-600">
|
||||
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps
|
||||
? `${map.beatmaps.length} difficulties`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
</div>
|
||||
</a>
|
||||
90
src/lib/map-finder/finder.test.ts
Normal file
90
src/lib/map-finder/finder.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSpotifyInput } from './input';
|
||||
import { normalize, cleanTitle, scoreBeatmapset, rankCandidates, MAX_CANDIDATES } from './matching';
|
||||
import type { SpotifyTrack } from './types';
|
||||
const id = '6PUzxtIHkv346yP89NzP9X';
|
||||
const track: SpotifyTrack = {
|
||||
id,
|
||||
name: 'Song Title',
|
||||
artists: [{ name: 'Beyoncé & Friends' }],
|
||||
album: { name: 'Album', images: [] }
|
||||
};
|
||||
const map = { id: 1, title: 'Song Title', artist: 'Beyonce and Friends' };
|
||||
describe('Spotify input', () => {
|
||||
it.each([
|
||||
`https://open.spotify.com/track/${id}`,
|
||||
` https://open.spotify.com/track/${id}?si=abc `,
|
||||
`http://www.open.spotify.com/track/${id}/`,
|
||||
`https://open.spotify.com/intl-nl/track/${id}/?si=abc`,
|
||||
`spotify:track:${id}`
|
||||
])('parses %s', (value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'track', id }));
|
||||
it.each([`spotify:playlist:${id}`, `https://open.spotify.com/playlist/${id}?si=abc`])(
|
||||
'parses playlist %s',
|
||||
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'playlist', id })
|
||||
);
|
||||
it.each(['artist', 'episode', 'show'])('rejects unsupported %s', (resource) =>
|
||||
expect(parseSpotifyInput(`https://open.spotify.com/${resource}/${id}`).kind).toBe('unsupported')
|
||||
);
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'spotify:track:bad',
|
||||
'https://open.spotify.com/track/',
|
||||
'https://open.spotify.com/track/bad',
|
||||
'x'.repeat(501)
|
||||
])('validates %s', (value) => expect(() => parseSpotifyInput(value)).toThrow());
|
||||
it('preserves plain search and rejects lookalike host recognition', () => {
|
||||
expect(parseSpotifyInput(' Deftones Change ')).toEqual({
|
||||
kind: 'search',
|
||||
query: 'Deftones Change'
|
||||
});
|
||||
expect(parseSpotifyInput(`https://open.spotify.com.evil.test/track/${id}`).kind).toBe('search');
|
||||
});
|
||||
});
|
||||
describe('matching', () => {
|
||||
it.each([
|
||||
'Song Title',
|
||||
'Song Title (Remastered 2011)',
|
||||
'Song Title - Remaster',
|
||||
'Song Title (feat. Someone)',
|
||||
'Song Title [Radio Edit]',
|
||||
'Song Title - Album Version'
|
||||
])('cleans %s', (value) => expect(cleanTitle(value)).toBe('song title'));
|
||||
it('normalizes punctuation, diacritics, ampersands and whitespace', () =>
|
||||
expect(normalize(' Beyoncé & Friends! ')).toBe('beyonce and friends'));
|
||||
it('keeps non-Latin text', () => expect(normalize('世界の歌')).toBe('世界の歌'));
|
||||
it('scores exact and unicode aliases highly', () => {
|
||||
expect(scoreBeatmapset(track, map).score).toBe(100);
|
||||
expect(
|
||||
scoreBeatmapset(
|
||||
{ ...track, name: '世界の歌' },
|
||||
{ ...map, title: 'Sekai no uta', title_unicode: '世界の歌' }
|
||||
).confidence
|
||||
).toBe('high');
|
||||
});
|
||||
it('does not confidently match the wrong artist', () =>
|
||||
expect(scoreBeatmapset(track, { ...map, artist: 'Unrelated Singer' }).confidence).toBe('low'));
|
||||
it('penalizes remix mismatch', () =>
|
||||
expect(scoreBeatmapset(track, { ...map, title: 'Song Title (Club Remix)' }).confidence).toBe(
|
||||
'low'
|
||||
));
|
||||
it('sorts, merges duplicate IDs and limits results', () => {
|
||||
const candidates = rankCandidates(track, [
|
||||
{ ...map, title: 'Song Title remix' },
|
||||
...Array.from({ length: 10 }, (_, id) => ({ ...map, id }))
|
||||
]);
|
||||
expect(candidates).toHaveLength(MAX_CANDIDATES);
|
||||
expect(new Set(candidates.map((m) => m.id)).size).toBe(MAX_CANDIDATES);
|
||||
expect(candidates[0].score).toBe(100);
|
||||
expect(candidates.every((m, i) => !i || candidates[i - 1].score >= m.score)).toBe(true);
|
||||
});
|
||||
it('discards unrelated candidates', () =>
|
||||
expect(
|
||||
rankCandidates(track, [{ ...map, title: 'Nothing Alike', artist: 'Different' }])
|
||||
).toEqual([]));
|
||||
});
|
||||
|
||||
it.each([`spotify:album:${id}`, `https://www.open.spotify.com/intl-nl/album/${id}/?si=abc`])(
|
||||
'parses album %s',
|
||||
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'album', id })
|
||||
);
|
||||
44
src/lib/map-finder/input.ts
Normal file
44
src/lib/map-finder/input.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type SpotifyInput =
|
||||
| { kind: 'track'; id: string }
|
||||
| { kind: 'playlist'; id: string }
|
||||
| { kind: 'album'; id: string }
|
||||
| { kind: 'search'; query: string }
|
||||
| { kind: 'unsupported'; resource: string };
|
||||
export function parseSpotifyInput(value: string): SpotifyInput {
|
||||
const query = value.trim();
|
||||
if (!query || query.length > 500)
|
||||
throw new Error('Enter a Spotify URL, URI, or song search (up to 500 characters).');
|
||||
let resource: string | undefined;
|
||||
let id: string | undefined;
|
||||
if (/^spotify:/i.test(query)) {
|
||||
const match = /^spotify:([^:]+):([^:]+)$/i.exec(query);
|
||||
if (!match)
|
||||
throw new Error(
|
||||
'Invalid Spotify URI. Use spotify:track:ID or spotify:playlist:ID or spotify:album:ID.'
|
||||
);
|
||||
[, resource, id] = match;
|
||||
} else {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(query);
|
||||
} catch {
|
||||
if (/^(?:https?:\/\/)?(?:www\.)?open\.spotify\.com\b/i.test(query))
|
||||
throw new Error('Invalid Spotify URL. Include https://open.spotify.com/.');
|
||||
return { kind: 'search', query };
|
||||
}
|
||||
if (!/^(www\.)?open\.spotify\.com$/i.test(url.hostname)) return { kind: 'search', query };
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.port)
|
||||
throw new Error('Invalid Spotify URL.');
|
||||
const match = /^\/(?:intl-[a-z-]+\/)?([^/]+)(?:\/([^/]+))?\/?$/i.exec(url.pathname);
|
||||
if (!match) throw new Error('Invalid Spotify URL path.');
|
||||
[, resource, id] = match;
|
||||
}
|
||||
resource = resource.toLowerCase();
|
||||
if (resource !== 'track' && resource !== 'playlist' && resource !== 'album')
|
||||
return { kind: 'unsupported', resource };
|
||||
if (!id || !/^[a-zA-Z0-9]{22}$/.test(id))
|
||||
throw new Error(
|
||||
'Invalid Spotify ID. Track, playlist, and album IDs must contain 22 letters or digits.'
|
||||
);
|
||||
return { kind: resource, id };
|
||||
}
|
||||
87
src/lib/map-finder/matching.ts
Normal file
87
src/lib/map-finder/matching.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from './types';
|
||||
// Medium requires a strong title plus artist evidence. High is reserved for near-exact pairs.
|
||||
export const HIGH_CONFIDENCE = 88;
|
||||
export const MEDIUM_CONFIDENCE = 72;
|
||||
export const MIN_CANDIDATE_SCORE = 40;
|
||||
export const MAX_CANDIDATES = 6;
|
||||
export function normalize(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
export function cleanTitle(value: string): string {
|
||||
return normalize(
|
||||
value
|
||||
.replace(
|
||||
/\s*[([](?:feat\.?|ft\.?|featuring|[^)\]]*(?:remaster|radio edit|album version|soundtrack|from the))[^)\]]*[)\]]/gi,
|
||||
''
|
||||
)
|
||||
.replace(/\s+(?:feat\.?|ft\.?|featuring)\s+.*$/i, '')
|
||||
.replace(/\s*[-–—]\s*(?:\d{4}\s*)?(?:remaster\w*|radio edit|album version|from\s+).*$/i, '')
|
||||
);
|
||||
}
|
||||
function similarity(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
if (a === b) return 1;
|
||||
const left = new Set(a.split(' ')),
|
||||
right = new Set(b.split(' '));
|
||||
const overlap = [...left].filter((token) => right.has(token)).length;
|
||||
return Math.max(
|
||||
(2 * overlap) / (left.size + right.size),
|
||||
a.length >= 5 && b.length >= 5 && (a.includes(b) || b.includes(a)) ? 0.8 : 0
|
||||
);
|
||||
}
|
||||
function versions(value: string): string {
|
||||
const normalized = normalize(value);
|
||||
return ['remix', 'live', 'acoustic', 'instrumental', 'sped up', 'nightcore']
|
||||
.filter((word) => normalized.includes(word))
|
||||
.join(' ');
|
||||
}
|
||||
export function scoreBeatmapset(track: SpotifyTrack, map: OsuBeatmapset): ScoredBeatmapset {
|
||||
const title = Math.max(
|
||||
...[map.title, map.title_unicode ?? ''].map((v) =>
|
||||
similarity(cleanTitle(track.name), cleanTitle(v))
|
||||
)
|
||||
);
|
||||
const artistNames = [
|
||||
...track.artists.map((a) => a.name),
|
||||
track.artists.map((a) => a.name).join(' and ')
|
||||
];
|
||||
const artist = Math.max(
|
||||
0,
|
||||
...artistNames.flatMap((a) =>
|
||||
[map.artist, map.artist_unicode ?? ''].map((b) => similarity(normalize(a), normalize(b)))
|
||||
)
|
||||
);
|
||||
const mismatch = versions(track.name) !== versions(map.title + ' ' + (map.title_unicode ?? ''));
|
||||
let score = Math.round(title * 65 + artist * 35 - (mismatch ? 24 : 0));
|
||||
if (artist < 0.3) score = Math.min(score, MEDIUM_CONFIDENCE - 1);
|
||||
score = Math.max(0, score);
|
||||
const reasons = [
|
||||
title === 1 ? 'title exact' : 'title similar',
|
||||
artist === 1 ? 'artist exact' : artist >= 0.3 ? 'artist similar' : 'artist differs'
|
||||
];
|
||||
if (mismatch) reasons.push('version differs');
|
||||
return {
|
||||
...map,
|
||||
score,
|
||||
confidence: score >= HIGH_CONFIDENCE ? 'high' : score >= MEDIUM_CONFIDENCE ? 'medium' : 'low',
|
||||
reasons
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
34
src/lib/map-finder/types.ts
Normal file
34
src/lib/map-finder/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type SpotifyTrack = {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: { name: string }[];
|
||||
album: { name: string; images: { url: string }[] };
|
||||
};
|
||||
export type SpotifyCollection = {
|
||||
kind: 'playlist' | 'album';
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
image?: string;
|
||||
};
|
||||
export type OsuBeatmapset = {
|
||||
id: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
artist_unicode?: string;
|
||||
title_unicode?: string;
|
||||
covers?: { card?: string; 'card@2x'?: string };
|
||||
status?: string;
|
||||
beatmaps?: { id: number }[];
|
||||
};
|
||||
export type ScoredBeatmapset = OsuBeatmapset & {
|
||||
score: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
reasons: string[];
|
||||
};
|
||||
export type TrackMatchResult = {
|
||||
track: SpotifyTrack;
|
||||
matches: ScoredBeatmapset[];
|
||||
status: 'matched' | 'no_match' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user