archive-ads-creator / server.js
ArchiveAds's picture
Update server.js
597a502 verified
Raw
History Blame Contribute Delete
16.2 kB
require("dotenv").config();
const express = require("express");
const path = require("path");
const fs = require("fs");
const pLimit = require("p-limit");
const crypto = require("crypto");
// MODULE IMPORTS
const { fetchApifyData } = require("./downloaders");
const { processWithTemplate } = require("./engines/template");
const { downloadFile } = require("./utils/file");
const { slugifyTitle } = require("./utils/text");
const { createJob, getJob, getBatchJobs, updateJobStatus, setJobFile, setJobTitle, setJobError, resetJob } = require("./batch");
const { createSaverJob, getSaverJob, processSaverJob } = require("./ig_saver");
const { extractFirstFrame } = require("./utils/cloner");
const BatchManager = require("./utils/batch_manager");
const { createZip } = require("./utils/zip");
// CONSTANTS
const app = express();
const PORT = process.env.PORT || 7860;
const APIFY_TOKEN = process.env.APIFY_TOKEN;
const DELETE_RAW_FILES = true;
// Concurrency Limits
const MAX_CONCURRENT_DOWNLOADS = 2;
const downloadLimit = pLimit(MAX_CONCURRENT_DOWNLOADS);
const MAX_CONCURRENT_JOBS = 1; // Limit for heavy FFmpeg rendering
const limit = pLimit(MAX_CONCURRENT_JOBS);
app.use(express.static(path.join(__dirname, "public")));
app.use("/videos", express.static(path.join(__dirname, "videos")));
app.use("/processed", express.static(path.join(__dirname, "processed")));
app.use("/downloads", express.static(path.join(__dirname, "downloads")));
app.use(express.static(path.join(__dirname, "static")));
app.use(express.json({ limit: '50mb' }));
// --- JOB PROCESSOR ---
async function processJob(jobId, payload) {
let videoPath = null, audioPath = null;
try {
const { url, caption, brandName, targetHeight, targetFps, cropData, templateId } = payload;
updateJobStatus(jobId, 'downloading', 'Fetching Metadata...', 10);
const item = await fetchApifyData(url, APIFY_TOKEN);
setJobTitle(jobId, item.title);
const uniqueId = crypto.randomBytes(8).toString('hex');
const safeTitle = slugifyTitle(item.title);
const videosDir = path.join(__dirname, "videos");
if (!fs.existsSync(videosDir)) fs.mkdirSync(videosDir, { recursive: true });
videoPath = path.join(videosDir, `${safeTitle}_${uniqueId}_raw.mp4`);
let chosenFps = 0;
if (item.platform === 'instagram') {
updateJobStatus(jobId, 'downloading', 'Downloading Instagram Video...', 30);
await downloadFile(item.downloadUrl, videoPath);
audioPath = null;
} else {
// YouTube Logic
const adaptiveFormats = item.videoInfo && item.videoInfo.adaptiveFormats ? item.videoInfo.adaptiveFormats : [];
const muxedFormats = item.videoInfo && item.videoInfo.formats ? item.videoInfo.formats : [];
let bestVideo = null;
let bestAudio = null;
// 1. Try to find adaptive video stream (video only)
if (adaptiveFormats.length > 0) {
if (targetHeight && targetFps) {
bestVideo = adaptiveFormats.find(f => {
let h = f.height || (f.qualityLabel?.match(/(\d+)/)?.[1] | 0);
return h === parseInt(targetHeight) && (f.fps||30) === parseInt(targetFps) && f.mimeType.startsWith('video/');
});
chosenFps = parseInt(targetFps);
}
if (!bestVideo) {
bestVideo = adaptiveFormats.filter(f => f.mimeType.startsWith('video/') && !f.audioQuality)
.sort((a,b) => ((a.height||0)*(a.fps||30)) - ((b.height||0)*(b.fps||30))).pop();
}
// If we have an adaptive video, we NEED adaptive audio
if (bestVideo) {
bestAudio = adaptiveFormats.filter(f => f.mimeType.startsWith('audio/')).sort((a,b) => b.bitrate - a.bitrate).pop();
}
}
// 2. Fallback to Muxed formats (Video + Audio combined) if no adaptive video found
if (!bestVideo && muxedFormats.length > 0) {
console.log("[Process] Using muxed format for YouTube");
// Sort by height desc
bestVideo = muxedFormats.sort((a,b) => (b.height || 0) - (a.height || 0))[0];
// Muxed formats have audio included, so no separate audio download needed
bestAudio = null;
// We treat this 'bestVideo' as the source for both video and audio
}
if (!bestVideo && !item.downloadUrl) {
throw new Error("Could not find playable video stream.");
}
// 3. Download
if (bestVideo) {
updateJobStatus(jobId, 'downloading', `Downloading Stream (${bestVideo.height}p)...`, 30);
if (bestAudio) {
// Download Video and Audio separately
audioPath = path.join(videosDir, `${safeTitle}_${uniqueId}_audio.m4a`);
await Promise.all([downloadFile(bestVideo.url, videoPath), downloadFile(bestAudio.url, audioPath)]);
} else {
// Download only Video (it might contain audio if muxed)
await downloadFile(bestVideo.url, videoPath);
audioPath = null;
}
} else if (item.downloadUrl) {
// Direct download URL fallback
console.log("[Process] Using direct download URL");
await downloadFile(item.downloadUrl, videoPath);
audioPath = null;
}
}
// 3. Process Template
updateJobStatus(jobId, 'processing', 'Rendering Template (FFmpeg)...', 60);
const processedPath = await processWithTemplate(videoPath, audioPath, item.title, caption, brandName, chosenFps, cropData, templateId);
setJobFile(jobId, "/processed/" + path.basename(processedPath));
} catch (err) {
console.error("Job Error:", err);
setJobError(jobId, err.message);
} finally {
if(DELETE_RAW_FILES) {
if(videoPath && fs.existsSync(videoPath)) fs.unlinkSync(videoPath);
if(audioPath && fs.existsSync(audioPath)) fs.unlinkSync(audioPath);
}
}
}
// --- ROUTES ---
app.post("/get-info", async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ success: false, error: "URL is required" });
try {
const item = await fetchApifyData(url, APIFY_TOKEN);
let formats = [];
// Aggregate formats from both adaptive and muxed lists
const allFormats = [];
if (item.videoInfo) {
if (item.videoInfo.adaptiveFormats) allFormats.push(...item.videoInfo.adaptiveFormats);
if (item.videoInfo.formats) allFormats.push(...item.videoInfo.formats);
}
if (allFormats.length > 0) {
const uniqueMap = new Map();
allFormats.filter(f => f.mimeType?.startsWith("video/")).forEach(f => {
let height = f.height || (f.qualityLabel?.match(/(\d+)/)?.[1] | 0);
// Skip if height is missing or 0
if (!height) return;
const key = `${height}p${f.fps||30}`;
// Prefer higher bitrate if duplicate key
if (!uniqueMap.has(key) || (f.bitrate > uniqueMap.get(key).bitrate)) {
uniqueMap.set(key, {
id: key,
label: `${height}p`,
height: height,
fps: f.fps||30,
bitrate: f.bitrate
});
}
});
formats = Array.from(uniqueMap.values()).sort((a,b) => b.height - a.height);
}
if (formats.length === 0 && item.downloadUrl) {
formats.push({ id: 'default', label: 'Best Available', height: 0, fps: 0 });
}
res.json({ success: true, title: item.title, thumbnail: item.thumbnailUrl, formats: formats });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// NEW ROUTE: ANALYZE FOR CLONING (Used by Single Insta & Batch Insta)
app.post("/analyze-clone", async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ success: false, error: "URL is required" });
try {
console.log(`[Analyze] Fetching info for: ${url}`);
const item = await fetchApifyData(url, APIFY_TOKEN);
const tempDir = path.join(__dirname, "videos");
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const uniqueId = crypto.randomBytes(8).toString('hex');
const tempPath = path.join(tempDir, `analyze_${uniqueId}.mp4`);
// Robust URL Finding:
let dlUrl = item.downloadUrl;
// Check videoInfo for YouTube
if (!dlUrl && item.videoInfo) {
// Try to get highest quality format from adaptive or muxed
const allFormats = [
...(item.videoInfo.adaptiveFormats || []),
...(item.videoInfo.formats || [])
];
const bestVid = allFormats
.filter(f => f.mimeType && f.mimeType.startsWith('video/'))
.sort((a,b) => (b.height || 0) - (a.height || 0))[0];
if (bestVid) dlUrl = bestVid.url;
}
if (!dlUrl) {
console.error("[Analyze] No download URL found in item:", JSON.stringify(item));
throw new Error("Could not find download URL");
}
console.log(`[Analyze] Downloading to ${tempPath}...`);
await downloadFile(dlUrl, tempPath);
console.log(`[Analyze] Extracting frame...`);
const framePath = await extractFirstFrame(tempPath);
if (!framePath) throw new Error("Could not extract frame from video");
if(fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
const frameFilename = path.basename(framePath);
console.log(`[Analyze] Done. Frame: ${frameFilename}`);
res.json({
success: true,
frameUrl: `/videos/${frameFilename}`,
originalUrl: url
});
} catch (err) {
console.error("[Analyze Error]", err);
res.status(500).json({ success: false, error: err.message });
}
});
// --- BATCH ROUTES ---
app.post("/batch/init", (req, res) => {
const { urls, tasks } = req.body;
let itemsToProcess = [];
if (tasks && Array.isArray(tasks)) {
itemsToProcess = tasks;
} else if (urls && Array.isArray(urls)) {
itemsToProcess = urls.map(u => ({ url: u, caption: "", brandName: "" }));
} else {
return res.status(400).json({ error: "Invalid Input" });
}
const batchId = `batch-${Date.now()}`;
const jobIds = [];
itemsToProcess.forEach(task => {
if(task.url && task.url.trim()) {
const jobId = BatchManager.createBatchJob(task.url.trim(), batchId);
BatchManager.updateJob(jobId, { userCaption: task.caption, userBrand: task.brandName });
jobIds.push(jobId);
downloadLimit(() => processBatchDownload(jobId));
}
});
res.json({ success: true, batchId, count: jobIds.length });
});
async function processBatchDownload(jobId) {
const job = BatchManager.getJob(jobId);
if(!job) return;
try {
BatchManager.updateJob(jobId, { status: "downloading" });
console.log(`[Batch] Downloading: ${job.url}`);
const item = await fetchApifyData(job.url, APIFY_TOKEN);
const tempDir = path.join(__dirname, "videos");
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const uniqueId = crypto.randomBytes(8).toString('hex');
const videoPath = path.join(tempDir, `batch_${uniqueId}.mp4`);
let dlUrl = item.downloadUrl;
// YouTube logic for batch
if (!dlUrl && item.videoInfo) {
const allFormats = [
...(item.videoInfo.adaptiveFormats || []),
...(item.videoInfo.formats || [])
];
// Prioritize 1080p or best available
const bestVid = allFormats
.filter(f => f.mimeType && f.mimeType.startsWith('video/'))
.sort((a,b) => (b.height || 0) - (a.height || 0))[0];
if (bestVid) dlUrl = bestVid.url;
}
if (!dlUrl) throw new Error("No download URL found");
await downloadFile(dlUrl, videoPath);
const framePath = await extractFirstFrame(videoPath);
BatchManager.updateJob(jobId, {
status: "ready_to_crop",
videoPath: videoPath,
framePath: `/videos/${path.basename(framePath)}`,
videoTitle: item.title,
frameLocalPath: framePath,
caption: job.userCaption || "",
brandName: job.userBrand || ""
});
} catch (e) {
console.error(`[Batch] Error job ${jobId}:`, e.message);
BatchManager.updateJob(jobId, { status: "error", error: e.message });
}
}
app.get("/batch/status/:batchId", (req, res) => {
const jobs = BatchManager.getBatchStatus(req.params.batchId);
res.json({ success: true, jobs });
});
app.post("/batch/submit-crop", async (req, res) => {
const { jobId, cropData, caption, brandName, templateId } = req.body;
const job = BatchManager.getJob(jobId);
if(!job || job.status !== "ready_to_crop") {
return res.status(400).json({ error: "Job not ready" });
}
BatchManager.updateJob(jobId, { status: "processing" });
res.json({ success: true });
try {
const finalCaption = caption || job.caption || "";
const finalBrand = brandName || job.brandName || "";
const processedPath = await processWithTemplate(
job.videoPath,
null,
job.videoTitle || "Video",
finalCaption,
finalBrand,
0,
cropData,
templateId
);
const publicUrl = `/processed/${path.basename(processedPath)}`;
BatchManager.updateJob(jobId, {
status: "completed",
outputPath: publicUrl,
absoluteOutputPath: processedPath
});
if (DELETE_RAW_FILES) {
if (fs.existsSync(job.videoPath)) fs.unlinkSync(job.videoPath);
if (job.frameLocalPath && fs.existsSync(job.frameLocalPath)) fs.unlinkSync(job.frameLocalPath);
}
} catch (e) {
console.error(`[Batch] Process Error ${jobId}:`, e);
BatchManager.updateJob(jobId, { status: "error", error: "Processing Failed" });
}
});
app.get("/batch/download-zip/:batchId", async (req, res) => {
const batchId = req.params.batchId;
const jobs = BatchManager.getBatchStatus(batchId);
const completedJobs = jobs.filter(j => j.status === 'completed' && j.absoluteOutputPath);
if (completedJobs.length === 0) return res.status(404).send("No files.");
const filesToZip = completedJobs.map(j => ({
path: j.absoluteOutputPath,
name: path.basename(j.absoluteOutputPath)
}));
const zipName = `${batchId}.zip`;
const zipPath = path.join(__dirname, "downloads", zipName);
if (!fs.existsSync(path.dirname(zipPath))) fs.mkdirSync(path.dirname(zipPath), { recursive: true });
try {
await createZip(filesToZip, zipPath);
res.download(zipPath, zipName);
} catch (err) {
res.status(500).send("Failed to create zip file.");
}
});
// --- SINGLE JOB HANDLER ---
app.post("/start", async (req, res) => {
const payload = req.body || {};
if (!payload.url) return res.status(400).json({ success: false, error: "Missing URL" });
const jobId = createJob(payload.batchId);
limit(() => processJob(jobId, payload));
res.json({ success: true, jobId });
});
app.get("/status/:jobId", (req, res) => {
const job = getJob(req.params.jobId);
if (!job) return res.status(404).json({ success: false });
res.json({ success: true, ...job });
});
// --- UTILS ---
app.post("/ig-saver/start", (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ success: false, error: "URL is required" });
const jobId = createSaverJob();
processSaverJob(jobId, url, APIFY_TOKEN);
res.json({ success: true, jobId });
});
app.get("/ig-saver/status/:jobId", (req, res) => {
const job = getSaverJob(req.params.jobId);
if (!job) return res.status(404).json({ success: false, error: "Job not found" });
res.json({ success: true, job });
});
app.listen(PORT, () => { console.log("Server running at http://localhost:" + PORT); });