diff --git a/api/lotus.ts b/api/lotus.ts index 90ed8ab..4a8afb4 100644 --- a/api/lotus.ts +++ b/api/lotus.ts @@ -21,7 +21,7 @@ const lotus = { }; export function normaliseArtistMetadata(rawArtist: string) { - const separators = /(&|feat\.?|ft\.?|and|,)/i; + const separators = /(&|feat\.?|ft\.?|,)/i; const base = rawArtist.split(separators).map((x) => x.trim()).filter((y) => y && !y.match(separators)); let artist: string | undefined = base.join(";"); diff --git a/browser/lastfm.ts b/browser/lastfm.ts new file mode 100644 index 0000000..7a934df --- /dev/null +++ b/browser/lastfm.ts @@ -0,0 +1,66 @@ +import { Fail, Ok, Result } from "~/lib/result.ts"; +import { Errors, NetworkError } from "~/lib/errors.ts"; +import { BrowserSession } from "~/browser/session.ts"; + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"; + +export interface DeleteScrobblePayload { + artist: string; + track: string; + timestamp: number; // UNIX timestamp +} + +async function performBrowserRequest( + session: BrowserSession, + endpoint: string, + params: Record, +): Promise> { + const url = `https://www.last.fm/user/${session.username}${endpoint}`; + + const body = new URLSearchParams({ + ...params, + csrfmiddlewaretoken: session.csrfMiddlewareToken, + }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Referer": `https://www.last.fm/user/${session.username}`, + "User-Agent": USER_AGENT, + "Cookie": `sessionid=${session.sessionId}; csrftoken=${session.csrfToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (!response.ok) { + return Fail( + Errors.network( + `last.fm browser api returned http ${response.status}: ${await response.text()}`, + response.status, + ), + ); + } + + return Ok(undefined); + } catch (e) { + return Fail(Errors.network(e instanceof Error ? e.message : String(e))); + } +} + +/** + * Deletes a scrobble via the Last.fm web UI endpoint, given that the official API doesn't provide you + * measures to purge scrobbles. + */ +export async function deleteScrobble( + session: BrowserSession, + { artist, track, timestamp }: DeleteScrobblePayload, +): Promise> { + return await performBrowserRequest(session, "/library/delete", { + artist_name: artist, + track_name: track, + timestamp: String(timestamp), + }); +} diff --git a/browser/session.ts b/browser/session.ts new file mode 100644 index 0000000..56d0c11 --- /dev/null +++ b/browser/session.ts @@ -0,0 +1,42 @@ +import { Config } from "~/lib/config.ts"; +import { log } from "~/cli/formatter.ts"; + +export interface BrowserSession { + username: string; + sessionId: string; + csrfToken: string; + csrfMiddlewareToken: string; + userAgent?: string; +} + +let activeSession: BrowserSession | null = null; + +/** + * Prompts the user for session data if not already present in memory. + */ +export function ensureBrowserSession(config: Config): BrowserSession | null { + if (activeSession) return activeSession; + + const username = config.username || prompt("Enter Last.fm username:"); + if (!username) return null; + + log.warn("Last.fm Browser Authentication Required"); + log.warn("These values are found in your browser cookies/inspector and will not be saved to disk."); + + const sessionId = prompt("Enter 'sessionid' (from cookies):")?.trim(); + const csrfToken = prompt("Enter 'csrftoken' (from cookies):")?.trim(); + const csrfMiddlewareToken = prompt("Enter 'csrfmiddlewaretoken' (from page source):")?.trim(); + + if (!sessionId || !csrfToken || !csrfMiddlewareToken) { + log.error("Authentication cancelled: Missing required session values."); + return null; + } + + activeSession = { + username, + sessionId, + csrfToken, + csrfMiddlewareToken, + }; + return activeSession; +} diff --git a/cli/formatter.ts b/cli/formatter.ts index c83855a..81595f7 100644 --- a/cli/formatter.ts +++ b/cli/formatter.ts @@ -6,19 +6,19 @@ export const symbols = { success: green("\u2714"), forbid: red("\u2298"), error: brightRed("\u2716"), - info: yellow("\u{1f6c8}"), + info: yellow("\u{1f6c8} "), } as const; export const log = { success: (msg: string) => console.log(` ${symbols.success} ${msg}`), - error: (msg: string) => console.error(`${symbols.error} ${red(msg)}`), + error: (msg: string) => console.error(` ${symbols.error} ${red(msg)}`), - warn: (msg: string) => console.warn(`${symbols.warn} ${msg}`), + warn: (msg: string) => console.warn(` ${symbols.warn} ${msg}`), - info: (msg: string) => console.warn(`${symbols.info} ${msg}`), + info: (msg: string) => console.warn(` ${symbols.info} ${msg}`), - forbid: (msg: string) => console.log(`${symbols.forbid} ${cyan(msg)}`), + forbid: (msg: string) => console.log(` ${symbols.forbid} ${cyan(msg)}`), }; export { dim }; diff --git a/commands/mod.ts b/commands/mod.ts index 531dbde..6cd762a 100644 --- a/commands/mod.ts +++ b/commands/mod.ts @@ -5,6 +5,7 @@ import { parseArgs } from "@std/cli"; import { log } from "~/cli/formatter.ts"; import { dim } from "@std/fmt/colors"; import { executeExportCommand } from "~/commands/export.ts"; +import { executeTidyCommand } from "~/commands/tidy.ts"; const HELP_TEXT = ` scrobkit - A minimal CLI toolkit for working with Last.fm scrobbles @@ -12,6 +13,7 @@ scrobkit - A minimal CLI toolkit for working with Last.fm scrobbles Usage: scrobkit [options] Commands: + tidy Normalize and correct recent scrobble metadata (14-day limit) import Import scrobbles from a CSV document export Export your scrobble history to a CSV file @@ -23,6 +25,7 @@ type CommandFn = (args: string[]) => Promise>; const commands: Record = { import: executeImportCommand, export: executeExportCommand, + tidy: executeTidyCommand, }; async function main($: string[] = Deno.args) { diff --git a/commands/tidy.ts b/commands/tidy.ts new file mode 100644 index 0000000..12abe82 --- /dev/null +++ b/commands/tidy.ts @@ -0,0 +1,95 @@ +import { parseArgs } from "@std/cli"; +import { requireBaseConfig } from "~/cli/bootstrap.ts"; +import { ensureSession } from "~/cli/session.ts"; +import { getAllRecentTracks, scrobble } from "~/api/lastfm.ts"; +import { dim, log } from "~/cli/formatter.ts"; +import { Ok, Result } from "~/lib/result.ts"; +import { AppError } from "~/lib/errors.ts"; +import { ensureBrowserSession } from "~/browser/session.ts"; +import { normaliseArtistMetadata } from "~/api/lotus.ts"; +import { deleteScrobble } from "~/browser/lastfm.ts"; +import { sleep } from "~/utils/retry.ts"; +import { cyan, green, italic, red } from "@std/fmt/colors"; +import { TIMESTAMP_LIMIT } from "~/cli/pipeline.ts"; + +const CORRECTION_CUTOFF = Math.floor(Date.now() / 1000) - TIMESTAMP_LIMIT; + +export async function executeTidyCommand(args: string[]): Promise> { + const flags = parseArgs(args, { + boolean: ["dry-run"], + alias: { n: "dry-run" }, + }); + + const config = await requireBaseConfig(); + if (!config.ok) return config; + + const session = await ensureSession(config.value); + if (!session.ok) return session; + + const browser = ensureBrowserSession(session.value); + if (!browser) return Ok(void 0); + + const prefix = flags["dry-run"] ? ` \u{1F6E0} ${italic("dry run")} ` : " "; + log.info(`${prefix}Scanning 14-day history for metadata issues...`); + + let skipCount = 0; + const MAX_SHOWN_SKIPS = 3; + + outer: for await (const page of getAllRecentTracks(session.value.apiKey, session.value.username, { limit: 50 })) { + for (const track of page) { + if (!track.timestamp) continue; + if (Number(track.timestamp) < CORRECTION_CUTOFF) break outer; + + const metadata = { artist: normaliseArtistMetadata(track.artist), title: track.title, album: track.album }; + + if (metadata.artist.toLowerCase() === track.artist.toLowerCase()) { + if (skipCount++ <= MAX_SHOWN_SKIPS) { + log.info(` ${dim("skip")} ${dim(track.artist + " — " + track.title)}`); + } + continue; + } + + if (skipCount > MAX_SHOWN_SKIPS) { + log.info(` ${dim("...")} ${dim(`skipped ${skipCount - MAX_SHOWN_SKIPS} more tracks`)}`); + } + + skipCount = 0; + const timestamp = new Date(track.timestamp * 1000).toLocaleString(); + + log.warn( + ` ${dim("tidy")} ${red(track.artist)} → ${green(metadata.artist)} ${ + dim(`(${track.title}) ${dim("@ " + timestamp)}`) + }`, + ); + + if (flags["dry-run"]) continue; + + const deletion = await deleteScrobble(browser, { + artist: track.artist, + timestamp: track.timestamp, + track: track.title, + }); + + if (!deletion.ok) { + log.error(` ${dim("fail")} deletion