Ctrl K
ComunidadGuías…

server.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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}`));