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(format): implement `.scrobbler.log` parser and serializer

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

+356 -3
+14 -3
lib/errors.ts
··· 1 + import { ScrobblerLogError, ScrobblerLogErrorReason } from "~/lib/format/scrobbler-log/error.ts"; 2 + 1 3 export type AppError = 2 4 | LastFmError 3 5 | MusicBrainzError ··· 5 7 | CsvError 6 8 | AuthError 7 9 | RateLimitError 8 - | NetworkError; 10 + | NetworkError 11 + | ScrobblerLogError; 9 12 10 13 export type LastFmErrorCode = 11 14 | 2 // unavailable service ··· 22 25 | 26 // suspended api key 23 26 | 29; // rate limit exceeded 24 27 25 - interface BaseError<Kind> { 28 + export interface BaseError<Kind> { 26 29 readonly kind: Kind; 27 30 } 28 31 29 - interface TaggedError<Kind, Code> extends BaseError<Kind> { 32 + export interface TaggedError<Kind, Code> extends BaseError<Kind> { 30 33 readonly tag: Code; 31 34 } 32 35 ··· 83 86 rateLimit: (retryAfterMs?: number): RateLimitError => ({ kind: "rate_limit", retryAfterMs }), 84 87 85 88 network: (message: string, status?: number): NetworkError => ({ kind: "network", message, tag: status }), 89 + 90 + scrobblerLog: (tag: ScrobblerLogErrorReason, message: string): ScrobblerLogError => ({ 91 + kind: "scrobbler_log", 92 + tag, 93 + message, 94 + }), 86 95 } as const; 87 96 88 97 export function describe(e: AppError): string { ··· 101 110 return e.retryAfterMs ? `Rate limited. Retry after ${e.retryAfterMs}ms` : "Rate limited"; 102 111 case "network": 103 112 return e.tag ? `Network error ${e.tag}: ${e.message}` : `Network error: ${e.message}`; 113 + case "scrobbler_log": 114 + return `Scrobbler Log error (${e.tag}): ${e.message}`; 104 115 } 105 116 }
+141
lib/format/scrobbler-log/codec.ts
··· 1 + import { Errors } from "~/lib/errors.ts"; 2 + import { ClientIdentification, ScrobblerLogHeader, ScrobblerLogTrack } from "~/lib/format/scrobbler-log/mod.ts"; 3 + import { Fail, Ok, Result } from "~/lib/result.ts"; 4 + 5 + type ParseError = ReturnType<typeof Errors.scrobblerLog>; 6 + 7 + const FIELD_COUNT = 8; 8 + const TAB = "\t"; 9 + 10 + function createMusicBrainzId(value: string): Result<string, ParseError> { 11 + const stripped = value.trim(); 12 + const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; 13 + if (stripped.length && !pattern.test(stripped)) { 14 + return Fail(Errors.scrobblerLog("invalid_field", "musicBrainz id must be a valid UUID")); 15 + } 16 + return Ok(stripped); 17 + } 18 + 19 + function serializeClient(client: ClientIdentification): string { 20 + const parts = [client.device, client.model, client.revision].filter(Boolean); 21 + return parts.join(" "); 22 + } 23 + 24 + export function serializeHeader(header: ScrobblerLogHeader): readonly string[] { 25 + return [ 26 + `#AUDIOSCROBBLER/${header.version}`, 27 + `#TZ/${header.timezone.kind.toUpperCase()}`, 28 + `#CLIENT/${serializeClient(header.client)}`, 29 + ]; 30 + } 31 + 32 + // the spec mandates: [...] "strip any tab characters from the data" 33 + function sanitize(str: string): string { 34 + return str.replace(/\t/g, " "); 35 + } 36 + 37 + export function serializeTrack(track: ScrobblerLogTrack): string { 38 + const fields: string[] = [ 39 + sanitize(track.artist), 40 + sanitize(track.album ?? ""), 41 + sanitize(track.title), 42 + track.trackIndex?.toString() ?? "", 43 + track.duration.toString(), 44 + track.rating, 45 + track.timestamp.toString(), 46 + sanitize(track.musicBrainzId ?? ""), 47 + ]; 48 + return fields.join(TAB); 49 + } 50 + 51 + function parseHeaderLine(line: string): Result<Partial<ScrobblerLogHeader>, ParseError> { 52 + if (!line.startsWith("#")) { 53 + return Fail(Errors.scrobblerLog("parse_failed", "header line must start with #")); 54 + } 55 + 56 + const [directive, ...rest] = line.slice(1).split("/"); 57 + const value = rest.join("/"); // sanity purposes 58 + 59 + switch (directive) { 60 + case "AUDIOSCROBBLER": 61 + if (value !== "1.1") { 62 + return Fail(Errors.scrobblerLog("unsupported_version", `version ${value} is unsupported`)); 63 + } 64 + return Ok({ version: value as ScrobblerLogHeader["version"] }); 65 + case "TZ": { 66 + const tz = value.toLowerCase(); 67 + if (tz === "utc" || tz === "unknown") return Ok({ timezone: { kind: tz } }); 68 + return Fail(Errors.scrobblerLog("invalid_field", `invalid timezone: ${value}`)); 69 + } 70 + case "CLIENT": 71 + return Ok({ client: { device: value, revision: "1.0" } }); 72 + default: 73 + // forward compatibility 74 + return Ok({}); 75 + } 76 + } 77 + 78 + export function parseHeader(lines: string[]): Result<ScrobblerLogHeader, ParseError> { 79 + let header: Partial<ScrobblerLogHeader> = {}; 80 + 81 + for (const line of lines) { 82 + if (!line.startsWith("#")) break; // headers end at first non-comment line 83 + 84 + const result = parseHeaderLine(line); 85 + if (!result.ok) return result; 86 + header = { ...header, ...result.value }; 87 + } 88 + 89 + if (!header.version) return Fail(Errors.scrobblerLog("parse_failed", "missing version header")); 90 + if (!header.timezone) return Fail(Errors.scrobblerLog("parse_failed", "missing timezone header")); 91 + if (!header.client) return Fail(Errors.scrobblerLog("parse_failed", "missing client header")); 92 + 93 + return Ok(header as ScrobblerLogHeader); 94 + } 95 + 96 + export function parseTrack(line: string): Result<ScrobblerLogTrack, ParseError> { 97 + const fields = line.split(TAB); 98 + if (fields.length !== FIELD_COUNT) { 99 + return Fail(Errors.scrobblerLog("invalid_columns", `Expected ${FIELD_COUNT} fields, got ${fields.length}`)); 100 + } 101 + 102 + const [artist, album, title, $trackIndex, rawDuration, rating, $timestamp, $mbId] = fields; 103 + 104 + if (!artist) { 105 + return Fail(Errors.scrobblerLog("invalid_field", "artist must not be empty")); 106 + } 107 + if (!title) { 108 + return Fail(Errors.scrobblerLog("invalid_field", "title must not be empty")); 109 + } 110 + 111 + const duration = parseInt(rawDuration, 10); 112 + if (isNaN(duration)) return Fail(Errors.scrobblerLog("invalid_field", "duration must be a number")); 113 + 114 + if (rating !== "L" && rating !== "S") { 115 + return Fail(Errors.scrobblerLog("invalid_field", "rating must be either 'L' or 'S'")); 116 + } 117 + 118 + const timestamp = typeof $timestamp === "string" ? parseInt($timestamp, 10) : $timestamp; 119 + if (isNaN(timestamp) || timestamp < 0) { 120 + return Fail(Errors.scrobblerLog("invalid_field", "timestamp must be a positive number")); 121 + } 122 + 123 + const trackIndex = $trackIndex ? parseInt($trackIndex, 10) : undefined; 124 + if (trackIndex !== undefined && isNaN(trackIndex)) { 125 + return Fail(Errors.scrobblerLog("invalid_field", "track number must be a valid number")); 126 + } 127 + 128 + const musicBrainzId = $mbId ? createMusicBrainzId($mbId) : Ok(undefined); 129 + if (!musicBrainzId.ok) return musicBrainzId; 130 + 131 + return Ok({ 132 + artist, 133 + album, 134 + title, 135 + trackIndex, 136 + duration, 137 + rating, 138 + timestamp, 139 + musicBrainzId: musicBrainzId.value, 140 + }); 141 + }
+14
lib/format/scrobbler-log/error.ts
··· 1 + import { TaggedError } from "~/lib/errors.ts"; 2 + 3 + export type ScrobblerLogErrorReason = 4 + | "not_found" 5 + | "read_failed" 6 + | "write_failed" 7 + | "parse_failed" 8 + | "invalid_columns" 9 + | "invalid_field" 10 + | "unsupported_version"; 11 + 12 + export interface ScrobblerLogError extends TaggedError<"scrobbler_log", ScrobblerLogErrorReason> { 13 + readonly message: string; 14 + }
+133
lib/format/scrobbler-log/io.ts
··· 1 + import { ScrobblerLogHeader, ScrobblerLogTrack } from "~/lib/format/scrobbler-log/mod.ts"; 2 + import { parseHeader, parseTrack, serializeHeader, serializeTrack } from "~/lib/format/scrobbler-log/codec.ts"; 3 + import { Errors } from "~/lib/errors.ts"; 4 + import { Fail, Ok, Result } from "~/lib/result.ts"; 5 + import { join } from "@std/path"; 6 + 7 + const FILENAME = ".scrobbler.log"; 8 + const NEWLINE = "\n"; 9 + 10 + export interface ScrobblerLogPayload { 11 + readonly header: ScrobblerLogHeader; 12 + readonly tracks: readonly ScrobblerLogTrack[]; 13 + } 14 + 15 + type IOError = ReturnType<typeof Errors.scrobblerLog>; 16 + 17 + /** 18 + * determine if a header needs to be prepended to a log entry. 19 + */ 20 + function withHeaderIfNeeded( 21 + path: string, 22 + header: ScrobblerLogHeader, 23 + line: string, 24 + fileExists: (p: string) => boolean, 25 + ): string { 26 + if (!fileExists(path)) { 27 + const headerLines = serializeHeader(header).join(NEWLINE); 28 + return `${headerLines}${NEWLINE}${line}`; 29 + } 30 + return line; 31 + } 32 + 33 + export async function readScrobblerLog(input: string): Promise<Result<ScrobblerLogPayload, IOError>> { 34 + let raw: string; 35 + let path = input; 36 + 37 + try { 38 + if ((await Deno.stat(path)).isDirectory) { 39 + path = join(input, FILENAME); 40 + } 41 + raw = await Deno.readTextFile(path); 42 + } catch (e) { 43 + if (e instanceof Deno.errors.NotFound) { 44 + return Fail(Errors.scrobblerLog("not_found", `log file not found at ${path}`)); 45 + } 46 + return Fail(Errors.scrobblerLog("read_failed", e instanceof Error ? e.message : String(e))); 47 + } 48 + 49 + const lines = raw.split(NEWLINE).filter((line) => line.length); 50 + 51 + const header = parseHeader(lines); 52 + if (!header.ok) return header; 53 + 54 + const headerLength = lines.filter((l) => l.startsWith("#")).length; 55 + const body = lines.slice(headerLength); 56 + 57 + const tracks: ScrobblerLogTrack[] = []; 58 + for (const meow of body) { 59 + const track = parseTrack(meow); 60 + if (!track.ok) continue; 61 + tracks.push(track.value); 62 + } 63 + 64 + return Ok({ header: header.value, tracks }); 65 + } 66 + 67 + /** 68 + * appends a single track to an existing log, or creates a new log if it doesn't exist. 69 + */ 70 + export async function appendTrack( 71 + directory: string, 72 + header: ScrobblerLogHeader, 73 + track: ScrobblerLogTrack, 74 + ): Promise<Result<void, IOError>> { 75 + const path = join(directory, FILENAME); 76 + const line = serializeTrack(track) + NEWLINE; 77 + 78 + try { 79 + const body = withHeaderIfNeeded(path, header, line, (p) => { 80 + try { 81 + Deno.statSync(p); 82 + return true; 83 + } catch (e) { 84 + if (e instanceof Deno.errors.NotFound) return false; 85 + throw e; // propagate actual i/o errors to the outer catch 86 + } 87 + }); 88 + 89 + await Deno.writeTextFile(path, body, { append: false }); 90 + return Ok(undefined); 91 + } catch (e) { 92 + return Fail(Errors.scrobblerLog("write_failed", e instanceof Error ? e.message : String(e))); 93 + } 94 + } 95 + 96 + /** 97 + * creates a new .scrobbler.log file, overwriting any existing one. 98 + */ 99 + export async function createLog( 100 + directory: string, 101 + $header: ScrobblerLogHeader, 102 + $tracks: readonly ScrobblerLogTrack[] = [], 103 + ): Promise<Result<void, IOError>> { 104 + const path = join(directory, FILENAME); 105 + 106 + const hader = serializeHeader($header).join(NEWLINE); 107 + const tracks = $tracks.map(serializeTrack).join(NEWLINE); 108 + 109 + const body = [hader, tracks].filter(Boolean).join(NEWLINE) + NEWLINE; 110 + 111 + try { 112 + await Deno.writeTextFile(path, body); 113 + return Ok(undefined); 114 + } catch (e) { 115 + return Fail(Errors.scrobblerLog("write_failed", e instanceof Error ? e.message : String(e))); 116 + } 117 + } 118 + 119 + /** 120 + * safely deletes the log file. done after successful synchronisation, in compliance with the spec 121 + */ 122 + export async function deleteLog(directory: string): Promise<Result<void, IOError>> { 123 + const path = join(directory, FILENAME); 124 + try { 125 + await Deno.remove(path); 126 + return Ok(undefined); 127 + } catch (e) { 128 + if (e instanceof Deno.errors.NotFound) { 129 + return Ok(undefined); 130 + } 131 + return Fail(Errors.scrobblerLog("write_failed", e instanceof Error ? e.message : String(e))); 132 + } 133 + }
+54
lib/format/scrobbler-log/mod.ts
··· 1 + // https://web.archive.org/web/20170107015006/http://www.audioscrobbler.net/wiki/Portable_Player_Logging 2 + 3 + /** 4 + * Semantic version identifier for the scrobbler log format. 5 + */ 6 + export type ScrobblerLogVersion = "1.0" | "1.1"; 7 + 8 + /** 9 + * Classify as "L" (scrobble) if the ratio of playback duration to total track duration is ≥ 50%; 10 + * otherwise, classify as "S" (skip). 11 + */ 12 + export type TrackRating = "L" | "S"; 13 + 14 + /** 15 + * If the device has a known time zone, it MUST normalize all recorded timestamps to UTC (e.g., #TZ/UTC). 16 + * 17 + * If the device has a valid clock but an unknown time zone, timestamps MUST be recorded as 18 + * local (unqualified) time (e.g., #TZ/UNKNOWN). 19 + */ 20 + export type Timezone = 21 + | { kind: "unknown" } 22 + | { kind: "utc" }; 23 + 24 + /** 25 + * Core domain representation of a scrobbled track. 26 + */ 27 + export interface ScrobblerLogTrack { 28 + readonly artist: string; 29 + readonly album?: string; 30 + readonly title: string; 31 + readonly trackIndex?: number; 32 + readonly duration: number; 33 + readonly rating: TrackRating; 34 + readonly timestamp: number; 35 + readonly musicBrainzId?: string; 36 + } 37 + 38 + /** 39 + * Strict client identification. Enforces format like "Rockbox h3xx 1.1" 40 + */ 41 + export interface ClientIdentification { 42 + readonly device: string; 43 + readonly model?: string; 44 + readonly revision: string; 45 + } 46 + 47 + /** 48 + * The header configuration required to initialize a log file. 49 + */ 50 + export interface ScrobblerLogHeader { 51 + readonly version: ScrobblerLogVersion; 52 + readonly timezone: Timezone; 53 + readonly client: ClientIdentification; 54 + }