import { HttpError } from "@sttracker/plugin-sdk/http"; import type { ListenFields, PluginContext, SourceRecord } from "@sttracker/plugin-sdk/types"; import { sleep } from "./options.ts"; export const BASE = "https://ws.audioscrobbler.com/2.0/"; export const MAX_ATTEMPTS = 3; export class LastfmError extends Error { readonly code: number; constructor(code: number, message: string) { super(`Last.fm error ${code}: ${message}`); this.name = "LastfmError"; this.code = code; } } type Q = Record; interface Text { "#text"?: string; mbid?: string; name?: string } interface LfmTrack { name?: string; mbid?: string; artist?: Text; album?: Text; date?: { uts?: string }; "@attr"?: { nowplaying?: string }; } function errorOf(body: unknown): { code: number; message: string } | null { if (body && typeof body === "object" && "error" in body && typeof (body as { error: unknown }).error === "number") { const b = body as { error: number; message?: string }; return { code: b.error, message: b.message ?? "unknown error" }; } return null; } /** Last.fm reports errors in the JSON body (HTTP 200 or 4xx); error 29 (rate limit) is retried here. */ export async function call(ctx: PluginContext, query: Q): Promise { const apiKey = (await ctx.credentials.get())?.apiKey ?? String(ctx.config.apiKey ?? ""); for (let attempt = 1; ; attempt++) { ctx.signal.throwIfAborted(); let body: unknown; try { body = await ctx.http.json(BASE, { query: { ...query, api_key: apiKey, format: "json" } }); } catch (e) { if (!(e instanceof HttpError)) throw e; let parsed: unknown; try { parsed = JSON.parse(e.body); } catch { throw e; } const err = errorOf(parsed); if (!err) throw e; body = parsed; } const err = errorOf(body); if (!err) return body as T; if (err.code === 29 && attempt < MAX_ATTEMPTS) { await sleep(2 ** attempt * 1000); continue; } throw new LastfmError(err.code, err.message); } } const arr = (v: T | T[] | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]); const nonEmpty = (s: string | undefined): string | undefined => (s && s.trim() !== "" ? s : undefined); function toListen(t: LfmTrack): SourceRecord | null { const uts = t.date?.uts; const artist = nonEmpty(t.artist?.["#text"] ?? t.artist?.name); const track = nonEmpty(t.name); if (!uts || !artist || !track) return null; const listen: ListenFields = { listenedAt: new Date(Number(uts) * 1000).toISOString(), artist, track }; const album = nonEmpty(t.album?.["#text"]); if (album) listen.album = album; const mbids: NonNullable = {}; const rec = nonEmpty(t.mbid), rel = nonEmpty(t.album?.mbid), art = nonEmpty(t.artist?.mbid); if (rec) mbids.recording = rec; if (rel) mbids.release = rel; if (art) mbids.artist = art; if (Object.keys(mbids).length > 0) listen.mbids = mbids; return { kind: "listen", key: `${uts}:${artist}:${track}`, raw: t, listen }; } async function* recent(ctx: PluginContext, from: number | undefined): AsyncGenerator { const user = String(ctx.config.username ?? ""); let max = from ?? 0; for (let page = 1, total = 1; page <= total; page++) { const res = await call<{ recenttracks?: { track?: LfmTrack | LfmTrack[]; "@attr"?: { totalPages?: string } } }>(ctx, { method: "user.getRecentTracks", user, limit: 200, page, from, }); total = Number(res.recenttracks?.["@attr"]?.totalPages ?? 1) || 1; for (const t of arr(res.recenttracks?.track)) { if (t["@attr"]?.nowplaying === "true") continue; const rec = toListen(t); if (!rec) continue; max = Math.max(max, Number(t.date?.uts)); yield rec; } } if (max > 0) await ctx.cursor.set(String(max)); } export async function* pull(ctx: PluginContext): AsyncGenerator { yield* recent(ctx, undefined); yield* loved(ctx); } export async function* pullChanges(ctx: PluginContext): AsyncGenerator { const c = await ctx.cursor.get(); yield* recent(ctx, c ? Number(c) : undefined); } async function* loved(ctx: PluginContext): AsyncGenerator { const user = String(ctx.config.username ?? ""); for (let page = 1, total = 1; page <= total; page++) { const res = await call<{ lovedtracks?: { track?: LfmTrack | LfmTrack[]; "@attr"?: { totalPages?: string } } }>(ctx, { method: "user.getLovedTracks", user, limit: 200, page, }); total = Number(res.lovedtracks?.["@attr"]?.totalPages ?? 1) || 1; for (const t of arr(res.lovedtracks?.track)) { const artist = nonEmpty(t.artist?.name ?? t.artist?.["#text"]); const title = nonEmpty(t.name); if (!artist || !title) continue; const mbid = nonEmpty(t.mbid); yield { kind: "entry", key: `loved:${artist}:${title}`, raw: t, work: { title, mediaType: "track", creators: [{ name: artist, role: "artist" }], externalIds: mbid ? { musicbrainz_recording: mbid } : {}, }, entry: { favorite: true }, }; } } }