- 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
217 lines
7.6 KiB
TypeScript
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);
|
|
}
|