a minimal CLI toolkit for working with last.fm scrobbles
0

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

Lívia (May 17, 2026, 9:41 AM -0300) eee79fc8

+1042
+3
.gitmodules
··· 1 + [submodule "third-party/lotus"] 2 + path = third-party/lotus 3 + url = https://github.com/katelyynn/lotus
+10
LICENSE
··· 1 + Copyright (c) 2025 adoravel 2 + 3 + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 + 5 + 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 + 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 + 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 + 9 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 +
+287
api/lastfm.ts
··· 1 + import { crypto } from "@std/crypto"; 2 + import { AuthError, Errors, type LastFmError, type NetworkError } from "~/lib/errors.ts"; 3 + import { Fail, Ok, Result as $Result } from "~/lib/result.ts"; 4 + 5 + type Result<T> = $Result<T, LastFmError | AuthError | NetworkError>; 6 + 7 + const BASE_URL = "https://ws.audioscrobbler.com/2.0/"; 8 + const USER_AGENT = "kyu.re/~scrobkit:v0.1.0"; 9 + 10 + interface RawSession { 11 + session: { key: string; name: string; subscriber: number }; 12 + } 13 + 14 + interface RawScrobbleResponse { 15 + scrobbles: { 16 + "@attr": { 17 + accepted: number; 18 + ignored: number; 19 + }; 20 + }; 21 + } 22 + 23 + interface RawTrack { 24 + artist: { "#text": string }; 25 + album: { "#text": string }; 26 + name: string; 27 + date?: { "#text": string; uts: string }; 28 + "@attr"?: { nowplaying: string }; 29 + } 30 + 31 + interface RawRecentTracks { 32 + recenttracks: { 33 + track: RawTrack[]; 34 + "@attr": { user: string; totalPages: string; page: string; total: string }; 35 + }; 36 + } 37 + 38 + export interface Session { 39 + key: string; 40 + name: string; 41 + } 42 + 43 + export type ScrobbleResult = RawScrobbleResponse["scrobbles"]["@attr"]; 44 + 45 + export interface RecentTrack { 46 + artist: string; 47 + album: string; 48 + title: string; 49 + date?: string; 50 + timestamp?: number; 51 + nowPlaying: boolean; 52 + } 53 + 54 + export interface RecentTracksPage { 55 + tracks: RecentTrack[]; 56 + page: number; 57 + totalPages: number; 58 + totalTracks: number; 59 + user: string; 60 + } 61 + 62 + export interface ScrobblePayload { 63 + artist: string; 64 + album?: string; 65 + title: string; 66 + timestamp: number; 67 + } 68 + 69 + async function md5(input: string): Promise<string> { 70 + const hash = await crypto.subtle.digest( 71 + "MD5", 72 + new TextEncoder().encode(input), 73 + ); 74 + return Array.from(new Uint8Array(hash)) 75 + .map((b) => b.toString(16).padStart(2, "0")) 76 + .join(""); 77 + } 78 + 79 + function asciiSort(a: string, b: string): number { 80 + return a < b ? -1 : a > b ? 1 : 0; 81 + } 82 + 83 + async function fm<T>( 84 + params: Record<string, string | number>, 85 + secret?: string, 86 + method: "GET" | "POST" = "GET", 87 + ): Promise<Result<T>> { 88 + const body: Record<string, string> = {}; 89 + 90 + for (const [k, v] of Object.entries(params)) { 91 + body[k] = String(v); 92 + } 93 + 94 + if (secret) { 95 + const toSign = Object.entries(body) 96 + .sort(([a], [b]) => asciiSort(a, b)) 97 + .map(([k, v]) => `${k}${v}`) 98 + .join(""); 99 + body.api_sig = await md5(toSign + secret); 100 + method = "POST"; 101 + } 102 + 103 + const url = new URL(BASE_URL); 104 + const init: RequestInit = { 105 + headers: { 106 + "User-Agent": USER_AGENT, 107 + }, 108 + }; 109 + 110 + body.format = "json"; 111 + if (method === "POST") { 112 + init.method = "POST"; 113 + init.body = new URLSearchParams(body).toString(); 114 + } else { 115 + (init.headers as any)["Content-Type"] = "application/x-www-form-urlencoded"; 116 + 117 + for (const [k, v] of Object.entries(body)) { 118 + url.searchParams.set(k, v); 119 + } 120 + } 121 + 122 + let response: Response; 123 + try { 124 + response = await fetch(url, init); 125 + } catch (e) { 126 + return Fail(Errors.network(e instanceof Error ? e.message : String(e))); 127 + } 128 + 129 + if (!response.ok) { 130 + if (response.status === 429) { 131 + return Fail(Errors.lastfm(29, "Rate limit exceeded")); 132 + } 133 + return Fail(Errors.network(`HTTP ${response.status}`, response.status)); 134 + } 135 + 136 + let json: any; 137 + try { 138 + json = await response.json(); 139 + } catch { 140 + return Fail(Errors.network("Invalid JSON response")); 141 + } 142 + 143 + if (typeof json.error === "number") { 144 + return Fail( 145 + Errors.lastfm( 146 + json.error as LastFmError["tag"], 147 + typeof json.message === "string" ? json.message : "Unknown error", 148 + ), 149 + ); 150 + } 151 + 152 + return Ok(json); 153 + } 154 + 155 + export async function authenticate( 156 + apiKey: string, 157 + secret: string, 158 + username: string, 159 + password: string, 160 + ): Promise<Result<Session>> { 161 + const result = await fm<RawSession>( 162 + { method: "auth.getMobileSession", api_key: apiKey, username, password }, 163 + secret, 164 + ); 165 + 166 + if (!result.ok) return result; 167 + return Ok({ key: result.value.session.key, name: result.value.session.name }); 168 + } 169 + 170 + export async function scrobble( 171 + apiKey: string, 172 + secret: string, 173 + sessionKey: string, 174 + tracks: ScrobblePayload | ScrobblePayload[], 175 + ): Promise<Result<ScrobbleResult>> { 176 + const body: Record<string, string | number> = { 177 + method: "track.scrobble", 178 + api_key: apiKey, 179 + sk: sessionKey, 180 + }; 181 + 182 + const inputs = Array.isArray(tracks) ? tracks : [tracks]; 183 + if (inputs.length > 50) { 184 + return Fail(Errors.lastfm(8, "tracks.length must be <=50")); 185 + } 186 + 187 + inputs.forEach((s, i) => { 188 + const suffix = inputs.length > 1 ? `[${i}]` : ""; 189 + 190 + body[`artist${suffix}`] = s.artist; 191 + body[`track${suffix}`] = s.title; 192 + body[`timestamp${suffix}`] = s.timestamp; 193 + if (s.album) { 194 + body[`album${suffix}`] = s.album; 195 + } 196 + }); 197 + 198 + const result = await fm<RawScrobbleResponse>(body, secret); 199 + if (!result.ok) return result; 200 + 201 + const attr = result.value.scrobbles["@attr"]; 202 + return Ok({ 203 + accepted: Number(attr.accepted), 204 + ignored: Number(attr.ignored), 205 + }); 206 + } 207 + 208 + export async function getRecentTracks( 209 + apiKey: string, 210 + username: string, 211 + page: number, 212 + options: { limit?: number; from?: number; to?: number } = {}, 213 + ): Promise<Result<RecentTracksPage>> { 214 + const params: Record<string, string | number> = { 215 + method: "user.getRecentTracks", 216 + api_key: apiKey, 217 + user: username, 218 + page, 219 + limit: options.limit ?? 200, 220 + }; 221 + 222 + if (options.from !== undefined) params.from = options.from; 223 + if (options.to !== undefined) params.to = options.to; 224 + 225 + const result = await fm<RawRecentTracks>(params); 226 + if (!result.ok) return result; 227 + 228 + const raw = result.value.recenttracks; 229 + const rawTracks = Array.isArray(raw.track) ? raw.track : [raw.track]; 230 + 231 + const tracks: RecentTrack[] = rawTracks 232 + .filter((t) => t.date || t["@attr"]?.nowplaying === "true") 233 + .map((t) => ({ 234 + artist: t.artist["#text"], 235 + album: t.album["#text"], 236 + title: t.name, 237 + date: t.date?.["#text"], 238 + timestamp: t.date?.uts ? Number(t.date.uts) : undefined, 239 + nowPlaying: t["@attr"]?.nowplaying === "true", 240 + })); 241 + 242 + return Ok({ 243 + tracks, 244 + page: Number(raw["@attr"].page), 245 + totalPages: Number(raw["@attr"].totalPages), 246 + totalTracks: Number(raw["@attr"].total), 247 + user: raw["@attr"].user, 248 + }); 249 + } 250 + 251 + export async function countScrobblesInRange( 252 + apiKey: string, 253 + username: string, 254 + from: number, 255 + to: number, 256 + ): Promise<Result<number>> { 257 + if (from > to) { 258 + return Fail(Errors.lastfm(8, "Invalid range: 'from' must be <= 'to'")); 259 + } 260 + 261 + // fetch a single track since we only care about the total attribute 262 + const result = await getRecentTracks(apiKey, username, 1, { limit: 1, from, to }); 263 + if (!result.ok) return result; 264 + return Ok(result.value.totalTracks); 265 + } 266 + 267 + export async function verifySession( 268 + apiKey: string, 269 + secret: string, 270 + sessionKey: string, 271 + ): Promise<Result<void>> { 272 + const result = await fm( 273 + { method: "user.getInfo", api_key: apiKey, sk: sessionKey }, 274 + secret, 275 + ); 276 + 277 + if (!result.ok) { 278 + if (result.error.kind === "lastfm" && result.error.tag === 9) { 279 + return Fail(Errors.auth("Session key is invalid or expired")); 280 + } 281 + return result; 282 + } 283 + 284 + return Ok(undefined); 285 + } 286 + 287 + export { type Result as LastFmResult };
+5
api/lotus.ts
··· 1 + export { default as combinedArtists } from "~/third-party/lotus/combined_artists.json" with { type: "json" }; 2 + export { default as redirects } from "~/third-party/lotus/redirects.json" with { type: "json" }; 3 + export { default as artist } from "~/third-party/lotus/artist.json" with { type: "json" }; 4 + export { default as albumTrack } from "~/third-party/lotus/album_track.json" with { type: "json" }; 5 + export { default as romanisedArtists } from "~/third-party/lotus/romanised_artists.json" with { type: "json" };
+23
deno.json
··· 1 + { 2 + "tasks": { 3 + "dev": "deno run --watch main.ts" 4 + }, 5 + "imports": { 6 + "@std/cli": "jsr:@std/cli@^1.0.28", 7 + "@std/crypto": "jsr:@std/crypto@^1.0.5", 8 + "@std/path": "jsr:@std/path@^1.1.4", 9 + "~/": "./" 10 + }, 11 + "fmt": { 12 + "useTabs": true, 13 + "semiColons": true, 14 + "lineWidth": 120, 15 + "exclude": ["./third-party"] 16 + }, 17 + "lint": { 18 + "rules": { 19 + "tags": ["recommended"], 20 + "exclude": ["no-explicit-any"] 21 + } 22 + } 23 + }
+49
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@std/assert@*": "1.0.19", 5 + "jsr:@std/assert@1.0.19": "1.0.19", 6 + "jsr:@std/cli@^1.0.28": "1.0.28", 7 + "jsr:@std/crypto@^1.0.5": "1.0.5", 8 + "jsr:@std/fmt@^1.0.9": "1.0.9", 9 + "jsr:@std/internal@^1.0.12": "1.0.12", 10 + "jsr:@std/path@^1.1.4": "1.1.4" 11 + }, 12 + "jsr": { 13 + "@std/assert@1.0.19": { 14 + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", 15 + "dependencies": [ 16 + "jsr:@std/internal" 17 + ] 18 + }, 19 + "@std/cli@1.0.28": { 20 + "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a", 21 + "dependencies": [ 22 + "jsr:@std/fmt", 23 + "jsr:@std/internal" 24 + ] 25 + }, 26 + "@std/crypto@1.0.5": { 27 + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" 28 + }, 29 + "@std/fmt@1.0.9": { 30 + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 31 + }, 32 + "@std/internal@1.0.12": { 33 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 34 + }, 35 + "@std/path@1.1.4": { 36 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 37 + "dependencies": [ 38 + "jsr:@std/internal" 39 + ] 40 + } 41 + }, 42 + "workspace": { 43 + "dependencies": [ 44 + "jsr:@std/cli@^1.0.28", 45 + "jsr:@std/crypto@^1.0.5", 46 + "jsr:@std/path@^1.1.4" 47 + ] 48 + } 49 + }
+98
lib/config.ts
··· 1 + import { join } from "@std/path"; 2 + import { Errors } from "../lib/errors.ts"; 3 + import { Fail, Ok, Result as $Result } from "~/lib/result.ts"; 4 + 5 + export interface Config { 6 + apiKey: string; 7 + secret: string; 8 + username?: string; 9 + password?: string; 10 + } 11 + 12 + function getConfigDir(): string { 13 + const env = Deno.env; 14 + 15 + if (Deno.build.os === "windows") { 16 + const appData = env.get("APPDATA"); 17 + if (appData) return appData; 18 + } else if (Deno.build.os === "darwin") { 19 + const home = env.get("HOME"); 20 + if (home) return join(home, "Library", "Preferences"); 21 + } else { 22 + const xdg = env.get("XDG_CONFIG_HOME"); 23 + if (xdg) return xdg; 24 + 25 + const home = env.get("HOME"); 26 + if (home) return join(home, ".config"); 27 + } 28 + 29 + throw new Error("Unable to determine config directory (missing environment variables)"); 30 + } 31 + 32 + const CONFIG_DIR = join(getConfigDir(), "scrobkit"); 33 + const CONFIG_PATH = join(CONFIG_DIR, "config.json"); 34 + 35 + type Result<T> = $Result<T, ReturnType<typeof Errors.config>>; 36 + 37 + export async function loadConfig(): Promise<Result<Config>> { 38 + try { 39 + const raw = await Deno.readTextFile(CONFIG_PATH); 40 + const json = JSON.parse(raw); 41 + 42 + if (!isValidConfig(json)) { 43 + return Fail(Errors.config("parse_failed", "Config is missing required fields (apiKey, secret)")); 44 + } 45 + return Ok(json); 46 + } catch (e) { 47 + if (e instanceof Deno.errors.NotFound) { 48 + return Fail(Errors.config("not_found", `No config found at ${CONFIG_PATH}`)); 49 + } 50 + if (e instanceof Deno.errors.PermissionDenied) { 51 + return Fail(Errors.config("permission_denied", `Lacking permissions to read ${CONFIG_PATH}`)); 52 + } 53 + return Fail(Errors.config("parse_failed", e instanceof Error ? e.message : String(e))); 54 + } 55 + } 56 + 57 + export async function saveConfig(config: Config): Promise<Result<void>> { 58 + try { 59 + await Deno.mkdir(CONFIG_DIR, { recursive: true }); 60 + await Deno.writeTextFile(CONFIG_PATH, JSON.stringify(config, null, "\t")); 61 + 62 + return Ok(undefined); 63 + } catch (e) { 64 + if (e instanceof Deno.errors.PermissionDenied) { 65 + return Fail(Errors.config("permission_denied", `Lacking permisssions to write to ${CONFIG_PATH}`)); 66 + } 67 + return Fail(Errors.config("write_failed", e instanceof Error ? e.message : String(e))); 68 + } 69 + } 70 + 71 + export async function updateConfig( 72 + patch: Partial<Config>, 73 + ): Promise<Result<Config>> { 74 + const existing = await loadConfig(); 75 + 76 + const base: Config = existing.ok ? existing.value : { apiKey: "", secret: "" }; 77 + const merged = { ...base, ...patch }; 78 + 79 + if (!isValidConfig(merged)) { 80 + return Fail(Errors.config("parse_failed", "Merged config is invalid")); 81 + } 82 + 83 + const saved = await saveConfig(merged); 84 + return saved.ok ? Ok(merged) : saved; 85 + } 86 + 87 + function isValidConfig(obj: unknown): obj is Config { 88 + return ( 89 + typeof obj === "object" && 90 + obj !== null && 91 + typeof (obj as Record<string, unknown>).apiKey === "string" && 92 + typeof (obj as Record<string, unknown>).secret === "string" && 93 + (obj as Config).apiKey.length > 0 && 94 + (obj as Config).secret.length > 0 95 + ); 96 + } 97 + 98 + export { type Result as ConfigResult };
+167
lib/csv.ts
··· 1 + import { Fail, Ok, Result as $Result } from "~/lib/result.ts"; 2 + import { Errors } from "~/lib/errors.ts"; 3 + 4 + export const SKIP_PREFIX = "#SKIP:"; 5 + 6 + const COLUMNS = ["artist", "album", "title", "date"] as const; 7 + 8 + export interface Track { 9 + artist: string; 10 + album: string; 11 + title: string; 12 + date: string; 13 + } 14 + 15 + export interface CsvDocument { 16 + path: string; 17 + rawLines: string[]; 18 + pending: Array<{ track: Track; lineIndex: number }>; 19 + skippedCount: number; 20 + } 21 + 22 + type Result<T> = $Result<T, ReturnType<typeof Errors.csv>>; 23 + 24 + export function loadCsvDocument(path: string): Result<CsvDocument> { 25 + let raw: string; 26 + try { 27 + raw = Deno.readTextFileSync(path); 28 + } catch (e) { 29 + if (e instanceof Deno.errors.NotFound) { 30 + return Fail(Errors.csv("not_found", `File '${path}' not found`, path)); 31 + } 32 + return Fail(Errors.csv("parse_failed", e instanceof Error ? e.message : String(e), path)); 33 + } 34 + 35 + const rawLines = raw.split("\n"); 36 + const pending: CsvDocument["pending"] = []; 37 + let skippedCount = 0; 38 + 39 + for (let i = 0; i < rawLines.length; i++) { 40 + const line = rawLines[i]; 41 + 42 + if (!line.trim()) { 43 + continue; 44 + } 45 + 46 + if (line.startsWith(SKIP_PREFIX)) { 47 + skippedCount++; 48 + continue; 49 + } 50 + 51 + const parsed = parse(line, i, path); 52 + if (!parsed.ok) return parsed; 53 + 54 + pending.push({ track: parsed.value, lineIndex: i }); 55 + } 56 + 57 + return Ok({ path, rawLines, pending, skippedCount }); 58 + } 59 + 60 + export function markSkipped( 61 + doc: CsvDocument, 62 + index: number, 63 + ): Result<CsvDocument> { 64 + const line = doc.rawLines[index]; 65 + 66 + if (line === undefined) { 67 + return Fail(Errors.csv("write_failed", `Line ${index} out of bounds`, doc.path)); 68 + } 69 + 70 + if (line.startsWith(SKIP_PREFIX)) { 71 + return Ok(doc); 72 + } 73 + 74 + const rawLines = [...doc.rawLines]; 75 + rawLines[index] = `${SKIP_PREFIX}${line}`; 76 + 77 + try { 78 + Deno.writeTextFileSync(doc.path, rawLines.join("\n")); 79 + } catch (e) { 80 + return Fail(Errors.csv("write_failed", e instanceof Error ? e.message : String(e), doc.path)); 81 + } 82 + 83 + return Ok({ ...doc, rawLines }); 84 + } 85 + 86 + export function saveCsvDocument(path: string, tracks: Track[]): Result<void> { 87 + const header = COLUMNS.join(","); 88 + const rows = tracks.map((t) => [escape(t.artist), escape(t.album), escape(t.title), escape(t.date)].join(",")); 89 + 90 + try { 91 + Deno.writeTextFileSync(path, [header, ...rows].join("\n") + "\n"); 92 + return Ok(undefined); 93 + } catch (e) { 94 + return Fail(Errors.csv("write_failed", e instanceof Error ? e.message : String(e), path)); 95 + } 96 + } 97 + 98 + function parse( 99 + line: string, 100 + index: number, 101 + path: string, 102 + ): Result<Track> { 103 + const fields = split(line); 104 + 105 + if (fields.length < 3) { 106 + return Fail( 107 + Errors.csv( 108 + "invalid_columns", 109 + `Line ${index + 1}: expected at least 3 columns (artist, album, title), got ${fields.length}`, 110 + path, 111 + ), 112 + ); 113 + } 114 + 115 + if (fields[0].toLowerCase() === "artist") { 116 + return Fail(Errors.csv("invalid_columns", "header row", path)); 117 + } 118 + 119 + return Ok({ 120 + artist: unescape(fields[0] ?? ""), 121 + album: unescape(fields[1] ?? ""), 122 + title: unescape(fields[2] ?? ""), 123 + date: unescape(fields[3] ?? ""), 124 + }); 125 + } 126 + 127 + function split(line: string): string[] { 128 + const fields: string[] = []; 129 + let current = ""; 130 + let inQuotes = false; 131 + 132 + for (let i = 0; i < line.length; i++) { 133 + const ch = line[i]; 134 + 135 + if (ch === '"') { 136 + if (inQuotes && line[i + 1] === '"') { 137 + current += '"'; 138 + i++; 139 + } else { 140 + inQuotes = !inQuotes; 141 + } 142 + } else if (ch === "," && !inQuotes) { 143 + fields.push(current); 144 + current = ""; 145 + } else { 146 + current += ch; 147 + } 148 + } 149 + 150 + return fields.push(current), fields; 151 + } 152 + 153 + function escape(value: string): string { 154 + if (value.includes(",") || value.includes('"') || value.includes("\n")) { 155 + return `"${value.replace(/"/g, '""')}"`; 156 + } 157 + return value; 158 + } 159 + 160 + function unescape(value: string): string { 161 + if (value.startsWith('"') && value.endsWith('"')) { 162 + return value.slice(1, -1).replace(/""/g, '"'); 163 + } 164 + return value; 165 + } 166 + 167 + export { type Result as CsvResult };
+105
lib/errors.ts
··· 1 + export type AppError = 2 + | LastFmError 3 + | MusicBrainzError 4 + | ConfigError 5 + | CsvError 6 + | AuthError 7 + | RateLimitError 8 + | NetworkError; 9 + 10 + export type LastFmErrorCode = 11 + | 2 // unavailable service 12 + | 3 // invalid method 13 + | 4 // authentication failed 14 + | 6 // invalid response format 15 + | 7 // invalid resource 16 + | 8 // generic error 17 + | 9 // invalid session key 18 + | 10 // invalid api key 19 + | 11 // service offline 20 + | 13 // invalid method signature 21 + | 16 // temporary error 22 + | 26 // suspended api key 23 + | 29; // rate limit exceeded 24 + 25 + interface BaseError<Kind> { 26 + readonly kind: Kind; 27 + } 28 + 29 + interface TaggedError<Kind, Code> extends BaseError<Kind> { 30 + readonly tag: Code; 31 + } 32 + 33 + export interface LastFmError extends TaggedError<"lastfm", LastFmErrorCode> { 34 + readonly message: string; 35 + } 36 + 37 + export interface MusicBrainzError extends TaggedError<"musicbrainz", number> { 38 + readonly message: string; 39 + } 40 + 41 + type IOError = "not_found" | "parse_failed" | "write_failed"; 42 + 43 + export type ConfigErrorReason = 44 + | IOError 45 + | "permission_denied"; 46 + 47 + export interface ConfigError extends TaggedError<"config", ConfigErrorReason> { 48 + readonly message: string; 49 + } 50 + 51 + export type CsvErrorReason = 52 + | IOError 53 + | "invalid_columns"; 54 + 55 + export interface CsvError extends TaggedError<"csv", CsvErrorReason> { 56 + readonly message: string; 57 + readonly path: string; 58 + } 59 + 60 + export interface AuthError extends BaseError<"auth"> { 61 + readonly message: string; 62 + } 63 + 64 + export interface RateLimitError extends BaseError<"rate_limit"> { 65 + readonly retryAfterMs?: number; 66 + } 67 + 68 + export interface NetworkError extends TaggedError<"network", number | undefined> { 69 + readonly message: string; 70 + } 71 + 72 + export const Errors = { 73 + lastfm: (tag: LastFmErrorCode, message: string): LastFmError => ({ kind: "lastfm", tag, message }), 74 + 75 + musicbrainz: (tag: number, message: string): MusicBrainzError => ({ kind: "musicbrainz", tag, message }), 76 + 77 + config: (tag: ConfigErrorReason, message: string): ConfigError => ({ kind: "config", tag, message }), 78 + 79 + csv: (tag: CsvErrorReason, message: string, path: string): CsvError => ({ kind: "csv", tag, message, path }), 80 + 81 + auth: (message: string): AuthError => ({ kind: "auth", message }), 82 + 83 + rateLimit: (retryAfterMs?: number): RateLimitError => ({ kind: "rate_limit", retryAfterMs }), 84 + 85 + network: (message: string, status?: number): NetworkError => ({ kind: "network", message, tag: status }), 86 + } as const; 87 + 88 + export function describe(e: AppError): string { 89 + switch (e.kind) { 90 + case "lastfm": 91 + return `Last.fm error ${e.tag}: ${e.message}`; 92 + case "musicbrainz": 93 + return `MusicBrainz error ${e.tag}: ${e.message}`; 94 + case "config": 95 + return `Config error (${e.tag}): ${e.message}`; 96 + case "csv": 97 + return `CSV error (${e.tag}) at ${e.path}: ${e.message}`; 98 + case "auth": 99 + return `Auth error: ${e.message}`; 100 + case "rate_limit": 101 + return e.retryAfterMs ? `Rate limited. Retry after ${e.retryAfterMs}ms` : "Rate limited"; 102 + case "network": 103 + return e.tag ? `Network error ${e.tag}: ${e.message}` : `Network error: ${e.message}`; 104 + } 105 + }
+35
lib/result.ts
··· 1 + import { Errors, NetworkError } from "~/lib/errors.ts"; 2 + 3 + type Ok<T> = { 4 + readonly ok: true; 5 + readonly value: T; 6 + }; 7 + 8 + type Err<E> = { 9 + readonly ok: false; 10 + readonly error: E; 11 + }; 12 + 13 + export type Result<T, E = never> = [E] extends [never] ? Ok<T> : Ok<T> | Err<E>; 14 + 15 + export const Ok = <T>(value: T): Result<T, never> => ({ 16 + ok: true, 17 + value, 18 + }); 19 + 20 + export const Fail = <E>(error: E): Result<never, E> => 21 + ({ 22 + ok: false, 23 + error, 24 + }) as Result<never, E>; 25 + 26 + export async function tryAsync<T, E = NetworkError>( 27 + fn: () => Promise<T>, 28 + mapError: (e: unknown) => E = (e) => Errors.network(e instanceof Error ? e.message : String(e)) as E, 29 + ): Promise<Result<T, E>> { 30 + try { 31 + return Ok(await fn()); 32 + } catch (e) { 33 + return Fail(mapError(e)); 34 + } 35 + }
mod.ts

This is a binary file and will not be displayed.

+27
readme.md
··· 1 + # scrobkit 2 + 3 + a minimal CLI toolkit for working with [last.fm] scrobbles 4 + 5 + ## status of this project 6 + 7 + - [x] last.fm api integration (authenticate, scrobble, recent tracks) 8 + - [x] structured error system with typed error codes 9 + - [x] csv import/export with skip directive and quoting 10 + - [x] persistent configuration format 11 + - [x] retry system with exponential backoff + jitter 12 + - [ ] cli command routing structure 13 + - [ ] individual track scrobbling 14 + - [ ] multipple album loop scrobbling 15 + - [ ] csv scrobbling history i/o 16 + - [ ] [.scrobbler.log] i/o (for [rockbox] and similar projects) 17 + - [ ] dry-run mode support 18 + - [ ] scrobble correction using [musicbrainz] api and [lotus] 19 + - [ ] conflict detection & resolution (timestamp + duplicates) 20 + - [ ] export filtering (date ranges, artist/album filters) 21 + - [ ] structured summary report (per artist/album breakdown) 22 + 23 + [last.fm]: https://last.fm 24 + [musicbrainz]: https://musicbrainz.org/ 25 + [lotus]: https://github.com/katelyynn/lotus 26 + [.scrobbler.log]: https://web.archive.org/web/20110522185339/http://www.audioscrobbler.net/wiki/Portable_Player_Logging 27 + [rockbox]: https://www.rockbox.org/
+136
tests/integration/lastfm_test.ts
··· 1 + import { assert } from "jsr:@std/assert@1.0.19"; 2 + import { authenticate, countScrobblesInRange, getRecentTracks, scrobble, verifySession } from "~/api/lastfm.ts"; 3 + 4 + function env(name: string): string { 5 + const v = Deno.env.get(name); 6 + if (!v) throw new Error(`missing env var: ${name}`); 7 + return v; 8 + } 9 + 10 + let cachedSessionKey: string | null = null; 11 + 12 + const apiKey = env("LASTFM_API_KEY"); 13 + const secret = env("LASTFM_SECRET"); 14 + const username = env("LASTFM_USERNAME"); 15 + 16 + export async function getSessionKey(): Promise<string> { 17 + if (cachedSessionKey) return cachedSessionKey; 18 + 19 + const existing = Deno.env.get("LASTFM_SESSION_KEY"); 20 + if (existing) { 21 + return cachedSessionKey = existing; 22 + } 23 + 24 + const password = env("LASTFM_PASSWORD"); 25 + 26 + if (!apiKey || !secret || !username || !password) { 27 + throw new Error( 28 + "missing both LASTFM_SESSION_KEY and auth env vars", 29 + ); 30 + } 31 + 32 + const res = await authenticate(apiKey, secret, username, password); 33 + if (!res.ok) { 34 + throw new Error("auth failed: " + JSON.stringify(res.error)); 35 + } 36 + 37 + return cachedSessionKey = res.value.key; 38 + } 39 + 40 + function now() { 41 + return Math.floor(Date.now() / 1000); 42 + } 43 + 44 + Deno.test("verifySession (integration)", async () => { 45 + const sk = await getSessionKey(); 46 + const res = await verifySession(apiKey, secret, sk); 47 + 48 + if (!res.ok) { 49 + throw new Error(JSON.stringify(res.error)); 50 + } 51 + assert(res.ok); 52 + }); 53 + 54 + Deno.test("getRecentTracks (integration)", async () => { 55 + const res = await getRecentTracks(apiKey, username, 1, { limit: 5 }); 56 + 57 + if (!res.ok) { 58 + throw new Error(JSON.stringify(res.error)); 59 + } 60 + 61 + assert(res.ok); 62 + 63 + assert(Array.isArray(res.value.tracks)); 64 + assert(res.value.page >= 1); 65 + assert(res.value.totalPages >= 1); 66 + 67 + if (res.value.tracks.length > 0) { 68 + const t = res.value.tracks[0]; 69 + assert(typeof t.artist === "string"); 70 + assert(typeof t.title === "string"); 71 + } 72 + }); 73 + 74 + Deno.test("countScrobblesInRange (integration)", async () => { 75 + const to = now(); 76 + const from = to - 86400 * 7; // last 7 days 77 + 78 + const res = await countScrobblesInRange(apiKey, username, from, to); 79 + 80 + if (!res.ok) { 81 + throw new Error(JSON.stringify(res.error)); 82 + } 83 + assert(res.ok); 84 + 85 + // sanity bounds 86 + console.log("scrobbles in range:", res.value); 87 + assert(res.value >= 0); 88 + assert(res.value < 10_000_000); 89 + }); 90 + 91 + Deno.test("scrobble (integration single)", async () => { 92 + const sk = await getSessionKey(); 93 + 94 + const res = await scrobble(apiKey, secret, sk, { 95 + artist: "away with words", 96 + album: "What to Think About Again", 97 + title: "Grave Robbery", 98 + timestamp: now(), 99 + }); 100 + 101 + if (!res.ok) { 102 + throw new Error(JSON.stringify(res.error)); 103 + } 104 + 105 + assert(res.ok); 106 + assert(res.value.accepted === 1); 107 + assert(res.value.ignored === 0); 108 + }); 109 + 110 + Deno.test("scrobble (integration batch)", async () => { 111 + const sk = await getSessionKey(); 112 + 113 + const timestamp = now(); 114 + 115 + const res = await scrobble(apiKey, secret, sk, [ 116 + { 117 + artist: "key vs. locket", 118 + title: "coincidência", 119 + timestamp, 120 + }, 121 + { 122 + artist: "Camellia", 123 + title: "fiиorza", 124 + timestamp: timestamp + 1, 125 + }, 126 + ]); 127 + 128 + if (!res.ok) { 129 + throw new Error(JSON.stringify(res.error)); 130 + } 131 + 132 + assert(res.ok); 133 + 134 + assert(res.value.accepted === 2); 135 + assert(res.value.ignored === 0); 136 + });
+40
utils/prompt.ts
··· 1 + import { promptSecret } from "@std/cli"; 2 + 3 + export function ask(message: string, secret = false, transform: (input: string) => string = (x) => x.trim()): string { 4 + const value = transform( 5 + (secret ? promptSecret : prompt)(message + " ") ?? "", 6 + ); 7 + 8 + if (!value) { 9 + throw new Error(`Required: ${message}`); 10 + } 11 + return value; 12 + } 13 + 14 + export function askOptional(message: string): string | null { 15 + const value = prompt(message); 16 + return value?.trim() || null; 17 + } 18 + 19 + export function confirm(message: string, defaultYes = false): boolean { 20 + const hint = defaultYes ? "[Y/n]" : "[y/N]"; 21 + const value = prompt(`${message} ${hint} `)?.trim().toLowerCase(); 22 + if (!value) return defaultYes; 23 + return value === "y" || value === "yes" || value === "si" || value === "sí" || value === "sim" || value === "ja"; 24 + } 25 + 26 + export function choose<T>( 27 + message: string, 28 + items: T[], 29 + label: (item: T) => string, 30 + ): T { 31 + console.log(`\n${message}`); 32 + items.forEach((item, i) => console.log(` ${i + 1}. ${label(item)}`)); 33 + 34 + while (true) { 35 + const raw = prompt(`\nEnter number (1-${items.length}):`); 36 + const n = parseInt(raw ?? "", 10); 37 + if (n >= 1 && n <= items.length) return items[n - 1]; 38 + console.log(` Please enter a number between 1 and ${items.length}.`); 39 + } 40 + }
+57
utils/retry.ts
··· 1 + export interface RetryOptions { 2 + maxAttempts?: number; 3 + baseDelayMs?: number; 4 + maxDelayMs?: number; 5 + retryIf?: (error: unknown, attempt: number) => boolean; 6 + onRetry?: (attempt: number, delayMs: number, error: unknown) => void; 7 + } 8 + 9 + const defaults: Required<Omit<RetryOptions, "onRetry">> = { 10 + maxAttempts: 4, 11 + baseDelayMs: 500, 12 + maxDelayMs: 15_000, 13 + retryIf: () => true, 14 + }; 15 + 16 + /** delay ∈ {0, ..., ⌊min(cap, base * 2^attempt)⌋ − 1}, uniformly distributed */ 17 + function jitteredDelay(attempt: number, base: number, cap: number): number { 18 + const ceiling = Math.min(cap, base * (2 ** attempt)); 19 + return Math.floor(Math.random() * ceiling); 20 + } 21 + 22 + export async function withRetry<T>( 23 + fn: () => Promise<T>, 24 + opts: RetryOptions = {}, 25 + ): Promise<T> { 26 + const { 27 + maxAttempts = defaults.maxAttempts, 28 + baseDelayMs = defaults.baseDelayMs, 29 + maxDelayMs = defaults.maxDelayMs, 30 + retryIf = defaults.retryIf, 31 + onRetry, 32 + } = opts; 33 + 34 + let lastError: unknown; 35 + 36 + for (let attempt = 1; attempt <= maxAttempts; attempt++) { 37 + try { 38 + return await fn(); 39 + } catch (error) { 40 + lastError = error; 41 + 42 + if (attempt === maxAttempts || !retryIf(error, attempt)) { 43 + throw error; 44 + } 45 + 46 + const delayMs = jitteredDelay(attempt, baseDelayMs, maxDelayMs); 47 + onRetry?.(attempt, delayMs, error); 48 + 49 + await sleep(delayMs); 50 + } 51 + } 52 + 53 + // unreachable 🥱 54 + throw lastError; 55 + } 56 + 57 + export const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));