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