commit 9aa7ba809d92bbacf1d89768f1f88fe2f951eef7 Author: Joris Bertomeu Date: Thu Sep 26 11:39:12 2024 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..158c0e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Use yarn.lock instead +package-lock.json + +*.mp4 +*.mkv +*.m4a +*.srt + +# Environment variables +#.env + +# Logs +logs +*.log +npm-debug.log* + +# Documentation +docs + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +[Uu]ploads/ +dist/ diff --git a/index.js b/index.js new file mode 100644 index 0000000..3b61bc4 --- /dev/null +++ b/index.js @@ -0,0 +1,254 @@ +const express = require('express'); +const Queue = require('bull'); +const { exec, spawn } = require('child_process'); +const fs = require('fs').promises; +const path = require('path'); +const cors = require('cors'); +const EventEmitter = require('events'); + +const app = express(); +const port = 3000; + +app.use(cors()); + +app.use(express.json()); + +// Configuration de la file d'attente Bull +const videoQueue = new Queue('crawl', 'redis://192.168.1.222:32768'); + +videoQueue.on('error', (e) => { + console.log('An error occured', e); +}); + +const runCommand = (command) => { + console.log('⚙️ Will execute: ' + command); + return new Promise((resolve, reject) => { + const cmd = spawn(command, { shell: true }); + let lastLog = ''; + + cmd.stdout.on('data', (data) => { + lastLog = data.toString(); + process.stdout.write(data.toString()); + }); + + cmd.stderr.on('data', (data) => { + lastLog = data.toString(); + process.stderr.write(`stderr: ${data.toString()}`); + }); + + cmd.on('close', (code) => { + if (code === 0) { + resolve('Command executed successfully.'); + } else { + reject(lastLog || 'Command failed with status code ' + code); + } + }); + }); +}; + +const runProgressCommand = (command) => { + console.log('⚙️ Will execute: ' + command); + const emitter = new EventEmitter(); + + const executeCommand = new Promise((resolve, reject) => { + const cmd = spawn(command, { shell: true }); + let lastLog = ''; + + cmd.stdout.on('data', (data) => { + lastLog = data.toString(); + const perc = extractPercentage(data.toString()); + if (!perc) { + process.stdout.write(data.toString()); + } else { + //console.log('🐳 Percentage ====> ' + perc); + emitter.emit('percentage', perc); + } + }); + + cmd.stderr.on('data', (data) => { + lastLog = data.toString(); + process.stderr.write(`stderr: ${data.toString()}`); + }); + + cmd.on('close', (code) => { + if (code === 0) { + resolve('Command executed successfully.'); + } else { + reject(lastLog || 'Command failed with status code ' + code); + } + }); + }); + + return { executeCommand, emitter }; +}; + +// Route pour démarrer le processus +app.post('/start-process', async (req, res, next) => { + try { + const { mp4Filename, mpdUrl, keys, wantedResolution } = req.body; + console.log(req.body); + const job = await videoQueue.add({ + mp4Filename, + mpdUrl, + keys, + wantedResolution + }); + res.json({ jobId: job.id }); + } catch(e) { + console.log(e); + next(); + } +}); + +// Route pour vérifier le statut du job +app.get('/job-status/:jobId', async (req, res) => { + const job = await videoQueue.getJob(req.params.jobId); + if (job === null) { + res.status(404).json({ error: 'Job not found' }); + } else { + const state = await job.getState(); + const progress = job._progress; + res.json({ jobId: job.id, state, progress }); + } +}); + +app.get('/jobs-status', async (req, res) => { + const jobs = await videoQueue.getJobs(); + res.json(await Promise.all(jobs.splice(0, 25).map(async job => ({ + id: job.id, + state: await job.getState(), + progress: job._progress, + addedOn: job.timestamp, + processedOn: job.processedOn, + finishedOn: job.finishedOn, + returnValue: job.returnvalue, + failedReason: job.failedReason, + data: job.data + })))); +}); + +app.get('/download/:filename', async (req, res) => { + const { filename } = req.params; + const file = path.join(process.cwd(), filename); + res.download(file); +}); + + +const checkFilesExistance = (pattern) => { + return new Promise(async (resolve, reject) => { + try { + const files = await fs.readdir(process.cwd()); + resolve(files.filter(file => file.includes(pattern))); + } catch(e) { + reject(e); + } + }); +}; + +const extractPercentage = (line) => { + const s = line.split('%'); + const percentages = []; + + for (let i = 0; i < s.length; i++) { + if (s[i].length === 0) continue; + const ss = s[i].split(' '); + if (ss.length > 0 && !isNaN(ss[ss.length - 1])) + percentages.push(parseFloat(ss[ss.length - 1])); + } + if (percentages.length === 0) return null; + return Math.max(...percentages.filter((p) => p < 100)); +} + +// Processus de la file d'attente +videoQueue.process((job) => { + return new Promise(async (resolve, reject) => { + const { mp4Filename, mpdUrl, keys, wantedResolution } = job.data; + const downloaderPath = path.join(process.env.HOME, 'Downloads/N_m3u8DL-RE_Beta_osx-arm64/N_m3u8DL-RE'); + const mp4decryptPath = path.join(process.env.HOME, 'Downloads/Bento4-SDK-1-6-0-641.universal-apple-macosx/bin/mp4decrypt'); + + try { + const filesExist = await checkFilesExistance('encrypted'); + + if (filesExist.length === 0) { + const resPattern = { + '4k': [3840, 2160], + '1080p': [1920, 1080], + '720p': [1280, 720], + '480p': [854, 480], + '360p': [640, 360], + '240p': [426, 240] + }[wantedResolution] || [1920, 1080]; + console.log('Encrypted files not found, downloading...'); + job.progress(10); + const { executeCommand, emitter } = runProgressCommand(`${downloaderPath} \"${mpdUrl}\" --save-name ${mp4Filename}_encrypted --select-video \"(?=.*${resPattern[0]})(?=.*${resPattern[1]})\" --select-audio lang=\"fr|en\":for=best2 --select-subtitle all`, true); + emitter.on('percentage', (percentage) => { + console.log(`Download Progression : ${percentage}%`); + job.progress(Math.round(10 + (percentage / 5))); + }); + await executeCommand; + } else { + console.log('Encrypted files already exist, bypassing download...') + } + + job.progress(30); + + // Décryptage vidéo + await runCommand(`${mp4decryptPath} ${keys.map(k => `--key ${k.key}:${k.value}`).join(' ')} "${mp4Filename}_encrypted.mp4" "${mp4Filename}_decrypted.mp4"`); + + job.progress(50); + + // Décryptage audio + const audioFiles = await fs.readdir('.'); + const finalAudio = []; + for (const file of audioFiles) { + if (file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.m4a')) { + const baseName = path.basename(file, '.m4a'); + await runCommand(`${mp4decryptPath} ${keys.map(k => `--key ${k.key}:${k.value}`).join(' ')} "${file}" "${baseName}_decrypted.m4a"`); + finalAudio.push(`${baseName}_decrypted.m4a`); + } + } + + job.progress(70); + + // Combinaison avec ffmpeg + let ffmpegCommand = `ffmpeg -y -i ${mp4Filename}_decrypted.mp4`; + let mapCommand = ' -map 0:v'; + let inputIndex = 1; + + for (const file of finalAudio) { + //if (file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('_decrypted.m4a')) { + ffmpegCommand += ` -i ${file}`; + mapCommand += ` -map ${inputIndex}:a`; + inputIndex++; + //} + } + + ffmpegCommand += `${mapCommand} -c copy ${mp4Filename}.mp4`; + await runCommand(ffmpegCommand); + + job.progress(90); + + // Renommage des fichiers SRT + let counter = 1; + for (const file of audioFiles) { + if (file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.srt')) { + await fs.rename(file, `${mp4Filename}_${counter}.srt`); + counter++; + } + } + + // Nettoyage (commenté pour correspondre au script original) + await runCommand(`rm ${mp4Filename}_encrypted* && rm ${mp4Filename}_decrypted*`); + + job.progress(100); + resolve({ message: `File fetched and decrypted with success: ${mp4Filename}.mp4`, filePath: `${mp4Filename}.mp4`, fileName: `${mp4Filename}.mp4` }); + } catch (error) { + console.log('Error while processing task', error) + reject(new Error(`${error.toString() || error}`)); + } + }); +}); + +app.listen(port, () => { + console.log(`Server running at http://localhost:${port}`); +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..279ae6b --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "crawlflix-api", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bull": "^4.16.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "fs": "^0.0.1-security", + "path": "^0.12.7" + } +}