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.

fix(csv): handle ambiguous 4-column headers and standardise serialization

Lívia (May 17, 2026, 9:41 AM -0300) 1a425634 f4c481b0

+24 -15
+1 -6
api/lotus.ts
··· 1 - import { default as $romanisedArtists } from "~/third-party/lotus/romanised_artists.json" with { type: "json" }; 1 + import { default as romanisedArtists } from "~/third-party/lotus/romanised_artists.json" with { type: "json" }; 2 2 import { default as artists } from "~/third-party/lotus/artist.json" with { type: "json" }; 3 3 import { default as combinedArtists } from "~/third-party/lotus/combined_artists.json" with { type: "json" }; 4 - 5 - // overrides 6 - const romanisedArtists: Record<string, string> = { ...$romanisedArtists }; 7 - romanisedArtists["かめりあ"] = "Camellia"; 8 - romanisedArtists["ななひら"] = "Nanahira"; 9 4 10 5 function normalise(input: Record<string, string>): typeof lookup & ((key: string) => string) { 11 6 const lookup = Object.fromEntries(
+19 -5
lib/format/csv/codec.ts
··· 58 58 path: string, 59 59 header?: ColumnIndices, 60 60 ): Result<DocumentTrack, ParseError> { 61 - if (line === serializeHeader()) { 62 - return Fail(Errors.csv("invalid_columns", "header row", path)); 61 + const fields = split(line); 62 + 63 + const headers = getPossibleHeaders(fields); 64 + for (const h of headers) { 65 + if (h.join(",") === line) { 66 + return Fail(Errors.csv("invalid_columns", "header row", path)); 67 + } 63 68 } 64 - 65 - const fields = split(line); 66 69 67 70 if (fields.length < 3) { 68 71 return Fail( ··· 93 96 } 94 97 95 98 export function serializeTrack(track: DocumentTrack): string { 96 - return [track.artist, track.album, track.title, track.date].map(escape).join(","); 99 + return [track.artist, track.albumArtist ?? "", track.album, track.title, track.date].map(escape).join(","); 97 100 } 98 101 99 102 export function serializeHeader(): string { 100 103 return columnFields.join(","); 104 + } 105 + 106 + function getPossibleHeaders(segments: string[]): (readonly string[])[] { 107 + if (segments.length === 5) { 108 + return [columnFields]; 109 + } 110 + if (segments.length === 4) { 111 + return [columnFields.filter((f) => f !== "album_artist"), columnFields.filter((f) => f !== "date")]; 112 + } 113 + 114 + return [["artist", "album", "title"]]; 101 115 } 102 116 103 117 export function getColumnIndices(line: string): Result<ColumnIndices, ParseError> {
+2 -2
tests/codec/csv.ts
··· 27 27 28 28 Deno.test("CSV Codec: serializeTrack() correctly formats a row", () => { 29 29 const track: DocumentTrack = { artist: "A", album: "B", title: "C", date: "D" }; 30 - assertEquals(serializeTrack(track), "A,B,C,D"); 30 + assertEquals(serializeTrack(track), "A,,B,C,D"); 31 31 }); 32 32 33 33 Deno.test("CSV Codec: serializeHeader() matches expected format", () => { 34 - assertEquals(serializeHeader(), "artist,album,title,date"); 34 + assertEquals(serializeHeader(), "artist,album_artist,album,title,date"); 35 35 }); 36 36 37 37 Deno.test("CSV Codec: parseTrack() successfully parses a valid line", () => {
+2 -2
tests/io/csv.ts
··· 9 9 10 10 const tracks: DocumentTrack[] = [ 11 11 { artist: "A", album: "B", title: "C", date: "D" }, 12 - { artist: "E, F", album: "G", title: 'H "I"', date: "J" }, // Contains special chars 12 + { artist: "E, F", album: "G", title: 'H "I"', date: "J" }, 13 13 ]; 14 14 15 15 await t.step("save creates file", async () => { ··· 45 45 await t.step("mark skipped returns updated doc", async () => { 46 46 const markResult = await markSkipped(doc, 1); 47 47 assert(markResult.ok); 48 - assertEquals(markResult.value.rawLines[1], `${SKIP_PREFIX}A,B,C,D`); 48 + assertEquals(markResult.value.rawLines[1], `${SKIP_PREFIX}A,,B,C,D`); 49 49 assertEquals(markResult.value.skippedCount, 1); 50 50 }); 51 51