beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

View 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>

View 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>

View 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 })
);

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

View 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);
}

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