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: initial cli pipeline logic

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

+190 -4
+173
cli/pipeline.ts
··· 1 + import { type Config } from "~/lib/config.ts"; 2 + import { countScrobblesInRange, scrobble, ScrobblePayload, ScrobbleResult } from "~/api/lastfm.ts"; 3 + import { CsvDocument, DocumentTrack, markSkipped } from "~/lib/csv.ts"; 4 + import { AppError, describe } from "~/lib/errors.ts"; 5 + import { sleep, withRetryR } from "~/utils/retry.ts"; 6 + 7 + const DAILY_SCROBBLE_LIMIT: number = 2880; 8 + 9 + export interface PipelineOptions { 10 + config: Omit<Required<Config>, "password">; 11 + sessionKey: string; 12 + dryRun?: boolean; 13 + delayMs?: number; 14 + } 15 + 16 + export interface PipelineSummary { 17 + total: number; 18 + accepted: number; 19 + ignored: number; 20 + failed: number; 21 + skipped: number; 22 + } 23 + 24 + export interface PendingEntry { 25 + track: DocumentTrack; 26 + index: number; 27 + payload: ScrobblePayload; 28 + } 29 + 30 + export interface LimitCheckResult { 31 + scrobblesToday: number; 32 + remaining: number; 33 + importCount: number; 34 + wouldExceed: boolean; 35 + excess: number; 36 + } 37 + 38 + export async function checkDailyLimit( 39 + apiKey: string, 40 + username: string, 41 + importCount: number, 42 + ): Promise<LimitCheckResult> { 43 + const begin = Math.floor( 44 + Date.UTC( 45 + new Date().getUTCFullYear(), 46 + new Date().getUTCMonth(), 47 + new Date().getUTCDate(), 48 + ) / 1000, 49 + ); 50 + const now = Math.floor(Date.now() / 1e3); 51 + 52 + const result = await countScrobblesInRange(apiKey, username, begin, now); 53 + const scrobblesToday = result.ok ? result.value : 0; 54 + 55 + const remaining = Math.max( 56 + 0, 57 + DAILY_SCROBBLE_LIMIT - scrobblesToday, 58 + ); 59 + const wouldExceed = importCount > remaining; 60 + const excess = wouldExceed ? importCount - remaining : 0; 61 + 62 + return { scrobblesToday, remaining, importCount, wouldExceed, excess }; 63 + } 64 + 65 + /** 66 + * determines if a Last.fm error is worth retrying. 67 + * 68 + * transient: rate limit (29), service offline (11), operation failed (8) 69 + * fatal: bad auth (4, 9, 10), invalid params (6), suspended key (26) 70 + */ 71 + export function isRetryable(error: unknown): boolean { 72 + if (typeof error === "object" && error !== null) { 73 + const e = error as AppError; 74 + if (e.kind === "network") return true; 75 + if (e.kind === "lastfm") { 76 + return [8, 11, 16, 29].includes(e.tag); 77 + } 78 + } 79 + return false; 80 + } 81 + 82 + export async function runPipeline( 83 + pending: PendingEntry[], 84 + file: CsvDocument, 85 + opts: PipelineOptions, 86 + onProgress: ( 87 + current: number, 88 + total: number, 89 + track: DocumentTrack, 90 + result: "ok" | "ignored" | "failed", 91 + detail?: string, 92 + ) => void, 93 + ): Promise<PipelineSummary> { 94 + const summary: PipelineSummary = { 95 + total: pending.length, 96 + accepted: 0, 97 + ignored: 0, 98 + failed: 0, 99 + skipped: 0, 100 + }; 101 + 102 + const delay = opts.delayMs ?? 100; 103 + let currentFile = file; 104 + 105 + for (const [i, entry] of pending.entries()) { 106 + const { track, index, payload } = entry; 107 + 108 + if (opts.dryRun) { 109 + summary.accepted++; 110 + onProgress(i + 1, pending.length, track, "ok"); 111 + continue; 112 + } 113 + 114 + try { 115 + const result: ScrobbleResult = await withRetryR( 116 + () => scrobble(opts.config.apiKey, opts.config.secret, opts.sessionKey, payload), 117 + { 118 + maxAttempts: 4, 119 + baseDelayMs: 500, 120 + retryIf: isRetryable, 121 + onRetry: (attempt, delayMs) => { 122 + console.error(` ↺ Retry ${attempt} for "${track.title}" in ${delayMs}ms...`); 123 + }, 124 + }, 125 + ); 126 + 127 + if (result.ignored) { 128 + summary.ignored += result.ignored; 129 + onProgress(i + 1, pending.length, track, "ignored"); 130 + } else { 131 + summary.accepted += result.accepted; 132 + onProgress(i + 1, pending.length, track, "ok"); 133 + } 134 + 135 + const marked = markSkipped(currentFile, index); 136 + if (marked.ok) currentFile = marked.value; 137 + } catch (e) { 138 + summary.failed++; 139 + const msg = describe(e as AppError); 140 + onProgress(i + 1, pending.length, track, "failed", msg); 141 + } 142 + 143 + if (i < pending.length - 1) await sleep(delay); 144 + } 145 + 146 + return summary; 147 + } 148 + 149 + /** 150 + * generate evenly-spaced timestamps for tracks that have no date. 151 + * starts 13.9 days before now and steps 30s per track 152 + */ 153 + export function generateTimestamps(count: number): number[] { 154 + const begin = Math.floor(Date.now() / 1_000) - 1_200_960; 155 + return Array.from({ length: count }, (_, i) => begin + (i + 1) * 30); 156 + } 157 + 158 + export function trackToPipelineEntry( 159 + track: DocumentTrack, 160 + index: number, 161 + timestamp: number, 162 + ): PendingEntry { 163 + return { 164 + track, 165 + index, 166 + payload: { 167 + artist: track.artist, 168 + album: track.album || undefined, 169 + title: track.title, 170 + timestamp, 171 + }, 172 + }; 173 + }
+4 -4
lib/csv.ts
··· 5 5 6 6 const COLUMNS = ["artist", "album", "title", "date"] as const; 7 7 8 - export interface Track { 8 + export interface DocumentTrack { 9 9 artist: string; 10 10 album: string; 11 11 title: string; ··· 15 15 export interface CsvDocument { 16 16 path: string; 17 17 rawLines: string[]; 18 - pending: Array<{ track: Track; lineIndex: number }>; 18 + pending: Array<{ track: DocumentTrack; lineIndex: number }>; 19 19 skippedCount: number; 20 20 } 21 21 ··· 83 83 return Ok({ ...doc, rawLines }); 84 84 } 85 85 86 - export function saveCsvDocument(path: string, tracks: Track[]): Result<void> { 86 + export function saveCsvDocument(path: string, tracks: DocumentTrack[]): Result<void> { 87 87 const header = COLUMNS.join(","); 88 88 const rows = tracks.map((t) => [escape(t.artist), escape(t.album), escape(t.title), escape(t.date)].join(",")); 89 89 ··· 99 99 line: string, 100 100 index: number, 101 101 path: string, 102 - ): Result<Track> { 102 + ): Result<DocumentTrack> { 103 103 const fields = split(line); 104 104 105 105 if (fields.length < 3) {
+13
utils/retry.ts
··· 1 + import { Result } from "~/lib/result.ts"; 2 + 1 3 export interface RetryOptions { 2 4 maxAttempts?: number; 3 5 baseDelayMs?: number; ··· 17 19 function jitteredDelay(attempt: number, base: number, cap: number): number { 18 20 const ceiling = Math.min(cap, base * (2 ** attempt)); 19 21 return Math.floor(Math.random() * ceiling); 22 + } 23 + 24 + export function withRetryR<L, R = never>( 25 + fn: () => Promise<Result<L, R>>, 26 + opts: RetryOptions = {}, 27 + ): Promise<L> { 28 + return withRetry(() => 29 + fn().then((result) => { 30 + if (!result.ok) throw result.error; 31 + return result.value; 32 + }), opts); 20 33 } 21 34 22 35 export async function withRetry<T>(