feat(spotify): implement milestone M4 importers
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
24
skills/spotify/src/importers/folder.ts
Normal file
24
skills/spotify/src/importers/folder.ts
Normal file
@@ -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<string[]> {
|
||||
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<ParsedTrackRef[]> {
|
||||
const files = await walkAudioFiles(path);
|
||||
return dedupeTrackRefs(files.flatMap(parseArtistTitle));
|
||||
}
|
||||
90
skills/spotify/src/importers/importer-utils.ts
Normal file
90
skills/spotify/src/importers/importer-utils.ts
Normal file
@@ -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<string>();
|
||||
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<string>();
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const DEFAULT_IMPORT_SEARCH_DELAY_MS = 100;
|
||||
108
skills/spotify/src/importers/index.ts
Normal file
108
skills/spotify/src/importers/index.ts
Normal file
@@ -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<SpotifyApiClient, "searchTracks" | "createPlaylist" | "addItemsToPlaylist">;
|
||||
|
||||
export interface ImportOptions {
|
||||
playlist?: string;
|
||||
playlistId?: string;
|
||||
public?: boolean;
|
||||
delayMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function readImportSource(path: string): Promise<ParsedTrackRef[]> {
|
||||
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<ImportClient, "searchTracks">): Promise<SpotifyTrack | undefined> {
|
||||
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<ImportResult> {
|
||||
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<string>();
|
||||
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
38
skills/spotify/src/importers/m3u.ts
Normal file
38
skills/spotify/src/importers/m3u.ts
Normal file
@@ -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<ParsedTrackRef[]> {
|
||||
return parseM3u(await readFile(path, "utf8"));
|
||||
}
|
||||
17
skills/spotify/src/importers/text-list.ts
Normal file
17
skills/spotify/src/importers/text-list.ts
Normal file
@@ -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<ParsedTrackRef[]> {
|
||||
return parseTextList(await readFile(path, "utf8"));
|
||||
}
|
||||
@@ -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(), "");
|
||||
});
|
||||
|
||||
21
skills/spotify/tests/folder.test.ts
Normal file
21
skills/spotify/tests/folder.test.ts
Normal 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);
|
||||
});
|
||||
98
skills/spotify/tests/import.test.ts
Normal file
98
skills/spotify/tests/import.test.ts
Normal 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");
|
||||
});
|
||||
52
skills/spotify/tests/importer-utils.test.ts
Normal file
52
skills/spotify/tests/importer-utils.test.ts
Normal 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"
|
||||
]);
|
||||
});
|
||||
38
skills/spotify/tests/m3u.test.ts
Normal file
38
skills/spotify/tests/m3u.test.ts
Normal 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");
|
||||
});
|
||||
33
skills/spotify/tests/text-list.test.ts
Normal file
33
skills/spotify/tests/text-list.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user