feat(plugins): listenbrainz and lastfm

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 15:49:31 +01:00
co-authored by Claude Sonnet 5.5
parent 06a59e1b5c
commit 3eec2f2184
8 changed files with 545 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import { definePlugin } from "@sttracker/plugin-sdk/types";
import { pull, pullChanges } from "./src/lastfm.ts";
export default definePlugin({
manifest: {
id: "lastfm",
name: "Last.fm",
version: "0.1.0",
sdkVersion: 1,
mediaTypes: ["track", "album", "artist"],
capabilities: ["pull", "pullChanges"],
config: [
{ key: "username", label: "Username", type: "string", required: true },
{ key: "apiKey", label: "API key", type: "secret", required: true },
],
},
pull,
pullChanges,
});
+137
View File
@@ -0,0 +1,137 @@
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<string, string | number | undefined>;
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<T>(ctx: PluginContext, query: Q): Promise<T> {
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<unknown>(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 = <T>(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<ListenFields["mbids"]> = {};
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<SourceRecord> {
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<SourceRecord> {
yield* recent(ctx, undefined);
yield* loved(ctx);
}
export async function* pullChanges(ctx: PluginContext): AsyncGenerator<SourceRecord> {
const c = await ctx.cursor.get();
yield* recent(ctx, c ? Number(c) : undefined);
}
async function* loved(ctx: PluginContext): AsyncGenerator<SourceRecord> {
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 },
};
}
}
}
+8
View File
@@ -0,0 +1,8 @@
/** Injectable sleep. Tests replace it with an immediate one via `setSleep`. */
let sleepImpl: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms);
export function setSleep(fn: ((ms: number) => Promise<void>) | null): void {
sleepImpl = fn ?? ((ms) => Bun.sleep(ms));
}
export const sleep = (ms: number): Promise<void> => sleepImpl(ms);
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { collect, createFixtureHttp, createTestContext } from "@sttracker/plugin-sdk/testing";
import plugin from "../index.ts";
import { setSleep } from "../src/options.ts";
const sleeps: number[] = [];
beforeEach(() => { sleeps.length = 0; setSleep(async (ms) => { sleeps.push(ms); }); });
afterEach(() => setSleep(null));
const URL0 = "GET https://ws.audioscrobbler.com/2.0/";
const rt = (name: string, uts: string, mbid = "", extra = {}) => ({
name, mbid, artist: { "#text": "Art", mbid: "" }, album: { "#text": "Alb", mbid: "" }, date: { uts }, ...extra,
});
function routes(handler: (q: URLSearchParams) => unknown) {
return { [URL0]: (req: Request) => handler(new URL(req.url).searchParams) };
}
test("skips nowplaying, omits empty mbids, includes loved entries", async () => {
const f = createFixtureHttp(routes((q) => {
expect(q.get("api_key")).toBe("key");
expect(q.get("format")).toBe("json");
if (q.get("method") === "user.getLovedTracks")
return { lovedtracks: { track: [{ name: "Fav", mbid: "m9", artist: { name: "Art" } }], "@attr": { totalPages: "1" } } };
return { recenttracks: { track: [
{ name: "Now", artist: { "#text": "Art" }, "@attr": { nowplaying: "true" } },
rt("One", "1700000000", "rec1", { artist: { "#text": "Art", mbid: "a1" } }),
rt("Two", "1699999999"),
], "@attr": { totalPages: "1" } } };
}));
const ctx = createTestContext({ fetch: f.fetch, config: { username: "u", apiKey: "key" } });
const recs = await collect(plugin.pull!(ctx));
expect(recs.map((r) => r.key)).toEqual(["1700000000:Art:One", "1699999999:Art:Two", "loved:Art:Fav"]);
const one = recs[0]!, two = recs[1]!, fav = recs[2]!;
expect(one.kind === "listen" && one.listen.mbids).toEqual({ recording: "rec1", artist: "a1" });
expect(two.kind === "listen" && two.listen.mbids).toBeUndefined();
expect(fav.kind === "entry" && fav.entry.favorite).toBe(true);
expect(fav.kind === "entry" && fav.work.externalIds).toEqual({ musicbrainz_recording: "m9" });
expect(ctx.saved.cursor).toEqual(["1700000000"]);
});
test("paginates recent tracks", async () => {
const pagesSeen: (string | null)[] = [];
const f = createFixtureHttp(routes((q) => {
if (q.get("method") === "user.getLovedTracks") return { lovedtracks: { track: [], "@attr": { totalPages: "0" } } };
pagesSeen.push(q.get("page"));
return { recenttracks: { track: [rt(`T${q.get("page")}`, `10${q.get("page")}`)], "@attr": { totalPages: "2" } } };
}));
const recs = await collect(plugin.pull!(createTestContext({ fetch: f.fetch, config: { username: "u", apiKey: "k" } })));
expect(pagesSeen).toEqual(["1", "2"]);
expect(recs).toHaveLength(2);
});
test("error 29 is retried with exponential sleeps (body on HTTP 200 and 403)", async () => {
let n = 0;
const f = createFixtureHttp(routes((q) => {
if (q.get("method") === "user.getLovedTracks") return { lovedtracks: { track: [] } };
n++;
if (n === 1) return { error: 29, message: "Rate Limit Exceeded" };
if (n === 2) return new Response(JSON.stringify({ error: 29, message: "Rate Limit Exceeded" }), { status: 403 });
return { recenttracks: { track: rt("X", "5") } };
}));
const recs = await collect(plugin.pull!(createTestContext({ fetch: f.fetch, config: { username: "u", apiKey: "k" } })));
expect(n).toBe(3);
expect(sleeps).toEqual([2000, 4000]);
expect(recs).toHaveLength(1);
});
test("error 29 gives up after 3 attempts; other codes throw immediately", async () => {
const f29 = createFixtureHttp(routes(() => ({ error: 29, message: "Rate Limit Exceeded" })));
const p = collect(plugin.pull!(createTestContext({ fetch: f29.fetch, config: { username: "u", apiKey: "k" } })));
await expect(p).rejects.toMatchObject({ code: 29 });
expect(f29.calls).toHaveLength(3);
const f6 = createFixtureHttp(routes(() => new Response(JSON.stringify({ error: 6, message: "User not found" }), { status: 404 })));
const p6 = collect(plugin.pull!(createTestContext({ fetch: f6.fetch, config: { username: "u", apiKey: "k" } })));
await expect(p6).rejects.toThrow(/Last\.fm error 6: User not found/);
expect(f6.calls).toHaveLength(1);
});
test("pullChanges sends from=<cursor> and advances it", async () => {
let from: string | null = null;
const f = createFixtureHttp(routes((q) => {
from = q.get("from");
return { recenttracks: { track: [rt("N", "2000")], "@attr": { totalPages: "1" } } };
}));
const ctx = createTestContext({ fetch: f.fetch, config: { username: "u", apiKey: "k" } });
await ctx.cursor.set("1500");
await collect(plugin.pullChanges!(ctx));
expect(from as string | null).toBe("1500");
expect(await ctx.cursor.get()).toBe("2000");
});