feat(spotify): implement milestone M4 importers

This commit is contained in:
2026-04-12 02:00:50 -05:00
parent d8570edcf0
commit 141488c0f2
12 changed files with 521 additions and 14 deletions

View File

@@ -70,16 +70,3 @@ test("dispatches known command with json flag", async () => {
});
assert.equal(stderr.output(), "");
});
test("known unimplemented commands return structured placeholder errors in json mode", async () => {
const stdout = createBuffer();
const stderr = createBuffer();
const code = await runCli(["import", "--json"], { stdout: stdout.stream, stderr: stderr.stream });
assert.equal(code, 2);
assert.deepEqual(JSON.parse(stdout.output()), {
ok: false,
error: "Command not implemented yet: import"
});
assert.equal(stderr.output(), "");
});

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { readFolder } from "../src/importers/folder.js";
test("recursively reads audio filenames and ignores non-audio files", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-folder-"));
await mkdir(join(root, "nested"));
await writeFile(join(root, "01 - Radiohead - Karma Police.mp3"), "");
await writeFile(join(root, "cover.jpg"), "");
await writeFile(join(root, "nested", "02 - Massive Attack - Teardrop.flac"), "");
const refs = await readFolder(root);
assert.equal(refs.some((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police"), true);
assert.equal(refs.some((ref) => ref.artist === "Massive Attack" && ref.title === "Teardrop"), true);
assert.equal(refs.some((ref) => ref.source.includes("cover")), false);
});

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { importTracks, readImportSource, runImportCommand } from "../src/importers/index.js";
import type { CliDeps } from "../src/cli.js";
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
let stdout = "";
let stderr = "";
return {
deps: {
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
},
stdout: () => stdout,
stderr: () => stderr
};
}
test("auto-detects text imports by extension fallback", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.csv");
await writeFile(path, "Radiohead - Karma Police\n");
const refs = await readImportSource(path);
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});
test("import creates a new private playlist for --playlist and records misses", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\nMissing Song\n");
let createdPublic: boolean | undefined;
let addedUris: string[] = [];
const result = await importTracks(path, { playlist: "New Mix", delayMs: 0 }, {
createPlaylist: async (_name, options) => {
createdPublic = options?.public;
return { id: "playlist-id", uri: "spotify:playlist:playlist-id", name: "New Mix", public: false, owner: { id: "owner" } };
},
searchTracks: async (query) => query.includes("Missing")
? []
: [{ id: "track-id", uri: "spotify:track:track-id", name: "Karma Police", artists: [{ name: "Radiohead" }] }],
addItemsToPlaylist: async (_playlistId, uris) => {
addedUris = uris;
return [{ snapshot_id: "snap" }];
}
});
assert.equal(createdPublic, false);
assert.deepEqual(addedUris, ["spotify:track:track-id"]);
assert.equal(result.found.length, 1);
assert.equal(result.missed.length, 1);
assert.deepEqual(result.added, { playlistId: "playlist-id", count: 1, snapshotIds: ["snap"] });
});
test("import updates explicit playlist id without creating a new playlist", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\n");
let created = false;
const result = await importTracks(path, { playlistId: "existing", delayMs: 0 }, {
createPlaylist: async () => {
created = true;
throw new Error("should not create");
},
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Karma Police", artists: [{ name: "Radiohead" }] }],
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }]
});
assert.equal(created, false);
assert.equal(result.added?.playlistId, "existing");
});
test("import command writes JSON result", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\n");
const io = createDeps();
await runImportCommand(
{ command: "import", positional: [path], playlistId: "existing", json: true },
io.deps,
{
createPlaylist: async () => { throw new Error("should not create"); },
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Karma Police", artists: [{ name: "Radiohead" }] }],
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }]
}
);
assert.equal(JSON.parse(io.stdout()).added.playlistId, "existing");
});

View File

@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildSearchQueries,
dedupeTrackRefs,
isAudioFile,
normalizeText,
parseArtistTitle,
stripAudioExtension,
stripTrackNumberPrefix
} from "../src/importers/importer-utils.js";
test("normalizes whitespace and underscores", () => {
assert.equal(normalizeText(" Radiohead__Karma\tPolice "), "Radiohead Karma Police");
});
test("strips audio extensions and track number prefixes", () => {
assert.equal(stripAudioExtension("01 - Radiohead - Karma Police.mp3"), "01 - Radiohead - Karma Police");
assert.equal(stripTrackNumberPrefix("01 - Radiohead - Karma Police"), "Radiohead - Karma Police");
assert.equal(isAudioFile("song.FLAC"), true);
assert.equal(isAudioFile("cover.jpg"), false);
});
test("parses artist title patterns", () => {
assert.deepEqual(parseArtistTitle("Radiohead - Karma Police"), [
{ source: "Radiohead - Karma Police", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" },
{ source: "Radiohead - Karma Police", query: "Karma Police Radiohead", artist: "Karma Police", title: "Radiohead" }
]);
assert.deepEqual(parseArtistTitle("Radiohead: Karma Police"), [
{ source: "Radiohead: Karma Police", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" }
]);
assert.deepEqual(parseArtistTitle("Radiohead_Karma Police"), [
{ source: "Radiohead Karma Police", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" }
]);
});
test("dedupes normalized artist title refs", () => {
const refs = dedupeTrackRefs([
{ source: "a", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" },
{ source: "b", query: "radiohead karma police", artist: " radiohead ", title: "karma police" }
]);
assert.equal(refs.length, 1);
});
test("builds fallback search queries", () => {
assert.deepEqual(buildSearchQueries({ source: "x", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" }), [
"Radiohead Karma Police",
"track:Karma Police artist:Radiohead"
]);
});

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { parseM3u, readM3u } from "../src/importers/m3u.js";
test("parses EXTINF metadata and ignores comments", () => {
const refs = parseM3u(`#EXTM3U
#EXTINF:123,Radiohead - Karma Police
/music/01 - ignored filename.mp3
# comment
#EXTINF:123,Massive Attack - Teardrop
/music/02 - fallback.mp3
`);
assert.equal(refs.some((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police"), true);
assert.equal(refs.some((ref) => ref.artist === "Massive Attack" && ref.title === "Teardrop"), true);
});
test("falls back to filename when EXTINF is absent", () => {
const refs = parseM3u("/music/01 - Radiohead - Karma Police.flac\n");
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});
test("reads m3u from file", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-m3u-"));
const path = join(root, "playlist.m3u8");
await writeFile(path, "#EXTINF:123,Radiohead - Karma Police\n/music/track.mp3\n");
const refs = await readM3u(path);
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { parseTextList, readTextList } from "../src/importers/text-list.js";
test("parses text list with comments, blanks, and dedupe", () => {
const refs = parseTextList(`
# favorites
Radiohead - Karma Police
Radiohead: Karma Police
// ignored
Massive Attack - Teardrop
`);
assert.equal(refs.some((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police"), true);
assert.equal(refs.some((ref) => ref.artist === "Massive Attack" && ref.title === "Teardrop"), true);
assert.equal(refs.filter((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police").length, 1);
});
test("reads text list from file", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-text-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\n");
const refs = await readTextList(path);
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});