import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { ensureSpotifyAccessToken } from "./spotifyServer"; const DESKTOP = process.env.SPOTIRIP_DESKTOP === "1"; const PYTHON = process.env.SPOTIRIP_PYTHON || "venv/bin/python"; const DOWNLOADER = process.env.SPOTIRIP_DOWNLOADER || "spotify_downloader.py"; const DOWNLOADER_BIN = process.env.SPOTIRIP_DOWNLOADER_BIN; const EVENT_PREFIX = "@@SPOTIRIP "; const MAX_RUNNING_JOBS = Number(process.env.SPOTIRIP_MAX_DOWNLOADS) || (DESKTOP ? 3 : 2); const MAX_RUNNING_JOBS_PER_USER = DESKTOP ? MAX_RUNNING_JOBS : 1; const FINISHED_JOB_TTL_MS = 30 * 60 * 1000; export type DownloadKind = "track" | "playlist"; export type DownloadJob = { id: string; ownerId: string; kind: DownloadKind; status: "running" | "ready" | "failed"; done: number; total: number; failed: number; currentTitle?: string; error?: string; filePath?: string; fileName?: string; savedPath?: string; workDir: string; isTempDir: boolean; finishedAt?: number; }; const jobs = new Map(); const userIdCache = new Map(); function allowedUserIds() { return (process.env.SPOTIRIP_DOWNLOAD_USERS || "*") .split(",") .map((id) => id.trim()) .filter(Boolean); } export function isDownloadAllowed(spotifyUserId: string | undefined) { if (!spotifyUserId) return false; const allowed = allowedUserIds(); return allowed.includes("*") || allowed.includes(spotifyUserId); } async function spotifyUserId(accessToken: string) { const cached = userIdCache.get(accessToken); if (cached && cached.expiresAt > Date.now()) return cached.id; const response = await fetch("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) return undefined; const profile = (await response.json()) as { id: string }; userIdCache.set(accessToken, { id: profile.id, expiresAt: Date.now() + 10 * 60 * 1000 }); return profile.id; } export async function requireDownloadAccess(request: Request) { const session = await ensureSpotifyAccessToken(request); if (!session.accessToken) { return { error: jsonError("Connect Spotify before downloading.", 401) }; } const userId = await spotifyUserId(session.accessToken); if (!isDownloadAllowed(userId)) { return { error: jsonError("Downloads are not enabled for this Spotify account.", 403) }; } return { userId: userId!, setCookies: session.setCookies }; } export function jsonError(message: string, status: number) { return new Response(JSON.stringify({ error: message }), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, }); } function removeJob(job: DownloadJob) { jobs.delete(job.id); if (job.isTempDir) rmSync(job.workDir, { recursive: true, force: true }); } function safeFolderName(name: string) { const cleaned = name.replace(/[\\/:*?"<>|\x00-\x1f]+/g, " ").replace(/\s+/g, " ").trim().replace(/^\.+|\.+$/g, ""); return cleaned.slice(0, 150) || "Spotify playlist"; } function desktopOutputDir(kind: DownloadKind, name?: string) { const folder = process.env.SPOTIRIP_DOWNLOAD_FOLDER; if (!folder) throw new Error("Choose a download folder in Settings first."); const dir = kind === "playlist" ? join(folder, safeFolderName(name || "")) : folder; mkdirSync(dir, { recursive: true }); return dir; } const sweeper = setInterval(() => { const cutoff = Date.now() - FINISHED_JOB_TTL_MS; for (const job of jobs.values()) { if (job.finishedAt && job.finishedAt < cutoff) removeJob(job); } }, 60 * 1000); sweeper.unref(); export function startDownloadJob(options: { ownerId: string; kind: DownloadKind; urls: string[]; name?: string }) { const running = [...jobs.values()].filter((job) => job.status === "running"); if (running.filter((job) => job.ownerId === options.ownerId).length >= MAX_RUNNING_JOBS_PER_USER) { throw new Error("You already have a download in progress. Wait for it to finish first."); } if (running.length >= MAX_RUNNING_JOBS) { throw new Error("The server is busy with other downloads. Try again in a few minutes."); } const workDir = DESKTOP ? desktopOutputDir(options.kind, options.name) : mkdtempSync(join(tmpdir(), "spotirip-")); const job: DownloadJob = { id: randomUUID(), ownerId: options.ownerId, kind: options.kind, status: "running", done: 0, total: 0, failed: 0, workDir, isTempDir: !DESKTOP, }; jobs.set(job.id, job); const args = ["--urls-stdin", "--output-dir", workDir, "--json"]; if (options.kind === "playlist" && !DESKTOP) args.push("--zip", "--zip-name", options.name || ""); const child = spawn(DOWNLOADER_BIN || resolve(PYTHON), DOWNLOADER_BIN ? args : [resolve(DOWNLOADER), ...args], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, SPOTIRIP_JS_RUNTIME: process.execPath, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), }, }); child.stdin.on("error", () => undefined); child.stdin.end(options.urls.join("\n") + "\n"); let buffered = ""; let stderrTail = ""; let outputTail = ""; child.stdout.on("data", (chunk: Buffer) => { buffered += chunk.toString("utf8"); const lines = buffered.split("\n"); buffered = lines.pop() || ""; for (const line of lines) { if (!line.startsWith(EVENT_PREFIX)) { outputTail = (outputTail + line + "\n").slice(-2000); continue; } try { handleEvent(job, JSON.parse(line.slice(EVENT_PREFIX.length))); } catch { } } }); child.stderr.on("data", (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000); }); child.on("error", (error) => finishWithError(job, `Could not start the downloader: ${error.message}`)); child.on("close", (code) => { if (code !== 0) console.error(`spotify_downloader.py exited with ${code}:\n${outputTail}${stderrTail}`); if (job.status !== "running") return; finishWithError(job, "The downloader stopped unexpectedly."); }); return job; } function handleEvent(job: DownloadJob, event: Record) { if (event.event === "found") { job.total = Number(event.total) || 0; } else if (event.event === "progress") { job.done = Number(event.done) || job.done; job.total = Number(event.total) || job.total; job.currentTitle = String(event.title || ""); if (event.ok === false) { job.failed += 1; console.warn(`Download ${job.id}: could not download "${event.title}" by ${event.artist}`); } } else if (event.event === "complete") { job.status = "ready"; job.filePath = String(event.file); job.fileName = basename(job.filePath); job.savedPath = DESKTOP && job.kind === "playlist" ? job.workDir : job.filePath; job.finishedAt = Date.now(); } else if (event.event === "error") { finishWithError(job, String(event.message || "Download failed.")); } } function finishWithError(job: DownloadJob, message: string) { if (job.status !== "running") return; job.status = "failed"; job.error = message; console.error(`Download ${job.id} failed after ${job.done}/${job.total} tracks: ${message}`); job.finishedAt = Date.now(); if (job.isTempDir) rmSync(job.workDir, { recursive: true, force: true }); } export function getDownloadJob(id: string, ownerId: string) { const job = jobs.get(id); return job && job.ownerId === ownerId ? job : undefined; } export function finishDownloadJob(job: DownloadJob) { removeJob(job); }