| require("dotenv").config(); |
| const express = require("express"); |
| const path = require("path"); |
| const fs = require("fs"); |
| const pLimit = require("p-limit"); |
| const crypto = require("crypto"); |
|
|
| |
| 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"); |
|
|
| |
| const app = express(); |
| const PORT = process.env.PORT || 7860; |
| const APIFY_TOKEN = process.env.APIFY_TOKEN; |
| const DELETE_RAW_FILES = true; |
|
|
| |
| const MAX_CONCURRENT_DOWNLOADS = 2; |
| const downloadLimit = pLimit(MAX_CONCURRENT_DOWNLOADS); |
|
|
| const MAX_CONCURRENT_JOBS = 1; |
| 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' })); |
|
|
| |
| 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 { |
| |
| 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; |
| |
| |
| 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 (bestVideo) { |
| bestAudio = adaptiveFormats.filter(f => f.mimeType.startsWith('audio/')).sort((a,b) => b.bitrate - a.bitrate).pop(); |
| } |
| } |
|
|
| |
| if (!bestVideo && muxedFormats.length > 0) { |
| console.log("[Process] Using muxed format for YouTube"); |
| |
| bestVideo = muxedFormats.sort((a,b) => (b.height || 0) - (a.height || 0))[0]; |
| |
| bestAudio = null; |
| |
| } |
|
|
| if (!bestVideo && !item.downloadUrl) { |
| throw new Error("Could not find playable video stream."); |
| } |
| |
| |
| if (bestVideo) { |
| updateJobStatus(jobId, 'downloading', `Downloading Stream (${bestVideo.height}p)...`, 30); |
| |
| if (bestAudio) { |
| |
| audioPath = path.join(videosDir, `${safeTitle}_${uniqueId}_audio.m4a`); |
| await Promise.all([downloadFile(bestVideo.url, videoPath), downloadFile(bestAudio.url, audioPath)]); |
| } else { |
| |
| await downloadFile(bestVideo.url, videoPath); |
| audioPath = null; |
| } |
| } else if (item.downloadUrl) { |
| |
| console.log("[Process] Using direct download URL"); |
| await downloadFile(item.downloadUrl, videoPath); |
| audioPath = null; |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
|
|
| 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 = []; |
| |
| |
| 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); |
| |
| if (!height) return; |
| |
| const key = `${height}p${f.fps||30}`; |
| |
| 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 }); } |
| }); |
|
|
| |
| 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`); |
| |
| |
| let dlUrl = item.downloadUrl; |
| |
| |
| if (!dlUrl && item.videoInfo) { |
| |
| 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 }); |
| } |
| }); |
|
|
| |
| 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; |
| |
| |
| if (!dlUrl && item.videoInfo) { |
| 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) 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."); |
| } |
| }); |
|
|
| |
| 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 }); |
| }); |
|
|
| |
| 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); }); |