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.

Parse CSV header (if present) and get the correct column indices

authored by

Nightdavisao and committed by
Lívia
(May 17, 2026, 9:41 AM -0300) 81f87266 5f3ac0b4

+65 -14
+5
api/lastfm.ts
··· 62 62 63 63 export interface ScrobblePayload { 64 64 artist: string; 65 + albumArtist?: string; 65 66 album?: string; 66 67 title: string; 67 68 timestamp: number; ··· 189 190 const suffix = inputs.length > 1 ? `[${i}]` : ""; 190 191 191 192 body[`artist${suffix}`] = s.artist; 193 + // according to last.fm's documentation, the album artist should *only* be sent if it differs from the artist. 194 + if (s.albumArtist && s.albumArtist !== s.artist) { 195 + body[`albumArtist${suffix}`] = s.albumArtist; 196 + } 192 197 body[`track${suffix}`] = s.title; 193 198 body[`timestamp${suffix}`] = s.timestamp; 194 199 if (s.album) {
+3 -3
commands/import.ts
··· 16 16 <path> Path to either a .scrobbler.log or .csv file. 17 17 18 18 Options: 19 - -n, --dry-run Simulate the import without scrobbling or modifying the file 20 - -s, --no-skip-marker Don't mark any track as skipped after importing (doesn't touch the provided file in any way) 21 - -h, --help Show this help message 19 + -n, --dry-run Simulate the import without scrobbling or modifying the file 20 + -s, --no-skip-marker Don't mark any track as skipped after importing (doesn't touch the provided file in any way) 21 + -h, --help Show this help message 22 22 `; 23 23 24 24 export async function executeImportCommand(args: string[] = Deno.args): Promise<Result<void, AppError>> {
+4 -3
lib/errors.ts
··· 53 53 54 54 export type CsvErrorReason = 55 55 | IOError 56 - | "invalid_columns"; 56 + | "invalid_columns" 57 + | "required_header_fields_missing"; 57 58 58 59 export interface CsvError extends TaggedError<"csv", CsvErrorReason> { 59 60 readonly message: string; 60 - readonly path: string; 61 + readonly path: string | undefined; 61 62 } 62 63 63 64 export interface AuthError extends BaseError<"auth"> { ··· 79 80 80 81 config: (tag: ConfigErrorReason, message: string): ConfigError => ({ kind: "config", tag, message }), 81 82 82 - csv: (tag: CsvErrorReason, message: string, path: string): CsvError => ({ kind: "csv", tag, message, path }), 83 + csv: (tag: CsvErrorReason, message: string, path?: string | undefined): CsvError => ({ kind: "csv", tag, message, path }), 83 84 84 85 auth: (message: string): AuthError => ({ kind: "auth", message }), 85 86
+43 -4
lib/format/csv/codec.ts
··· 54 54 line: string, 55 55 lineIndex: number, 56 56 path: string, 57 + header?: Record<string, number>, 57 58 ): Result<DocumentTrack, ParseError> { 58 59 if (line === serializeHeader()) { 59 60 return Fail(Errors.csv("invalid_columns", "header row", path)); ··· 71 72 ); 72 73 } 73 74 75 + if (!header) { 76 + return Ok({ 77 + artist: unescape(fields[0] ?? ""), 78 + album: unescape(fields[1] ?? ""), 79 + title: unescape(fields[2] ?? ""), 80 + date: unescape(fields[3] ?? ""), 81 + }); 82 + } 83 + 74 84 return Ok({ 75 - artist: unescape(fields[0] ?? ""), 76 - album: unescape(fields[1] ?? ""), 77 - title: unescape(fields[2] ?? ""), 78 - date: unescape(fields[3] ?? ""), 85 + artist: unescape(fields[header["artist"]]), 86 + albumArtist: unescape(fields[header["album_artist"]] ?? ""), 87 + album: unescape(fields[header["album"]] ?? ""), 88 + title: unescape(fields[header["title"]]), 89 + date: unescape(fields[header["date"]] ?? ""), 79 90 }); 80 91 } 81 92 ··· 86 97 export function serializeHeader(): string { 87 98 return CSV_HEADER.join(","); 88 99 } 100 + 101 + export function getColumnIndices(line: string): Result<Record<string, number>, ParseError> { 102 + const fields = split(line); 103 + const returns: Record<string, number> = {}; 104 + const requiredFields = ["artist", "title"]; 105 + 106 + CSV_HEADER.forEach((currentValue: string) => { 107 + for (let i = 0; i < fields.length; i++) { 108 + const field = fields[i]; 109 + 110 + if (currentValue === field) { 111 + returns[currentValue] = i; 112 + break; 113 + } 114 + } 115 + }); 116 + 117 + for (const required of requiredFields) { 118 + if (!Object.keys(returns).includes(required)) { 119 + return Fail(Errors.csv( 120 + "required_header_fields_missing", 121 + `expected at least ${requiredFields.length} header fields (${requiredFields.join(", ")})`, 122 + )); 123 + } 124 + } 125 + 126 + return Ok(returns); 127 + }
+8 -3
lib/format/csv/io.ts
··· 1 1 import { Errors } from "~/lib/errors.ts"; 2 2 import { CsvDocument, DocumentTrack, SKIP_PREFIX } from "~/lib/format/csv/mod.ts"; 3 - import { parseTrack, serializeHeader, serializeTrack } from "~/lib/format/csv/codec.ts"; 3 + import { getColumnIndices, parseTrack, serializeHeader, serializeTrack } from "~/lib/format/csv/codec.ts"; 4 4 import { Fail, Ok, Result } from "~/lib/result.ts"; 5 5 6 6 type IOError = ReturnType<typeof Errors.csv>; ··· 17 17 } 18 18 19 19 const data = raw.split("\n"); 20 + 21 + if (data.length == 0) { 22 + return Fail(Errors.csv("parse_failed", "file is possibly blank or not properly formatted", path)) 23 + } 20 24 21 25 const pending: CsvDocument["pending"][number][] = []; 26 + const header = getColumnIndices(data[0]) 22 27 let skippedCount = 0; 23 28 24 - for (let i = +data[0]?.startsWith(serializeHeader()); i < data.length; i++) { 29 + for (let i = +header.ok; i < data.length; i++) { 25 30 const line = data[i]; 26 31 27 32 if (!line.trim()) continue; ··· 30 35 continue; 31 36 } 32 37 33 - const parsed = parseTrack(line, i, path); 38 + const parsed = parseTrack(line, i, path, header.ok ? header.value : undefined) 34 39 if (!parsed.ok) return parsed; 35 40 36 41 pending.push({ track: parsed.value, lineIndex: i });
+2 -1
lib/format/csv/mod.ts
··· 2 2 3 3 export interface DocumentTrack { 4 4 readonly artist: string; 5 + readonly albumArtist?: string; 5 6 readonly album: string; 6 7 readonly title: string; 7 8 readonly date: string; 8 9 } 9 10 10 - export const CSV_HEADER: readonly string[] = ["artist", "album", "title", "date"] as const; 11 + export const CSV_HEADER: readonly string[] = ["artist", "album_artist", "album", "title", "date"] as const; 11 12 12 13 /** 13 14 * immutable state representing a loaded csv document in memory