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(pipeline): add jittered timestamp generation and resolve expired dates

Lívia (May 17, 2026, 9:41 AM -0300) bb38f65f 06ad4a5b

+65 -29
LICENSE license
+1 -1
cli/bootstrap.ts
··· 1 1 import { type Config, loadConfig, updateConfig } from "~/lib/config.ts"; 2 2 import { dim, log } from "~/cli/formatter.ts"; 3 - import { AppError, describe } from "~/lib/errors.ts"; 3 + import { AppError } from "~/lib/errors.ts"; 4 4 import { Ok, Result } from "~/lib/result.ts"; 5 5 import { ask } from "~/utils/prompt.ts"; 6 6 import { italic } from "@std/fmt/colors";
+5 -6
cli/csv-runner.ts
··· 1 1 import { CsvDocument } from "~/lib/format/csv/mod.ts"; 2 - import { PendingEntry, PipelineOptions, PipelineSummary, runPipeline } from "~/cli/pipeline.ts"; 2 + import { PendingEntry, PipelineOptions, PipelineSummary, resolveTimestamp, runPipeline } from "~/cli/pipeline.ts"; 3 3 import { loadCsvDocument, markSkipped } from "~/lib/format/csv/io.ts"; 4 4 import { describe } from "~/lib/errors.ts"; 5 5 import { symbols } from "~/cli/formatter.ts"; ··· 7 7 8 8 type CsvContext = { lineIndex: number }; 9 9 10 - function toPendingEntry(entry: CsvDocument["pending"][number]): PendingEntry<CsvContext> { 10 + function toPendingEntry(entry: CsvDocument["pending"][number], index: number): PendingEntry<CsvContext> { 11 11 return { 12 12 meta: { 13 - artist: entry.track.title, 13 + artist: entry.track.artist, 14 14 album: entry.track.album || undefined, 15 15 title: entry.track.title, 16 - timestamp: Math.floor(new Date(entry.track.date).getTime() / 1000), 16 + timestamp: resolveTimestamp(index, entry.track.date), 17 17 }, 18 18 context: { 19 19 lineIndex: entry.lineIndex, ··· 24 24 export async function runCsvPipeline( 25 25 path: string, 26 26 opts: PipelineOptions<CsvContext>, 27 - onProgress: (current: number, total: number, meta: any, status: any, detail?: string) => void, 28 27 ): Promise<PipelineSummary> { 29 28 const document = await loadCsvDocument(path); 30 29 if (!document.ok) throw new Error(document.error.message); ··· 45 44 } 46 45 return skip; 47 46 }, 48 - }, onProgress); 47 + }); 49 48 }
+2 -2
cli/formatter.ts
··· 1 - import { cyan, dim, gray, green, red, yellow } from "@std/fmt/colors"; 1 + import { brightRed, cyan, dim, gray, green, red, yellow } from "@std/fmt/colors"; 2 2 3 3 export const symbols = { 4 4 retry: gray("\u21bb"), 5 5 warn: cyan("\u26a0"), 6 6 success: green("\u2714"), 7 7 forbid: red("\u2298"), 8 - error: red("\u2716"), 8 + error: brightRed("\u2716"), 9 9 info: yellow("\u{1f6c8}"), 10 10 } as const; 11 11
+42 -13
cli/pipeline.ts
··· 2 2 import { countScrobblesInRange, scrobble, ScrobblePayload, ScrobbleResult } from "~/api/lastfm.ts"; 3 3 import { AppError, describe } from "~/lib/errors.ts"; 4 4 import { sleep, withRetryR } from "~/utils/retry.ts"; 5 - import { Ok, Result } from "~/lib/result.ts"; 5 + import { Result } from "~/lib/result.ts"; 6 6 import { symbols } from "~/cli/formatter.ts"; 7 - import { dim, yellow } from "@std/fmt/colors"; 7 + import { dim, italic, yellow } from "@std/fmt/colors"; 8 8 9 - const DAILY_SCROBBLE_LIMIT: number = 2880; 9 + export const DAILY_SCROBBLE_LIMIT: number = 2880; 10 + export const TIMESTAMP_LIMIT = 1_200_960; // ~13.9 days 10 11 11 12 export type PipelineTrackMeta = ScrobblePayload; 12 13 13 14 export interface PipelineOptions<TContext = void> { 14 15 readonly config: Omit<Required<Config>, "password">; 15 - readonly sessionKey: string; 16 16 readonly dryRun?: boolean; 17 17 readonly delayMs?: number; 18 18 ··· 113 113 status: PipelineProgressStatus, 114 114 detail?: string, 115 115 ) { 116 - const prefix = dryRun ? " \u{1F6E0} 𝘥𝘳𝘺 𝘳𝘶𝘯 " : " "; 116 + const prefix = dryRun ? ` \u{1F6E0} ${italic("dry run")} ` : " "; 117 117 118 118 const track = yellow(`"${meta.artist} - ${meta.title}"`); 119 119 const progress = prefix + dim(`[${current}/${total}]`); 120 120 121 + const timestamp = new Date(meta.timestamp * 1000).toLocaleString(); 122 + 121 123 switch (status) { 122 124 case "ok": 123 - console.log(`${progress} ${symbols.success} ${track}`); 125 + console.log(`${progress} ${symbols.success} ${track} ${dim("@ " + timestamp)}`); 124 126 break; 125 127 case "ignored": 126 - console.warn(`${progress} ${symbols.forbid} Ignored by Last.fm: ${track}`); 128 + console.warn(`${progress} ${symbols.forbid} Ignored by Last.fm: ${track} ${dim("@ " + timestamp)}`); 127 129 break; 128 130 case "failed": 129 131 console.error( ··· 177 179 178 180 try { 179 181 const result: ScrobbleResult = await withRetryR( 180 - () => scrobble(opts.config.apiKey, opts.config.secret, opts.sessionKey, meta), 182 + () => scrobble(opts.config.apiKey, opts.config.secret, opts.config.sessionKey, meta), 181 183 { 182 184 maxAttempts: 4, 183 185 baseDelayMs: 500, ··· 218 220 } 219 221 220 222 /** 221 - * generate evenly-spaced timestamps for tracks that have no date. 222 - * starts 13.9 days before now and steps 30s per track 223 + * assigns a quasi-uniformly spaced timestamp to a track with a or date that's either older than 14 days 224 + * or missing. 225 + * 226 + * t₀ = (now − 13.9 days), with fixed increment Δt = 30 s per track. 223 227 */ 224 - export function generateTimestamps(count: number): number[] { 225 - const begin = Math.floor(Date.now() / 1_000) - 1_200_960; 226 - return Array.from({ length: count }, (_, i) => begin + (i + 1) * 30); 228 + export function generateTimestamp(index: number, jitter = 10): number { 229 + const t0 = Math.floor(Date.now() / 1_000) - TIMESTAMP_LIMIT; 230 + 231 + // uniform noise ε ∈ [-jitter, +jitter] 232 + const epsilon = Math.floor((Math.random() * 2 - 1) * jitter); 233 + return t0 + index * 30 + epsilon; 234 + } 235 + 236 + export function resolveTimestamp(index: number, input?: string | number): number { 237 + let ts: number | undefined; 238 + 239 + if (typeof input === "number") { 240 + ts = input > 1e9 ? input : undefined; 241 + } else if (input) { 242 + const parsed = Date.parse(input); 243 + if (!isNaN(parsed)) { 244 + ts = Math.floor(parsed / 1000); 245 + } 246 + } 247 + 248 + if (ts !== undefined) { 249 + const now = Math.floor(Date.now() / 1000); 250 + if (now - ts <= TIMESTAMP_LIMIT) { 251 + return ts; 252 + } 253 + } 254 + 255 + return generateTimestamp(index); 227 256 }
+5 -7
cli/scrobbler-log-runner.ts
··· 1 - import { PendingEntry, PipelineOptions, PipelineSummary, runPipeline } from "~/cli/pipeline.ts"; 2 - import { describe } from "~/lib/errors.ts"; 1 + import { PendingEntry, PipelineOptions, PipelineSummary, resolveTimestamp, runPipeline } from "~/cli/pipeline.ts"; 3 2 import { ScrobblerLogTrack } from "~/lib/format/scrobbler-log/mod.ts"; 4 3 import { deleteLog, readScrobblerLog } from "~/lib/format/scrobbler-log/io.ts"; 5 4 6 5 type ScrobblerLogContext = Record<PropertyKey, never>; 7 6 8 - function toPendingEntry(track: ScrobblerLogTrack): PendingEntry<ScrobblerLogContext> { 7 + function toPendingEntry(track: ScrobblerLogTrack, index: number): PendingEntry<ScrobblerLogContext> { 9 8 return { 10 9 meta: { 11 - artist: track.title, 10 + artist: track.artist, 12 11 album: track.album || undefined, 13 12 title: track.title, 14 - timestamp: track.timestamp, 13 + timestamp: resolveTimestamp(index, track.timestamp), 15 14 }, 16 15 context: {}, 17 16 }; ··· 20 19 export async function runScrobblerLogPipeline( 21 20 path: string, 22 21 opts: PipelineOptions<ScrobblerLogContext>, 23 - onProgress: (current: number, total: number, meta: any, status: any, detail?: string) => void, 24 22 ): Promise<PipelineSummary> { 25 23 const log = await readScrobblerLog(path); 26 24 if (!log.ok) throw new Error(log.error.message); 27 25 28 26 const pending = log.value.tracks.map(toPendingEntry); 29 - const summary = await runPipeline<ScrobblerLogContext>(pending, { ...opts }, onProgress); 27 + const summary = await runPipeline<ScrobblerLogContext>(pending, { ...opts }); 30 28 31 29 if (summary.failed === 0 && !opts.dryRun) { 32 30 console.log(`\n \u2713 sync complete, removing .scrobbler.log...`);
+1
deno.json
··· 6 6 "@std/cli": "jsr:@std/cli@^1.0.28", 7 7 "@std/crypto": "jsr:@std/crypto@^1.0.5", 8 8 "@std/fmt": "jsr:@std/fmt@^1.0.9", 9 + "@std/fs": "jsr:@std/fs@^1.0.23", 9 10 "@std/path": "jsr:@std/path@^1.1.4", 10 11 "~/": "./" 11 12 },
+9
deno.lock
··· 6 6 "jsr:@std/cli@^1.0.28": "1.0.28", 7 7 "jsr:@std/crypto@^1.0.5": "1.0.5", 8 8 "jsr:@std/fmt@^1.0.9": "1.0.9", 9 + "jsr:@std/fs@^1.0.23": "1.0.23", 9 10 "jsr:@std/internal@^1.0.12": "1.0.12", 10 11 "jsr:@std/path@^1.1.4": "1.1.4" 11 12 }, ··· 29 30 "@std/fmt@1.0.9": { 30 31 "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 31 32 }, 33 + "@std/fs@1.0.23": { 34 + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 35 + "dependencies": [ 36 + "jsr:@std/internal", 37 + "jsr:@std/path" 38 + ] 39 + }, 32 40 "@std/internal@1.0.12": { 33 41 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 34 42 }, ··· 44 52 "jsr:@std/cli@^1.0.28", 45 53 "jsr:@std/crypto@^1.0.5", 46 54 "jsr:@std/fmt@^1.0.9", 55 + "jsr:@std/fs@^1.0.23", 47 56 "jsr:@std/path@^1.1.4" 48 57 ] 49 58 }