109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
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;
|
|
}
|