server.js
Rama main · réplica de lectura del repositorio original.
import express from "express";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import crypto from "crypto";
import { spawn } from "child_process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
/**
* Env
*/
const PORT = Number(process.env.PORT || 3002);
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/data/downloads";
const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || "";
const BASIC_AUTH_PASS = process.env.BASIC_AUTH_PASS || "";
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || "";
/**
* Ensure output dir exists
*/
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
/**
* Middleware
*/
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));
/**
* Basic Auth (applies to everything)
*/
function basicAuth(req, res, next) {
if (!BASIC_AUTH_USER || !BASIC_AUTH_PASS) return next();
const hdr = req.headers.authorization || "";
if (!hdr.startsWith("Basic ")) {
res.setHeader("WWW-Authenticate", 'Basic realm="MediaFetch"');
return res.status(401).send("Auth required");
}
const decoded = Buffer.from(hdr.slice(6), "base64").toString("utf8");
const idx = decoded.indexOf(":");
const user = idx >= 0 ? decoded.slice(0, idx) : "";
const pass = idx >= 0 ? decoded.slice(idx + 1) : "";
if (user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASS) return next();
res.setHeader("WWW-Authenticate", 'Basic realm="MediaFetch"');
return res.status(401).send("Invalid credentials");
}
app.use(basicAuth);
/**
* Static: UI + Assets
* - / serves ./public (so / loads public/index.html)
* - /assets serves ./assets (fixes missing logo if your UI references /assets/...)
* - /downloads serves OUTPUT_DIR so the UI can link files
*/
app.use(express.static(path.join(__dirname, "public")));
app.use("/assets", express.static(path.join(__dirname, "assets")));
app.use("/downloads", express.static(OUTPUT_DIR));
/**
* Helpers
*/
function safeSlug(input) {
return String(input || "")
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80) || "mediafetch";
}
function normalisePublicBaseUrl(req) {
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL.replace(/\/+$/, "");
const proto =
(req.headers["x-forwarded-proto"] || "").toString().split(",")[0].trim() ||
"http";
const host =
(req.headers["x-forwarded-host"] || req.headers.host || "")
.toString()
.split(",")[0]
.trim();
if (!host) return "";
return `${proto}://${host}`.replace(/\/+$/, "");
}
function cleanInputUrl(raw) {
const u = String(raw || "").trim();
if (!u) return "";
if (!/^https?:\/\//i.test(u)) return "";
return u;
}
function startSSE(res) {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Helpful for some proxies
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
}
function sseSend(res, event, data) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
/**
* Core runner (shared by GET + POST)
*/
function runYtDlp({ req, res, url, mode, filename }) {
const cleanUrl = cleanInputUrl(url);
if (!cleanUrl) {
// For SSE endpoints, still reply cleanly
res.status(400);
startSSE(res);
sseSend(res, "done", { ok: false, error: "Invalid url" });
return res.end();
}
const jobId = crypto.randomBytes(8).toString("hex");
const baseName = safeSlug(filename) || "mediafetch";
const outTemplate = path.join(OUTPUT_DIR, `${baseName}-${jobId}.%(ext)s`);
const argsBase = [
"--no-warnings",
"--newline",
"--restrict-filenames",
"--no-playlist",
"--no-part",
"-o",
outTemplate,
];
let args = [];
if (String(mode) === "audio") {
// Extract Audio (mp3)
args = [...argsBase, "-x", "--audio-format", "mp3", "--audio-quality", "192K", cleanUrl];
} else {
// Best Video + Best Audio (merge to mp4)
args = [...argsBase, "-f", "bv*+ba/b", "--merge-output-format", "mp4", cleanUrl];
}
startSSE(res);
sseSend(res, "start", { jobId });
const proc = spawn("yt-dlp", args, { stdio: ["ignore", "pipe", "pipe"] });
let lastFile = null;
const onLine = (chunk) => {
const text = chunk.toString("utf8");
const lines = text.split(/\r?\n/).filter(Boolean);
for (const line of lines) {
if (line.includes("Destination:") || line.includes("Merging formats into")) {
const m = line.match(/(?:Destination:|Merging formats into)\s+"?([^"]+)"?$/);
if (m?.[1]) lastFile = m[1].trim();
}
sseSend(res, "log", { line });
}
};
proc.stdout.on("data", onLine);
proc.stderr.on("data", onLine);
proc.on("close", (code) => {
const baseUrl = normalisePublicBaseUrl(req);
let link = null;
if (code === 0 && lastFile) {
const normalisedOutput = path.resolve(OUTPUT_DIR) + path.sep;
const normalisedFile = path.resolve(lastFile);
if (normalisedFile.startsWith(normalisedOutput) && baseUrl) {
const rel = normalisedFile.slice(normalisedOutput.length).replace(/^\/+/, "");
link = `${baseUrl}/downloads/${rel}`;
}
}
sseSend(res, "done", { ok: code === 0, code, downloadUrl: link });
res.end();
});
proc.on("error", (err) => {
sseSend(res, "done", { ok: false, error: err.message });
res.end();
});
}
/**
* The route your UI is calling
* POST /api/fetch
* Body: { url, mode, filename }
*/
app.post("/api/fetch", (req, res) => {
const { url, mode, filename } = req.body || {};
runYtDlp({ req, res, url, mode, filename });
});
/**
* Optional: keep a GET version for manual testing
* GET /api/run?url=...&mode=video|audio&filename=...
*/
app.get("/api/run", (req, res) => {
const url = req.query.url;
const mode = req.query.mode || "video";
const filename = req.query.filename || "";
runYtDlp({ req, res, url, mode, filename });
});
/**
* Health
*/
app.get("/api/health", (req, res) => res.json({ ok: true }));
app.listen(PORT, () => console.log(`MediaFetch listening on :${PORT}`));