Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

217 lines
7.6 KiB
TypeScript

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<string, DownloadJob>();
const userIdCache = new Map<string, { id: string; expiresAt: number }>();
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<string, unknown>) {
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);
}