diff --git a/skills/spotify/src/cli.ts b/skills/spotify/src/cli.ts index fa323de..75baaef 100644 --- a/skills/spotify/src/cli.ts +++ b/skills/spotify/src/cli.ts @@ -4,6 +4,7 @@ import minimist from "minimist"; import { fileURLToPath } from "node:url"; import { getAuthStatus, runAuthorizationFlow } from "./auth.js"; +import { runImportCommand } from "./importers/index.js"; import { runAddToPlaylistCommand, runCreatePlaylistCommand, @@ -70,7 +71,7 @@ export function createDefaultHandlers(): CommandHandlers { "add-to-playlist": runAddToPlaylistCommand, "remove-from-playlist": runRemoveFromPlaylistCommand, "search-and-add": runSearchAndAddCommand, - import: notImplemented("import") + import: runImportCommand }; } diff --git a/skills/spotify/src/importers/folder.ts b/skills/spotify/src/importers/folder.ts new file mode 100644 index 0000000..16759e8 --- /dev/null +++ b/skills/spotify/src/importers/folder.ts @@ -0,0 +1,24 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; + +import type { ParsedTrackRef } from "../types.js"; +import { dedupeTrackRefs, isAudioFile, parseArtistTitle } from "./importer-utils.js"; + +async function walkAudioFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await walkAudioFiles(path)); + } else if (entry.isFile() && isAudioFile(entry.name)) { + files.push(path); + } + } + return files.sort(); +} + +export async function readFolder(path: string): Promise { + const files = await walkAudioFiles(path); + return dedupeTrackRefs(files.flatMap(parseArtistTitle)); +} diff --git a/skills/spotify/src/importers/importer-utils.ts b/skills/spotify/src/importers/importer-utils.ts new file mode 100644 index 0000000..4dc2d70 --- /dev/null +++ b/skills/spotify/src/importers/importer-utils.ts @@ -0,0 +1,90 @@ +import { basename, extname } from "node:path"; + +import type { ParsedTrackRef } from "../types.js"; + +const audioExtensions = new Set([".aac", ".aiff", ".alac", ".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav", ".wma"]); + +export function normalizeText(value: string): string { + return value.normalize("NFKC").replace(/[_\t]+/g, " ").replace(/\s+/g, " ").trim(); +} + +export function stripAudioExtension(filename: string): string { + const extension = extname(filename).toLowerCase(); + const base = basename(filename); + return audioExtensions.has(extension) ? base.slice(0, -extension.length) : base; +} + +export function isAudioFile(filename: string): boolean { + return audioExtensions.has(extname(filename).toLowerCase()); +} + +export function stripTrackNumberPrefix(value: string): string { + return normalizeText(value) + .replace(/^\d{1,3}\s*[-._)]\s*/u, "") + .replace(/^\d{1,3}\s+/u, ""); +} + +function ref(source: string, artist: string | undefined, title: string | undefined, query?: string): ParsedTrackRef { + const cleanedArtist = artist ? normalizeText(artist) : undefined; + const cleanedTitle = title ? normalizeText(title) : undefined; + return { + source, + query: normalizeText(query ?? [cleanedArtist, cleanedTitle].filter(Boolean).join(" ")), + ...(cleanedArtist ? { artist: cleanedArtist } : {}), + ...(cleanedTitle ? { title: cleanedTitle } : {}) + }; +} + +export function parseArtistTitle(value: string): ParsedTrackRef[] { + const source = normalizeText(stripTrackNumberPrefix(stripAudioExtension(value))); + if (!source) { + return []; + } + + const colon = source.match(/^(.+?)\s*:\s*(.+)$/u); + if (colon) { + return [ref(source, colon[1], colon[2])]; + } + + const dash = source.match(/^(.+?)\s+-\s+(.+)$/u); + if (dash) { + return [ref(source, dash[1], dash[2]), ref(source, dash[2], dash[1])]; + } + + const underscore = value.match(/^(.+?)_(.+)$/u); + if (underscore) { + return [ref(source, underscore[1], underscore[2])]; + } + + return [ref(source, undefined, source, source)]; +} + +export function dedupeTrackRefs(refs: ParsedTrackRef[]): ParsedTrackRef[] { + const seen = new Set(); + const output: ParsedTrackRef[] = []; + for (const item of refs) { + const key = `${normalizeText(item.artist ?? "").toLowerCase()}|${normalizeText(item.title ?? item.query).toLowerCase()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(item); + } + return output; +} + +export function buildSearchQueries(ref: ParsedTrackRef): string[] { + const queries = new Set(); + if (ref.artist && ref.title) { + queries.add(`${ref.artist} ${ref.title}`); + queries.add(`track:${ref.title} artist:${ref.artist}`); + } + queries.add(ref.query); + return Array.from(queries).map(normalizeText).filter(Boolean); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const DEFAULT_IMPORT_SEARCH_DELAY_MS = 100; diff --git a/skills/spotify/src/importers/index.ts b/skills/spotify/src/importers/index.ts new file mode 100644 index 0000000..7fee195 --- /dev/null +++ b/skills/spotify/src/importers/index.ts @@ -0,0 +1,108 @@ +import { stat } from "node:fs/promises"; +import { extname } from "node:path"; + +import { createSpotifyApiClient, type SpotifyApiClient } from "../api-client.js"; +import type { CliDeps, ParsedCli } from "../cli.js"; +import type { ImportResult, ParsedTrackRef, SpotifyTrack } from "../types.js"; +import { DEFAULT_IMPORT_SEARCH_DELAY_MS, buildSearchQueries, sleep as defaultSleep } from "./importer-utils.js"; +import { readFolder } from "./folder.js"; +import { readM3u } from "./m3u.js"; +import { readTextList } from "./text-list.js"; + +type ImportClient = Pick; + +export interface ImportOptions { + playlist?: string; + playlistId?: string; + public?: boolean; + delayMs?: number; + sleep?: (ms: number) => Promise; +} + +export async function readImportSource(path: string): Promise { + const info = await stat(path); + if (info.isDirectory()) { + return readFolder(path); + } + const extension = extname(path).toLowerCase(); + if (extension === ".m3u" || extension === ".m3u8") { + return readM3u(path); + } + return readTextList(path); +} + +async function findTrack(ref: ParsedTrackRef, client: Pick): Promise { + for (const query of buildSearchQueries(ref)) { + const tracks = await client.searchTracks(query, 1); + if (tracks[0]) { + return tracks[0]; + } + } + return undefined; +} + +export async function importTracks( + path: string, + options: ImportOptions, + client: ImportClient = createSpotifyApiClient() +): Promise { + if (Boolean(options.playlist) === Boolean(options.playlistId)) { + throw new Error("Specify exactly one of --playlist or --playlist-id."); + } + + const refs = await readImportSource(path); + const wait = options.sleep ?? defaultSleep; + const found: ImportResult["found"] = []; + const missed: ImportResult["missed"] = []; + const foundUris = new Set(); + + for (const [index, ref] of refs.entries()) { + const track = await findTrack(ref, client); + if (!track) { + missed.push({ ...ref, reason: "No Spotify match found" }); + } else if (!foundUris.has(track.uri)) { + foundUris.add(track.uri); + found.push({ ...ref, uri: track.uri, matchedName: track.name, matchedArtists: track.artists.map((artist) => artist.name) }); + } + if (options.delayMs !== 0 && index < refs.length - 1) { + await wait(options.delayMs ?? DEFAULT_IMPORT_SEARCH_DELAY_MS); + } + } + + const playlistId = options.playlistId ?? (await client.createPlaylist(options.playlist ?? "", { public: Boolean(options.public) })).id; + const mutationResults = found.length > 0 + ? await client.addItemsToPlaylist(playlistId, found.map((item) => item.uri)) + : []; + + return { + found, + missed, + added: { + playlistId, + count: found.length, + snapshotIds: mutationResults.map((result) => result.snapshot_id).filter((id): id is string => Boolean(id)) + } + }; +} + +export async function runImportCommand( + args: ParsedCli, + deps: CliDeps, + client: ImportClient = createSpotifyApiClient() +): Promise { + const [path] = args.positional; + if (!path) { + throw new Error("Missing import path."); + } + const result = await importTracks(path, { + playlist: args.playlist, + playlistId: args.playlistId, + public: args.public + }, client); + if (args.json) { + deps.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + deps.stdout.write(`Imported ${result.found.length} track(s); missed ${result.missed.length}; playlist ${result.added?.playlistId}.\n`); + } + return 0; +} diff --git a/skills/spotify/src/importers/m3u.ts b/skills/spotify/src/importers/m3u.ts new file mode 100644 index 0000000..a6d5665 --- /dev/null +++ b/skills/spotify/src/importers/m3u.ts @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +import type { ParsedTrackRef } from "../types.js"; +import { dedupeTrackRefs, parseArtistTitle } from "./importer-utils.js"; + +function parseExtInf(line: string): string | undefined { + const comma = line.indexOf(","); + if (comma === -1 || comma === line.length - 1) { + return undefined; + } + return line.slice(comma + 1).trim(); +} + +export function parseM3u(content: string): ParsedTrackRef[] { + const refs: ParsedTrackRef[] = []; + let pendingExtInf: string | undefined; + for (const rawLine of content.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line) { + continue; + } + if (line.startsWith("#EXTINF:")) { + pendingExtInf = parseExtInf(line); + continue; + } + if (line.startsWith("#")) { + continue; + } + refs.push(...parseArtistTitle(pendingExtInf ?? basename(line))); + pendingExtInf = undefined; + } + return dedupeTrackRefs(refs); +} + +export async function readM3u(path: string): Promise { + return parseM3u(await readFile(path, "utf8")); +} diff --git a/skills/spotify/src/importers/text-list.ts b/skills/spotify/src/importers/text-list.ts new file mode 100644 index 0000000..2c62255 --- /dev/null +++ b/skills/spotify/src/importers/text-list.ts @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; + +import type { ParsedTrackRef } from "../types.js"; +import { dedupeTrackRefs, parseArtistTitle } from "./importer-utils.js"; + +export function parseTextList(content: string): ParsedTrackRef[] { + const refs = content + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#") && !line.startsWith("//")) + .flatMap(parseArtistTitle); + return dedupeTrackRefs(refs); +} + +export async function readTextList(path: string): Promise { + return parseTextList(await readFile(path, "utf8")); +} diff --git a/skills/spotify/tests/cli.test.ts b/skills/spotify/tests/cli.test.ts index d113dff..56c869a 100644 --- a/skills/spotify/tests/cli.test.ts +++ b/skills/spotify/tests/cli.test.ts @@ -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(), ""); -}); diff --git a/skills/spotify/tests/folder.test.ts b/skills/spotify/tests/folder.test.ts new file mode 100644 index 0000000..b6cfe13 --- /dev/null +++ b/skills/spotify/tests/folder.test.ts @@ -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); +}); diff --git a/skills/spotify/tests/import.test.ts b/skills/spotify/tests/import.test.ts new file mode 100644 index 0000000..4713bce --- /dev/null +++ b/skills/spotify/tests/import.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/importer-utils.test.ts b/skills/spotify/tests/importer-utils.test.ts new file mode 100644 index 0000000..24098bd --- /dev/null +++ b/skills/spotify/tests/importer-utils.test.ts @@ -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" + ]); +}); diff --git a/skills/spotify/tests/m3u.test.ts b/skills/spotify/tests/m3u.test.ts new file mode 100644 index 0000000..1d4883a --- /dev/null +++ b/skills/spotify/tests/m3u.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/text-list.test.ts b/skills/spotify/tests/text-list.test.ts new file mode 100644 index 0000000..623dad6 --- /dev/null +++ b/skills/spotify/tests/text-list.test.ts @@ -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"); +});