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

@@ -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;
}