138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
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 },
|
|
};
|
|
}
|
|
}
|
|
}
|