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:
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: '' }
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user