Files
pl8list-rewind/web/src/api.ts
T
nvrl b42780d24b Timed comments on the waveform
Each comment sits as a dot under the moment it was left at. When the music
gets there the dot pings and a bubble grows out of it on a spark, then drifts
up and fades; a mouse over a dot peeks at it. The server fetches the newest
60 comments from pl8list on demand and keeps them for 15 minutes. A setting
turns them off.
2026-11-02 23:18:44 +01:00

168 lines
5.4 KiB
TypeScript

// Calls to our own server. The browser never talks to pl8list directly.
export interface Track {
id: number;
handle: string;
slug: string;
title: string;
description: string | null;
artist_name: string;
artist_img: string | null;
artist_verified: boolean;
bpm: number | null;
key: string | null;
duration: number | null;
price: number;
created_at: string | null;
saves: number;
downloads: number;
purchases: number;
comments: number;
preview: boolean;
upload_id: string | null;
waveform: number[] | null;
followed?: boolean;
/// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[];
/// In an artist radio: why this track is in it.
why?: Why;
}
/// `seed`: by the radio's artist; `related`: by an artist next to them;
/// `via`: one step further, through the artist `handle`.
export type Why = { kind: "seed" } | { kind: "related" } | { kind: "via"; handle: string; name: string };
export interface Artist {
handle: string;
name: string;
img: string | null;
verified: boolean;
tracks: number;
}
/// An artist and their neighbours in the artist graph, for the radio sheet.
export interface ArtistInfo {
artist: Artist;
neighbours: { handle: string; name: string; img: string | null; weight: number }[];
tags: string[];
followed: boolean;
/// Still crawling for neighbours; ask again in a moment.
exploring: boolean;
}
export interface Stats {
tracks: number;
artists: number;
artists_crawled: number;
served: number;
liked: number;
}
export interface User {
handle: string;
name: string | null;
/// Listening without a pl8list account.
guest?: boolean;
}
export interface Pl8Status {
connected: boolean;
synced_at: number | null;
}
export interface Me {
user: User;
pl8list: Pl8Status;
}
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
/// Which part of the catalogue the feed draws from. `artist` is an artist radio:
/// that artist and the ones pl8list relates to them.
export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" }
| { mode: "artist"; artist: string; name: string; img?: string | null };
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike" | "undislike";
export class HttpError extends Error {
constructor(message: string, readonly status: number) {
super(message);
}
}
async function parse<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(body.error || `request failed (${res.status})`, res.status);
return body as T;
}
export async function getJSON<T>(url: string): Promise<T> {
return parse<T>(await fetch(url));
}
export async function postJSON<T = { ok: boolean }>(url: string, data?: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data ?? {}),
});
return parse<T>(res);
}
/// Fire and forget; survives the page closing.
export function sendEvent(track: Track, kind: EventKind, seconds?: number): void {
const body = JSON.stringify({ track_id: track.id, kind, seconds });
if (!navigator.sendBeacon("/local/event", new Blob([body], { type: "application/json" }))) {
fetch("/local/event", { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true });
}
}
export function feed(filters: Filters, mode: Mode) {
const p = new URLSearchParams({ n: "10", ...filters });
if (mode.mode !== "classic") p.set("mode", mode.mode);
if (mode.mode === "artist") p.set("artist", mode.artist);
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
}
export const artist = (handle: string) => getJSON<ArtistInfo>("/local/artist?" + new URLSearchParams({ handle }));
export const track = (handle: string, slug: string) =>
getJSON<{ track: Track }>("/local/track?" + new URLSearchParams({ handle, slug }));
/// A comment left at a moment of the track (`at`, seconds in).
export interface Comment {
id: number;
at: number;
body: string;
handle: string;
name: string;
img: string | null;
verified: boolean;
}
export const comments = (trackId: number) =>
getJSON<{ comments: Comment[] }>("/local/comments?" + new URLSearchParams({ id: String(trackId) }));
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
export const me = () => getJSON<Me>("/local/me");
export const PL8 = "https://pl8list.com";
export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
/// A link that opens this app on the track.
export const appTrackUrl = (t: Track) => `${location.origin}/?t=${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}`;
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
/// A link that opens this app on an artist's radio sheet.
export const appRadioUrl = (handle: string) => `${location.origin}/?r=${encodeURIComponent(handle)}`;
// Images come through our server, so pl8list never sees the visitor's address.
export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null);
export const artistImg = (t: Track) => imgUrl(t.artist_img);
export function streamUrl(t: Track): string {
const p = new URLSearchParams();
if (t.preview) p.set("preview", "true");
if (t.upload_id) p.set("v", t.upload_id);
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`;
}