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.

feat(commands): add `tidy` command to rewrite recent scrobbles via browser API

Lívia (May 17, 2026, 9:41 AM -0300) 6f401300 b22385d1

+214 -8
+1 -1
api/lotus.ts
··· 21 21 }; 22 22 23 23 export function normaliseArtistMetadata(rawArtist: string) { 24 - const separators = /(&|feat\.?|ft\.?|and|,)/i; 24 + const separators = /(&|feat\.?|ft\.?|,)/i; 25 25 const base = rawArtist.split(separators).map((x) => x.trim()).filter((y) => y && !y.match(separators)); 26 26 27 27 let artist: string | undefined = base.join(";");
+66
browser/lastfm.ts
··· 1 + import { Fail, Ok, Result } from "~/lib/result.ts"; 2 + import { Errors, NetworkError } from "~/lib/errors.ts"; 3 + import { BrowserSession } from "~/browser/session.ts"; 4 + 5 + const USER_AGENT = 6 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"; 7 + 8 + export interface DeleteScrobblePayload { 9 + artist: string; 10 + track: string; 11 + timestamp: number; // UNIX timestamp 12 + } 13 + 14 + async function performBrowserRequest( 15 + session: BrowserSession, 16 + endpoint: string, 17 + params: Record<string, string>, 18 + ): Promise<Result<void, NetworkError>> { 19 + const url = `https://www.last.fm/user/${session.username}${endpoint}`; 20 + 21 + const body = new URLSearchParams({ 22 + ...params, 23 + csrfmiddlewaretoken: session.csrfMiddlewareToken, 24 + }); 25 + 26 + try { 27 + const response = await fetch(url, { 28 + method: "POST", 29 + headers: { 30 + "Referer": `https://www.last.fm/user/${session.username}`, 31 + "User-Agent": USER_AGENT, 32 + "Cookie": `sessionid=${session.sessionId}; csrftoken=${session.csrfToken}`, 33 + "Content-Type": "application/x-www-form-urlencoded", 34 + }, 35 + body: body.toString(), 36 + }); 37 + 38 + if (!response.ok) { 39 + return Fail( 40 + Errors.network( 41 + `last.fm browser api returned http ${response.status}: ${await response.text()}`, 42 + response.status, 43 + ), 44 + ); 45 + } 46 + 47 + return Ok(undefined); 48 + } catch (e) { 49 + return Fail(Errors.network(e instanceof Error ? e.message : String(e))); 50 + } 51 + } 52 + 53 + /** 54 + * Deletes a scrobble via the Last.fm web UI endpoint, given that the official API doesn't provide you 55 + * measures to purge scrobbles. 56 + */ 57 + export async function deleteScrobble( 58 + session: BrowserSession, 59 + { artist, track, timestamp }: DeleteScrobblePayload, 60 + ): Promise<Result<void, NetworkError>> { 61 + return await performBrowserRequest(session, "/library/delete", { 62 + artist_name: artist, 63 + track_name: track, 64 + timestamp: String(timestamp), 65 + }); 66 + }
+42
browser/session.ts
··· 1 + import { Config } from "~/lib/config.ts"; 2 + import { log } from "~/cli/formatter.ts"; 3 + 4 + export interface BrowserSession { 5 + username: string; 6 + sessionId: string; 7 + csrfToken: string; 8 + csrfMiddlewareToken: string; 9 + userAgent?: string; 10 + } 11 + 12 + let activeSession: BrowserSession | null = null; 13 + 14 + /** 15 + * Prompts the user for session data if not already present in memory. 16 + */ 17 + export function ensureBrowserSession(config: Config): BrowserSession | null { 18 + if (activeSession) return activeSession; 19 + 20 + const username = config.username || prompt("Enter Last.fm username:"); 21 + if (!username) return null; 22 + 23 + log.warn("Last.fm Browser Authentication Required"); 24 + log.warn("These values are found in your browser cookies/inspector and will not be saved to disk."); 25 + 26 + const sessionId = prompt("Enter 'sessionid' (from cookies):")?.trim(); 27 + const csrfToken = prompt("Enter 'csrftoken' (from cookies):")?.trim(); 28 + const csrfMiddlewareToken = prompt("Enter 'csrfmiddlewaretoken' (from page source):")?.trim(); 29 + 30 + if (!sessionId || !csrfToken || !csrfMiddlewareToken) { 31 + log.error("Authentication cancelled: Missing required session values."); 32 + return null; 33 + } 34 + 35 + activeSession = { 36 + username, 37 + sessionId, 38 + csrfToken, 39 + csrfMiddlewareToken, 40 + }; 41 + return activeSession; 42 + }
+5 -5
cli/formatter.ts
··· 6 6 success: green("\u2714"), 7 7 forbid: red("\u2298"), 8 8 error: brightRed("\u2716"), 9 - info: yellow("\u{1f6c8}"), 9 + info: yellow("\u{1f6c8} "), 10 10 } as const; 11 11 12 12 export const log = { 13 13 success: (msg: string) => console.log(` ${symbols.success} ${msg}`), 14 14 15 - error: (msg: string) => console.error(`${symbols.error} ${red(msg)}`), 15 + error: (msg: string) => console.error(` ${symbols.error} ${red(msg)}`), 16 16 17 - warn: (msg: string) => console.warn(`${symbols.warn} ${msg}`), 17 + warn: (msg: string) => console.warn(` ${symbols.warn} ${msg}`), 18 18 19 - info: (msg: string) => console.warn(`${symbols.info} ${msg}`), 19 + info: (msg: string) => console.warn(` ${symbols.info} ${msg}`), 20 20 21 - forbid: (msg: string) => console.log(`${symbols.forbid} ${cyan(msg)}`), 21 + forbid: (msg: string) => console.log(` ${symbols.forbid} ${cyan(msg)}`), 22 22 }; 23 23 24 24 export { dim };
+3
commands/mod.ts
··· 5 5 import { log } from "~/cli/formatter.ts"; 6 6 import { dim } from "@std/fmt/colors"; 7 7 import { executeExportCommand } from "~/commands/export.ts"; 8 + import { executeTidyCommand } from "~/commands/tidy.ts"; 8 9 9 10 const HELP_TEXT = ` 10 11 scrobkit - A minimal CLI toolkit for working with Last.fm scrobbles ··· 12 13 Usage: scrobkit <command> [options] 13 14 14 15 Commands: 16 + tidy Normalize and correct recent scrobble metadata (14-day limit) 15 17 import Import scrobbles from a CSV document 16 18 export Export your scrobble history to a CSV file 17 19 ··· 23 25 const commands: Record<string, CommandFn> = { 24 26 import: executeImportCommand, 25 27 export: executeExportCommand, 28 + tidy: executeTidyCommand, 26 29 }; 27 30 28 31 async function main($: string[] = Deno.args) {
+95
commands/tidy.ts
··· 1 + import { parseArgs } from "@std/cli"; 2 + import { requireBaseConfig } from "~/cli/bootstrap.ts"; 3 + import { ensureSession } from "~/cli/session.ts"; 4 + import { getAllRecentTracks, scrobble } from "~/api/lastfm.ts"; 5 + import { dim, log } from "~/cli/formatter.ts"; 6 + import { Ok, Result } from "~/lib/result.ts"; 7 + import { AppError } from "~/lib/errors.ts"; 8 + import { ensureBrowserSession } from "~/browser/session.ts"; 9 + import { normaliseArtistMetadata } from "~/api/lotus.ts"; 10 + import { deleteScrobble } from "~/browser/lastfm.ts"; 11 + import { sleep } from "~/utils/retry.ts"; 12 + import { cyan, green, italic, red } from "@std/fmt/colors"; 13 + import { TIMESTAMP_LIMIT } from "~/cli/pipeline.ts"; 14 + 15 + const CORRECTION_CUTOFF = Math.floor(Date.now() / 1000) - TIMESTAMP_LIMIT; 16 + 17 + export async function executeTidyCommand(args: string[]): Promise<Result<void, AppError>> { 18 + const flags = parseArgs(args, { 19 + boolean: ["dry-run"], 20 + alias: { n: "dry-run" }, 21 + }); 22 + 23 + const config = await requireBaseConfig(); 24 + if (!config.ok) return config; 25 + 26 + const session = await ensureSession(config.value); 27 + if (!session.ok) return session; 28 + 29 + const browser = ensureBrowserSession(session.value); 30 + if (!browser) return Ok(void 0); 31 + 32 + const prefix = flags["dry-run"] ? ` \u{1F6E0} ${italic("dry run")} ` : " "; 33 + log.info(`${prefix}Scanning 14-day history for metadata issues...`); 34 + 35 + let skipCount = 0; 36 + const MAX_SHOWN_SKIPS = 3; 37 + 38 + outer: for await (const page of getAllRecentTracks(session.value.apiKey, session.value.username, { limit: 50 })) { 39 + for (const track of page) { 40 + if (!track.timestamp) continue; 41 + if (Number(track.timestamp) < CORRECTION_CUTOFF) break outer; 42 + 43 + const metadata = { artist: normaliseArtistMetadata(track.artist), title: track.title, album: track.album }; 44 + 45 + if (metadata.artist.toLowerCase() === track.artist.toLowerCase()) { 46 + if (skipCount++ <= MAX_SHOWN_SKIPS) { 47 + log.info(` ${dim("skip")} ${dim(track.artist + " — " + track.title)}`); 48 + } 49 + continue; 50 + } 51 + 52 + if (skipCount > MAX_SHOWN_SKIPS) { 53 + log.info(` ${dim("...")} ${dim(`skipped ${skipCount - MAX_SHOWN_SKIPS} more tracks`)}`); 54 + } 55 + 56 + skipCount = 0; 57 + const timestamp = new Date(track.timestamp * 1000).toLocaleString(); 58 + 59 + log.warn( 60 + ` ${dim("tidy")} ${red(track.artist)} → ${green(metadata.artist)} ${ 61 + dim(`(${track.title}) ${dim("@ " + timestamp)}`) 62 + }`, 63 + ); 64 + 65 + if (flags["dry-run"]) continue; 66 + 67 + const deletion = await deleteScrobble(browser, { 68 + artist: track.artist, 69 + timestamp: track.timestamp, 70 + track: track.title, 71 + }); 72 + 73 + if (!deletion.ok) { 74 + log.error(` ${dim("fail")} deletion </3: ${deletion.error.message}`); 75 + continue; 76 + } 77 + 78 + const add = await scrobble(session.value.apiKey, session.value.secret, session.value.sessionKey, { 79 + ...metadata, 80 + timestamp: track.timestamp, 81 + }); 82 + 83 + if (!add.ok || !add.value.accepted) { 84 + log.error(` ${dim("fail")} resubmission qwq`); 85 + continue; 86 + } 87 + 88 + log.success(` ${dim("done")} ${cyan(metadata.artist)} — ${metadata.title} ${dim("@ " + timestamp)}`); 89 + } 90 + sleep(100); 91 + } 92 + 93 + log.info(" finished tidying history :3"); 94 + return Ok(void 0); 95 + }
+2 -2
readme.md
··· 17 17 - [x] command-line commands 18 18 - [x] individual track scrobbling 19 19 - [ ] multiple album loop scrobbling 20 - - [ ] scrobble correction using [musicbrainz] api and [lotus] 21 - - [ ] export filtering (date ranges, artist/album filters) 20 + - [x] scrobble correction using [musicbrainz] api and [lotus] 21 + - [x] export filtering (date ranges, artist/album filters) 22 22 23 23 [last.fm]: https://last.fm 24 24 [musicbrainz]: https://musicbrainz.org/