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); });