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

This commit is contained in:
2026-10-24 16:15:56 +00:00
parent 0aa14cebf3
commit 46703f1a6c
21 changed files with 21050 additions and 178 deletions

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

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