From dc5c541bc4170090aab0946fcb88740f7b03013f Mon Sep 17 00:00:00 2001 From: Joris Bertomeu Date: Tue, 26 Aug 2025 17:30:47 +0200 Subject: [PATCH] Add upscale feat --- index.js | 1031 +++++++++++++++++++++++++++++++++-------- package.json | 2 + services/softwares.js | 4 + 3 files changed, 838 insertions(+), 199 deletions(-) diff --git a/index.js b/index.js index d0bd38a..dc46df5 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,8 @@ const EventEmitter = require('events'); const axios = require('axios'); const mpdParser = require('mpd-parser'); const softwareService = require('./services/softwares'); +const ffprobe = require('ffprobe'); +const ffprobeStatic = require('ffprobe-static'); const BASE_PATH = process.env.DATA_PATH || `./data`; const OUTPUT_PATH = process.env.OUTPUT_PATH || `${BASE_PATH}/output`; @@ -52,38 +54,38 @@ const runCommand = (command) => { }; const runProgressCommand = (command) => { - console.log('⚙️ Will execute: ' + command); - const emitter = new EventEmitter(); + console.log('⚙️ Will execute: ' + command); + const emitter = new EventEmitter(); - const executeCommand = new Promise((resolve, reject) => { - const cmd = spawn(command, { shell: true }); - let lastLog = ''; + 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 { - emitter.emit('percentage', perc); - } - }); + cmd.stdout.on('data', (data) => { + lastLog = data.toString(); + const perc = extractPercentage(data.toString()); + if (!perc) { + process.stdout.write(data.toString()); + } else { + emitter.emit('percentage', perc); + } + }); - cmd.stderr.on('data', (data) => { - lastLog = data.toString(); - process.stderr.write(`stderr: ${data.toString()}`); - }); + cmd.stderr.on('data', (data) => { + lastLog = data.toString(); + process.stderr.write(`stderr: ${data.toString()}`); + }); - cmd.on('close', (code) => { - if (code === 0) { + cmd.on('close', (code) => { + if (code === 0) { resolve('Command executed successfully.'); } else { reject(lastLog || 'Command failed with status code ' + code); } - }); - }); + }); + }); - return { executeCommand, emitter }; + return { executeCommand, emitter }; }; app.use((req, res, next) => { @@ -94,7 +96,7 @@ app.use((req, res, next) => { app.post('/start-process', async (req, res, next) => { try { - const { mp4Filename, mpdUrl, keys, wantedResolution, wantedAudioTracks, wantedSubtitles, wantedRemux } = req.body; + const { mp4Filename, mpdUrl, keys, wantedResolution, wantedAudioTracks, wantedSubtitles, wantedRemux, wantToUpscale } = req.body; console.log(JSON.stringify(req.body, null, 2)); const job = await videoQueue.add({ mp4Filename, @@ -103,7 +105,8 @@ app.post('/start-process', async (req, res, next) => { wantedResolution, wantedAudioTracks, wantedSubtitles, - wantedRemux + wantedRemux, + wantToUpscale }); res.json({ jobId: job.id }); } catch(e) { @@ -354,26 +357,26 @@ const extractPercentage = (line) => { } const formatDuration = (seconds) => { - if (isNaN(seconds) || seconds < 0) { - return 'Invalid input'; - } + if (isNaN(seconds) || seconds < 0) { + return 'Invalid input'; + } - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; - const parts = []; - if (hours > 0) { - parts.push(`${hours}h`); - } - if (minutes > 0) { - parts.push(`${minutes}m`); - } - if (secs > 0) { - parts.push(`${secs}s`); - } + const parts = []; + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (secs > 0) { + parts.push(`${secs}s`); + } - return parts.join(', ') || '0 secondes'; + return parts.join(', ') || '0 secondes'; } const parseMPDStream = async (mpdUrl) => { @@ -477,52 +480,374 @@ const safeMove = async (source, destination) => { } }; -const remuxToMKV = async (options) => { - const { mp4FilePath, outputPath, filename, wantedAudioTracks, wantedSubtitles, videoInfo } = options; - +// const remuxToMKV = async (options) => { +// const { mp4FilePath, outputPath, filename, wantedAudioTracks, wantedSubtitles, videoInfo } = options; + +// try { +// console.log('🎬 Starting MKV remux...'); + +// const mkvFilePath = `${outputPath}/${filename}.mkv`; + +// let mkvCommand = `mkvmerge -o "${mkvFilePath}" --verbose`; + +// mkvCommand += ` --title "${filename}"`; + +// const resolution = videoInfo?.resolution || { width: 'Unknown', height: 'Unknown' }; +// mkvCommand += ` --language 0:und`; +// mkvCommand += ` --track-name "0:Video ${resolution.width}x${resolution.height}"`; +// mkvCommand += ` "${mp4FilePath}"`; + +// for (let i = 0; i < wantedAudioTracks.length; i++) { +// const audioTrack = wantedAudioTracks[i]; +// const codec = audioTrack.attributes.CODECS?.split('.')[0] || 'Unknown'; +// const bitrate = Math.round(audioTrack.attributes.BANDWIDTH / 1000); + +// const trackIndex = i + 1; + +// mkvCommand += ` --language ${trackIndex}:${audioTrack.language}`; +// mkvCommand += ` --track-name "${trackIndex}:${audioTrack.language.toUpperCase()} ${codec} ${bitrate}kbps"`; + +// mkvCommand += ` --default-track ${trackIndex}:${i === 0 ? 'yes' : 'no'}`; +// } + +// const srtFiles = fs.readdirSync(outputPath).filter(file => +// file.startsWith(filename) && file.endsWith('.srt') +// ); + +// for (let i = 0; i < srtFiles.length; i++) { +// const srtFile = srtFiles[i]; +// const srtPath = `${outputPath}/${srtFile}`; + +// const srtIndex = i; +// const correspondingSubtitle = wantedSubtitles[srtIndex]; + +// if (correspondingSubtitle) { +// const language = correspondingSubtitle.language; + +// const isForced = srtFile.toLowerCase().includes('forced') || srtFile.toLowerCase().includes('sdh') === false; +// const trackName = isForced ? `${language.toUpperCase()} Forced` : `${language.toUpperCase()}`; + +// mkvCommand += ` --language 0:${language}`; +// mkvCommand += ` --track-name "0:${trackName}"`; + +// if (isForced) { +// mkvCommand += ` --forced-track 0:yes`; +// } + +// const defaultSub = (i === 0 && wantedAudioTracks.length > 0 && language === wantedAudioTracks[0].language) ? 'yes' : 'no'; +// mkvCommand += ` --default-track 0:${defaultSub}`; + +// mkvCommand += ` "${srtPath}"`; +// } +// } + +// console.log('🔧 MKV Command:', mkvCommand); + +// await runCommand(mkvCommand); + +// console.log(`✅ MKV remux completed: ${mkvFilePath}`); + +// //fs.unlinkSync(mp4FilePath); +// //srtFiles.forEach(srt => fs.unlinkSync(`${outputPath}/${srt}`)); + +// return mkvFilePath; + +// } catch (error) { +// console.error('❌ MKV remux failed:', error); +// throw new Error(`MKV remux failed: ${error.message}`); +// } +// }; + +const upscaleVideo = async (inputFilePath, options = {}) => { + const { + scale = 2, // Facteur d'upscale (2x, 4x) + method = 'realesrgan', // 'realesrgan', 'waifu2x', 'lanczos' + model = 'realesr-animevideov3', // Modèle Real-ESRGAN + outputSuffix = '_upscaled', + deleteOriginal = false, + fps = null // FPS de sortie (null = garder l'original) + } = options; + try { - console.log('🎬 Starting MKV remux...'); + console.log(`🔍 Starting ${scale}x upscale with ${method}...`); - const mkvFilePath = `${outputPath}/${filename}.mkv`; + const inputDir = path.dirname(inputFilePath); + const inputName = path.basename(inputFilePath, '.mp4'); + const outputFilePath = path.join(inputDir, `${inputName}${outputSuffix}.mp4`); - let mkvCommand = `mkvmerge -o "${mkvFilePath}" --verbose`; - - mkvCommand += ` --title "${filename}"`; - - const resolution = videoInfo?.resolution || { width: 'Unknown', height: 'Unknown' }; - mkvCommand += ` --language 0:und`; - mkvCommand += ` --track-name "0:Video ${resolution.width}x${resolution.height}"`; - mkvCommand += ` "${mp4FilePath}"`; - - for (let i = 0; i < wantedAudioTracks.length; i++) { - const audioTrack = wantedAudioTracks[i]; - const codec = audioTrack.attributes.CODECS?.split('.')[0] || 'Unknown'; - const bitrate = Math.round(audioTrack.attributes.BANDWIDTH / 1000); - - const trackIndex = i + 1; - - mkvCommand += ` --language ${trackIndex}:${audioTrack.language}`; - mkvCommand += ` --track-name "${trackIndex}:${audioTrack.language.toUpperCase()} ${codec} ${bitrate}kbps"`; - - mkvCommand += ` --default-track ${trackIndex}:${i === 0 ? 'yes' : 'no'}`; + // Vérifier si le fichier upscalé existe déjà + if (fs.existsSync(outputFilePath)) { + console.log('📁 Upscaled file already exists, skipping...'); + return outputFilePath; } + + // Sélection de la méthode d'upscale + switch (method.toLowerCase()) { + case 'realesrgan': + await upscaleWithRealESRGAN(inputFilePath, outputFilePath, { scale, model }); + break; + case 'lanczos': + await upscaleWithLanczos(inputFilePath, outputFilePath, { scale, fps }); + break; + case 'bicubic': + await upscaleWithBicubic(inputFilePath, outputFilePath, { scale, fps }); + break; + case 'super-resolution': + await upscaleWithSuperResolution(inputFilePath, outputFilePath, { scale, fps }); + break; + default: + throw new Error(`Unknown upscale method: ${method}`); + } + + // Vérifier que le fichier de sortie existe + if (!fs.existsSync(outputFilePath)) { + throw new Error('Upscale failed: output file not created'); + } + + // Supprimer l'original si demandé + if (deleteOriginal) { + fs.unlinkSync(inputFilePath); + fs.renameSync(outputFilePath, inputFilePath); + console.log('🗑️ Original file deleted'); + } + + console.log(`✅ Video upscaled successfully: ${outputFilePath}`); + return outputFilePath; + + } catch (error) { + console.error('❌ Video upscale failed:', error); + throw new Error(`Upscale failed: ${error.message}`); + } +}; + +// ========== MÉTHODES D'UPSCALE ========== + +async function upscaleWithRealESRGAN(inputPath, outputPath, { scale, model }) { + console.log(`🤖 Using Real-ESRGAN with model: ${model}`); + + const realEsrganPath = softwareService.getLocalBinFileInfo('realesrgan').path; + + // Real-ESRGAN pour vidéo (meilleure qualité) + const command = `${realEsrganPath} -i "${inputPath}" -o "${outputPath}" -n ${model} -s ${scale} -v`; + + console.log('🔧 Real-ESRGAN Command:', command); + await runCommand(command); +} + +async function upscaleWithBicubic(inputPath, outputPath, { scale, fps }) { + console.log('🔄 Using FFmpeg Bicubic'); + + const videoInfo = await getVideoInfo(inputPath); + const newWidth = Math.round(videoInfo.width * scale); + const newHeight = Math.round(videoInfo.height * scale); + + const fpsFilter = fps ? ` -r ${fps}` : ''; + const command = `ffmpeg -i "${inputPath}" -vf "scale=${newWidth}:${newHeight}:flags=bicubic" -c:a copy${fpsFilter} "${outputPath}"`; + + console.log('🔧 FFmpeg Bicubic Command:', command); + await runCommand(command); +} + +async function upscaleWithSuperResolution(inputPath, outputPath, { scale, fps }) { + console.log('🧠 Using FFmpeg Super Resolution filter'); + + const videoInfo = await getVideoInfo(inputPath); + const newWidth = Math.round(videoInfo.width * scale); + const newHeight = Math.round(videoInfo.height * scale); + + // Utilise le filtre super-resolution de FFmpeg (si disponible) + const fpsFilter = fps ? ` -r ${fps}` : ''; + const command = `ffmpeg -i "${inputPath}" -vf "scale=${newWidth}:${newHeight}:flags=lanczos+accurate_rnd+full_chroma_int" -c:a copy${fpsFilter} "${outputPath}"`; + + console.log('🔧 FFmpeg Super Resolution Command:', command); + await runCommand(command); +} + +async function upscaleWithLanczos(inputPath, outputPath, { scale }) { + console.log('⚡ Using FFmpeg Lanczos (fast fallback)'); + + // Déterminer la résolution cible + const videoInfo = await getVideoInfo(inputPath); + const newWidth = Math.round(videoInfo.width * scale); + const newHeight = Math.round(videoInfo.height * scale); + + // FFmpeg avec filtre Lanczos (rapide mais qualité moindre) + const command = `ffmpeg -i "${inputPath}" -vf "scale=${newWidth}:${newHeight}:flags=lanczos" -c:a copy "${outputPath}"`; + + console.log('🔧 FFmpeg Lanczos Command:', command); + await runCommand(command); +} + +// ========== FONCTIONS UTILITAIRES ========== + +async function getVideoInfo(filePath) { + try { + // const command = `ffprobe -v quiet -print_format json -show_format -show_streams "${filePath}"`; + // const output = await runCommand(command, { returnOutput: true }); + const info = await ffprobe(filePath, { path: ffprobeStatic.path }); + + console.log(info); - const srtFiles = fs.readdirSync(outputPath).filter(file => - file.startsWith(filename) && file.endsWith('.srt') - ); + const videoStream = info.streams.find(stream => stream.codec_type === 'video'); - for (let i = 0; i < srtFiles.length; i++) { - const srtFile = srtFiles[i]; - const srtPath = `${outputPath}/${srtFile}`; + return { + width: parseInt(videoStream.width), + height: parseInt(videoStream.height), + //duration: parseFloat(info.format.duration), + codec: videoStream.codec_name + }; + } catch (error) { + console.warn('⚠️ Could not get video info, using defaults', error); + return { width: 1920, height: 1080, duration: 0, codec: 'unknown' }; + } +} + +// ========== FONCTION D'AIDE POUR CHOISIR LA MEILLEURE MÉTHODE ========== + +function getRecommendedUpscaleMethod(videoInfo, contentType = 'mixed') { + const { width, height } = videoInfo; + const totalPixels = width * height; + + // Recommandations basées sur le contenu et la résolution + if (contentType === 'anime' || contentType === 'cartoon') { + return { + method: 'waifu2x', + model: null, + reason: 'Optimized for animated content' + }; + } + + if (totalPixels < 1000000) { // < 1MP (très basse résolution) + return { + method: 'realesrgan', + model: 'realesr-animevideov3', + reason: 'Best quality for low resolution content' + }; + } + + if (totalPixels < 2000000) { // < 2MP (résolution moyenne) + return { + method: 'realesrgan', + model: 'realesrgan-x4plus', + reason: 'Good balance of quality and speed' + }; + } + + // Haute résolution - utiliser Lanczos pour la vitesse + return { + method: 'lanczos', + model: null, + reason: 'Fast processing for high resolution content' + }; +} + +// ========== EXEMPLE D'UTILISATION ========== + +/* +// Utilisation basique +const upscaledPath = await upscaleVideo('/path/to/video.mp4'); + +// Utilisation avancée +const upscaledPath = await upscaleVideo('/path/to/video.mp4', { + scale: 4, + method: 'realesrgan', + model: 'realesr-animevideov3', + outputSuffix: '_4x_upscaled', + deleteOriginal: false +}); + +// Auto-détection de la meilleure méthode +const videoInfo = await getVideoInfo(inputPath); +const recommendation = getRecommendedUpscaleMethod(videoInfo, 'mixed'); +const upscaledPath = await upscaleVideo(inputPath, { + method: recommendation.method, + model: recommendation.model, + scale: 2 +}); +*/ + +const remuxToMKV = async (options) => { + const { + mp4FilePath, // Mode classique (MP4 existant) + videoFilePath, // Mode direct (fichier vidéo décrypté) + audioFiles, // Mode direct (array de fichiers audio) + subtitleFiles, // Mode direct (array de fichiers SRT) + outputPath, + filename, + wantedAudioTracks, + wantedSubtitles, + videoInfo + } = options; + + try { + console.log('🎬 Starting MKV creation...'); + + const mkvFilePath = `${outputPath}/${filename}.mkv`; + const isDirect = videoFilePath && audioFiles; // Mode direct si ces params sont fournis + + console.log(`📝 Mode: ${isDirect ? 'Direct from decrypted files' : 'Remux from MP4'}`); + + // Construction de la commande de base + let mkvCommand = `mkvmerge -o "${mkvFilePath}" --verbose --title "${filename}"`; + + // === PARTIE VIDÉO === + const resolution = videoInfo?.resolution || { width: 'Unknown', height: 'Unknown' }; + const videoFile = isDirect ? videoFilePath : mp4FilePath; + + mkvCommand += ` --language 0:und --track-name "0:Video ${resolution.width}x${resolution.height}" "${videoFile}"`; + + // === PARTIE AUDIO === + if (isDirect) { + wantedAudioTracks.forEach((audioTrack, index) => { + const { language, codec, bitrate } = extractAudioTrackInfo(audioTrack); + + const matchingAudioFile = audioFiles.find(audioFile => { + const filename = path.basename(audioFile); + return filename.includes(`.${language.toLowerCase()}_`) || filename.includes(`_${language.toLowerCase()}.`) || filename.includes(`_${language.toLowerCase()}_`); + }); + + if (matchingAudioFile) { + console.log(`🔗 Matching ${language} track with: ${path.basename(matchingAudioFile)}`); + + mkvCommand += ` --language 0:${language}`; + mkvCommand += ` --track-name "0:${language.toUpperCase()} ${codec} ${bitrate}kbps"`; + mkvCommand += ` --default-track 0:${index === 0 ? 'yes' : 'no'}`; + mkvCommand += ` "${matchingAudioFile}"`; + } else { + console.warn(`⚠️ No audio file found for language: ${language}`); + } + }); + } else { + wantedAudioTracks.forEach((audioTrack, index) => { + const { language, codec, bitrate } = extractAudioTrackInfo(audioTrack); + const trackIndex = index + 1; // +1 car 0 = vidéo + + mkvCommand += ` --language ${trackIndex}:${language}`; + mkvCommand += ` --track-name "${trackIndex}:${language.toUpperCase()}"`; + mkvCommand += ` --default-track ${trackIndex}:${index === 0 ? 'yes' : 'no'}`; + }); + } + + // === PARTIE SOUS-TITRES === + const srtFilesToProcess = isDirect ? subtitleFiles : findSrtFiles(outputPath, filename); + + // Matcher les sous-titres par langue comme pour l'audio + wantedSubtitles.forEach((subtitleInfo, index) => { + const language = subtitleInfo.language || 'und'; - const srtIndex = i; - const correspondingSubtitle = wantedSubtitles[srtIndex]; + // Trouver le fichier SRT correspondant à cette langue + const matchingSrtFile = srtFilesToProcess.find(srtPath => { + const filename = path.basename(srtPath).toLowerCase(); + return filename.includes(`.${language.toLowerCase()}_`) || + filename.includes(`_${language.toLowerCase()}.`) || + filename.includes(`_${language.toLowerCase()}_`) || + filename.includes(`.${language.toLowerCase()}.`); + }); - if (correspondingSubtitle) { - const language = correspondingSubtitle.language; + if (matchingSrtFile) { + console.log(`🔗 Matching ${language} subtitle with: ${path.basename(matchingSrtFile)}`); - const isForced = srtFile.toLowerCase().includes('forced') || srtFile.toLowerCase().includes('sdh') === false; - const trackName = isForced ? `${language.toUpperCase()} Forced` : `${language.toUpperCase()}`; + const { isForced, trackName } = extractSubtitleInfo(subtitleInfo, matchingSrtFile); mkvCommand += ` --language 0:${language}`; mkvCommand += ` --track-name "0:${trackName}"`; @@ -531,160 +856,468 @@ const remuxToMKV = async (options) => { mkvCommand += ` --forced-track 0:yes`; } - const defaultSub = (i === 0 && wantedAudioTracks.length > 0 && language === wantedAudioTracks[0].language) ? 'yes' : 'no'; - mkvCommand += ` --default-track 0:${defaultSub}`; + const defaultAudioLang = wantedAudioTracks[0]?.language; + const isDefault = (index === 0 && language === defaultAudioLang) ? 'yes' : 'no'; + mkvCommand += ` --default-track 0:${isDefault}`; - mkvCommand += ` "${srtPath}"`; + mkvCommand += ` "${matchingSrtFile}"`; + } else { + console.warn(`⚠️ No subtitle file found for language: ${language}`); } - } - - console.log('🔧 MKV Command:', mkvCommand); - - await runCommand(mkvCommand); - - console.log(`✅ MKV remux completed: ${mkvFilePath}`); - - //fs.unlinkSync(mp4FilePath); - //srtFiles.forEach(srt => fs.unlinkSync(`${outputPath}/${srt}`)); - - return mkvFilePath; - - } catch (error) { - console.error('❌ MKV remux failed:', error); - throw new Error(`MKV remux failed: ${error.message}`); - } + }); + + console.log('🔧 MKV Command:', mkvCommand); + console.log('⚙️ Will execute:', mkvCommand); + + await runCommand(mkvCommand); + + console.log(`✅ MKV creation completed: ${mkvFilePath}`); + + // Nettoyage optionnel (commenté pour sécurité) + // if (!isDirect && mp4FilePath) fs.unlinkSync(mp4FilePath); + // srtFilesToProcess.forEach(srt => fs.unlinkSync(srt)); + + return mkvFilePath; + + } catch (error) { + console.error('❌ MKV creation failed:', error); + throw new Error(`MKV creation failed: ${error.message}`); + } }; +// ========== FONCTIONS UTILITAIRES ========== + +function extractAudioTrackInfo(audioTrack) { + const language = audioTrack.language || 'und'; + const codec = audioTrack.attributes?.CODECS?.split('.')[0] || 'unknown'; + const bitrate = Math.round((audioTrack.attributes?.BANDWIDTH || 0) / 1000); + + return { language, codec, bitrate }; +} + +function extractSubtitleInfo(subtitleInfo, srtPath) { + const language = subtitleInfo.language || 'und'; + + // Détecter si c'est forcé via le nom du fichier ou les métadonnées + const filename = path.basename(srtPath).toLowerCase(); + const isForced = false;//filename.includes('forced') || + //filename.includes('sdh') === false || + //subtitleInfo.forced === true; + + const trackName = isForced ? `${language.toUpperCase()} Forced` : language.toUpperCase(); + + return { language, isForced, trackName }; +} + +function findSrtFiles(outputPath, filename) { + try { + return fs.readdirSync(outputPath) + .filter(file => file.startsWith(filename) && file.endsWith('.srt')) + .map(file => `${outputPath}/${file}`) + .sort(); // Tri pour ordre prévisible + } catch (error) { + console.warn('⚠️ Could not read SRT files:', error.message); + return []; + } +} + // Processus de la file d'attente -videoQueue.process((job) => { - return new Promise(async (resolve, reject) => { - try { - console.log('Will launch job') - const { mp4Filename, mpdUrl, keys, wantedResolution, wantedAudioTracks, wantedSubtitles, wantedRemux } = job.data; - const downloaderPath = softwareService.getLocalBinFileInfo('downloader').path; - const mp4decryptPath = softwareService.getLocalBinFileInfo('mp4decrypt').path; +// videoQueue.process((job) => { +// return new Promise(async (resolve, reject) => { +// try { +// console.log('Will launch job') +// const { mp4Filename, mpdUrl, keys, wantedResolution, wantedAudioTracks, wantedSubtitles, wantedRemux } = job.data; +// const downloaderPath = softwareService.getLocalBinFileInfo('downloader').path; +// const mp4decryptPath = softwareService.getLocalBinFileInfo('mp4decrypt').path; - const workdir = path.join(TMP_PATH, mp4Filename); - if (!fs.existsSync(workdir)) - fs.mkdirSync(workdir); - const mp4FilenameWithExt= `${mp4Filename}.mp4`; - const finalPath = path.join(OUTPUT_PATH, mp4Filename); +// console.log('wantedSubtitles', wantedSubtitles); +// const workdir = path.join(TMP_PATH, mp4Filename); +// if (!fs.existsSync(workdir)) +// fs.mkdirSync(workdir); +// const mp4FilenameWithExt= `${mp4Filename}.mp4`; +// const finalPath = path.join(OUTPUT_PATH, mp4Filename); - const filesExist = await checkFilesExistance('encrypted', workdir); - if (filesExist.length === 0) { - console.log('Encrypted files not found, downloading...'); - let objectsDownloaded = -1, previousPercentage = -1; - const objectsToDownload = 1 + wantedAudioTracks.length + wantedSubtitles.length; - job.progress(10); // Début à 10% +// const filesExist = await checkFilesExistance('encrypted', workdir); +// if (filesExist.length === 0) { +// console.log('Encrypted files not found, downloading...'); +// let objectsDownloaded = -1, previousPercentage = -1; +// const objectsToDownload = 1 + wantedAudioTracks.length + wantedSubtitles.length; +// job.progress(10); // Début à 10% - const bwAudio = wantedAudioTracks.length === 1 ? `:bwMin=\"${wantedAudioTracks.map(elem => Math.floor(elem.attributes.BANDWIDTH / 1000 -1)).join('|')}\":bwMax=\"${wantedAudioTracks.map(elem => Math.round(elem.attributes.BANDWIDTH / 1000 + 1)).join('|')}\"` : ''; - const bwSubs = wantedSubtitles.length === 1 ? `:bwMin=\"${wantedSubtitles.map(elem => Math.floor(elem.attributes.BANDWIDTH / 1000 -1)).join('|')}\":bwMax=\"${wantedSubtitles.map(elem => Math.round(elem.attributes.BANDWIDTH / 1000 + 1)).join('|')}\"` : ''; +// const bwAudio = wantedAudioTracks.length === 1 ? `:bwMin=\"${wantedAudioTracks.map(elem => Math.floor(elem.attributes.BANDWIDTH / 1000 -1)).join('|')}\":bwMax=\"${wantedAudioTracks.map(elem => Math.round(elem.attributes.BANDWIDTH / 1000 + 1)).join('|')}\"` : ''; +// const bwSubs = wantedSubtitles.length === 1 ? `:bwMin=\"${wantedSubtitles.map(elem => Math.floor(elem.attributes.BANDWIDTH / 1000 -1)).join('|')}\":bwMax=\"${wantedSubtitles.map(elem => Math.round(elem.attributes.BANDWIDTH / 1000 + 1)).join('|')}\"` : ''; - const subPart = wantedSubtitles.length > 0 ? `--select-subtitle lang=\"${wantedSubtitles.map(elem => elem.language).join('|')}\"${bwSubs}` : '--drop-subtitle lang=\".*\"'; - const audioPart = wantedAudioTracks.length > 0 ? `--select-audio lang=\"${wantedAudioTracks.map(elem => elem.language).join('|')}\":codecs=\"${[...new Set(wantedAudioTracks.map(elem => elem.attributes.CODECS))].join('|')}\":for=all${bwAudio}` : '--drop-audio lang=\".*\"'; +// const subPart = wantedSubtitles.length > 0 ? `--select-subtitle lang=\"${wantedSubtitles.map(elem => elem.language).join('|')}\"${bwSubs}` : '--drop-subtitle lang=\".*\"'; +// const audioPart = wantedAudioTracks.length > 0 ? `--select-audio lang=\"${wantedAudioTracks.map(elem => elem.language).join('|')}\":codecs=\"${[...new Set(wantedAudioTracks.map(elem => elem.attributes.CODECS))].join('|')}\":for=all${bwAudio}` : '--drop-audio lang=\".*\"'; - const { executeCommand, emitter } = runProgressCommand(`${downloaderPath} \"${mpdUrl}\" --save-dir ${workdir} --save-name ${mp4Filename}_encrypted --select-video id=\"${wantedResolution.id}\" ${audioPart} ${subPart}`, true); +// const { executeCommand, emitter } = runProgressCommand(`${downloaderPath} \"${mpdUrl}\" --save-dir ${workdir} --save-name ${mp4Filename}_encrypted --select-video id=\"${wantedResolution.id}\" ${audioPart} ${subPart}`, true); - emitter.on('percentage', (percentage) => { - if (percentage < previousPercentage) { - objectsDownloaded++; - } - previousPercentage = percentage; +// emitter.on('percentage', (percentage) => { +// if (percentage < previousPercentage) { +// objectsDownloaded++; +// } +// previousPercentage = percentage; - const subPercMax = 50 / objectsToDownload; - job.progress(Math.round((10 + (objectsDownloaded * subPercMax)) + (percentage * subPercMax / 100))); - }); +// const subPercMax = 50 / objectsToDownload; +// job.progress(Math.round((10 + (objectsDownloaded * subPercMax)) + (percentage * subPercMax / 100))); +// }); - await executeCommand; - } else { - console.log('Encrypted files already exist, bypassing download...') - } - job.progress(60); +// await executeCommand; +// } else { +// console.log('Encrypted files already exist, bypassing download...') +// } +// job.progress(60); - // Decrypt video stream - await runCommand(`${mp4decryptPath} ${keys.map(k => `--key ${k.key}:${k.value}`).join(' ')} "${workdir}/${mp4Filename}_encrypted.mp4" "${workdir}/${mp4Filename}_decrypted.mp4"`); +// // Decrypt video stream +// await runCommand(`${mp4decryptPath} ${keys.map(k => `--key ${k.key}:${k.value}`).join(' ')} "${workdir}/${mp4Filename}_encrypted.mp4" "${workdir}/${mp4Filename}_decrypted.mp4"`); - job.progress(70); +// job.progress(70); - // Decrypt audio streams - const audioFiles = fs.readdirSync(workdir); - 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(' ')} "${workdir}/${file}" "${workdir}/${baseName}_decrypted.m4a"`); - finalAudio.push(`${workdir}/${baseName}_decrypted.m4a`); - } - } +// // Decrypt audio streams +// const audioFiles = fs.readdirSync(workdir); +// 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(' ')} "${workdir}/${file}" "${workdir}/${baseName}_decrypted.m4a"`); +// finalAudio.push(`${workdir}/${baseName}_decrypted.m4a`); +// } +// } - job.progress(80); +// job.progress(80); - // Combinaison avec ffmpeg - let ffmpegCommand = `ffmpeg -y -i ${workdir}/${mp4Filename}_decrypted.mp4`; - let mapCommand = ' -map 0:v'; - let inputIndex = 1; +// // Combinaison avec ffmpeg +// let ffmpegCommand = `ffmpeg -y -i ${workdir}/${mp4Filename}_decrypted.mp4`; +// let mapCommand = ' -map 0:v'; +// let inputIndex = 1; - for (const file of finalAudio) { - ffmpegCommand += ` -i ${file}`; - mapCommand += ` -map ${inputIndex}:a`; - inputIndex++; - } +// for (const file of finalAudio) { +// ffmpegCommand += ` -i ${file}`; +// mapCommand += ` -map ${inputIndex}:a`; +// inputIndex++; +// } - if (!fs.existsSync(finalPath)) - fs.mkdirSync(finalPath); +// if (!fs.existsSync(finalPath)) +// fs.mkdirSync(finalPath); - ffmpegCommand += `${mapCommand} -c copy ${finalPath}/${mp4FilenameWithExt}`; - await runCommand(ffmpegCommand); +// ffmpegCommand += `${mapCommand} -c copy ${finalPath}/${mp4FilenameWithExt}`; +// await runCommand(ffmpegCommand); - job.progress(90); +// job.progress(90); - if (wantedRemux) { - console.log('🎬 Starting optional MKV remux...'); +// if (wantedRemux) { +// console.log('🎬 Starting optional MKV remux...'); - try { - await remuxToMKV({ - mp4FilePath: `${finalPath}/${mp4FilenameWithExt}`, - outputPath: finalPath, - filename: mp4Filename, - wantedAudioTracks, - wantedSubtitles, - videoInfo: wantedResolution - }); +// try { +// await remuxToMKV({ +// mp4FilePath: `${finalPath}/${mp4FilenameWithExt}`, +// outputPath: finalPath, +// filename: mp4Filename, +// wantedAudioTracks, +// wantedSubtitles, +// videoInfo: wantedResolution +// }); - job.progress(95); - console.log('✅ MKV remux completed successfully'); +// job.progress(95); +// console.log('✅ MKV remux completed successfully'); - } catch (remuxError) { - console.error('⚠️ MKV remux failed, keeping MP4:', remuxError); - } - } +// } catch (remuxError) { +// console.error('⚠️ MKV remux failed, keeping MP4:', remuxError); +// } +// } - // Renommage des fichiers SRT - const subFiles = fs.readdirSync(workdir); - let counter = 1; - for (const file of subFiles) { - if (file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.srt')) { - const sourcePath = `${workdir}/${file}`; - const destPath = `${finalPath}/${mp4Filename}_${counter}.srt`; +// // Renommage des fichiers SRT +// const subFiles = fs.readdirSync(workdir); +// let counter = 1; +// for (const file of subFiles) { +// if (file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.srt')) { +// const sourcePath = `${workdir}/${file}`; +// const destPath = `${finalPath}/${mp4Filename}_${counter}.srt`; - await safeMove(sourcePath, destPath); - counter++; - } - } +// await safeMove(sourcePath, destPath); +// counter++; +// } +// } - // Nettoyage (commenté pour correspondre au script original) - await runCommand(`rm -fr ${workdir}`); +// // Nettoyage (commenté pour correspondre au script original) +// await runCommand(`rm -fr ${workdir}`); - job.progress(100); - resolve({ message: `File fetched and decrypted with success: ${mp4Filename}.mp4`, filePath: `${OUTPUT_PATH}/${mp4Filename}.mp4`, fileName: `${mp4Filename}.mp4` }); - } catch (error) { - console.log('Error while processing task', error) - reject(new Error(`${error.toString() || error}`)); +// job.progress(100); +// resolve({ message: `File fetched and decrypted with success: ${mp4Filename}.mp4`, filePath: `${OUTPUT_PATH}/${mp4Filename}.mp4`, fileName: `${mp4Filename}.mp4` }); +// } catch (error) { +// console.log('Error while processing task', error) +// reject(new Error(`${error.toString() || error}`)); +// } +// }); +// }); + +videoQueue.process(async (job) => { + try { + console.log('🚀 Starting video processing job'); + const { mp4Filename, mpdUrl, keys, wantedResolution, wantedAudioTracks, wantedSubtitles, wantedRemux, wantToUpscale } = job.data; + + const config = { + downloaderPath: softwareService.getLocalBinFileInfo('downloader').path, + mp4decryptPath: softwareService.getLocalBinFileInfo('mp4decrypt').path, + workdir: path.join(TMP_PATH, mp4Filename), + mp4FilenameWithExt: `${mp4Filename}.mp4`, + finalPath: path.join(OUTPUT_PATH, mp4Filename) + }; + + await prepareDirectories(config); + + await downloadEncryptedFiles(job, config, { mpdUrl, wantedResolution, wantedAudioTracks, wantedSubtitles, mp4Filename }); + job.progress(60); + + await decryptVideoFile(config, keys, mp4Filename); + job.progress(70); + + const decryptedAudioFiles = await decryptAudioFiles(config, keys, mp4Filename); + job.progress(80); + + if (wantToUpscale) { + await upscaleVideo(`${config.workdir}/${mp4Filename}_decrypted.mp4`, { + method: 'lanczos', + deleteOriginal: true, + }); } - }); + + if (wantedRemux) { + console.log('🎬 Direct MKV creation with mkvtoolnix (bypassing FFmpeg)...'); + await createMKVDirectly(config, { + decryptedAudioFiles, + wantedAudioTracks, + wantedSubtitles, + videoInfo: wantedResolution, + mp4Filename + }); + job.progress(95); + } else { + await combineMediaFiles(config, decryptedAudioFiles, mp4Filename); + job.progress(90); + } + + await handleSubtitlesAndCleanup(config, mp4Filename); + job.progress(100); + + const finalExtension = wantedRemux ? '.mkv' : '.mp4'; + const finalFileName = `${mp4Filename}${finalExtension}`; + + return { + message: `File fetched and decrypted with success: ${finalFileName}`, + filePath: `${OUTPUT_PATH}/${mp4Filename}/${finalFileName}`, + fileName: finalFileName + }; + + } catch (error) { + console.error('❌ Error while processing task:', error); + throw new Error(`Processing failed: ${error.message || error}`); + } }); +async function createMKVDirectly(config, { decryptedAudioFiles, wantedAudioTracks, wantedSubtitles, videoInfo, mp4Filename }) { + console.log('🎬 Creating MKV directly from decrypted files...'); + const { workdir, finalPath } = config; + + const subFiles = fs.readdirSync(workdir); + const srtFiles = subFiles + .filter(file => file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.srt')) + .map(file => `${workdir}/${file}`); + + try { + await remuxToMKV({ + videoFilePath: `${workdir}/${mp4Filename}_decrypted.mp4`, + audioFiles: decryptedAudioFiles, + subtitleFiles: srtFiles, + outputPath: finalPath, + filename: mp4Filename, + wantedAudioTracks, + wantedSubtitles, + videoInfo + }); + + console.log('✅ Direct MKV creation completed successfully'); + } catch (remuxError) { + console.error('❌ Direct MKV creation failed:', remuxError); + // Fallback vers FFmpeg si échec + console.log('🔄 Falling back to FFmpeg + remux...'); + await combineMediaFiles(config, decryptedAudioFiles, mp4Filename); + await performMKVRemux(config, { wantedAudioTracks, wantedSubtitles, videoInfo: videoInfo, mp4Filename }); + } +} + +async function prepareDirectories(config) { + const { workdir, finalPath } = config; + + if (!fs.existsSync(workdir)) { + fs.mkdirSync(workdir, { recursive: true }); + } + if (!fs.existsSync(finalPath)) { + fs.mkdirSync(finalPath, { recursive: true }); + } +} + +async function downloadEncryptedFiles(job, config, { mpdUrl, wantedResolution, wantedAudioTracks, wantedSubtitles, mp4Filename }) { + const { workdir, downloaderPath } = config; + const filesExist = await checkFilesExistance('encrypted', workdir); + + if (filesExist.length > 0) { + console.log('📁 Encrypted files already exist, bypassing download...'); + return; + } + + console.log('⬇️ Encrypted files not found, downloading...'); + + // Construction des paramètres de téléchargement + const downloadParams = buildDownloadParameters(wantedAudioTracks, wantedSubtitles); + const objectsToDownload = 1 + wantedAudioTracks.length + wantedSubtitles.length; + + job.progress(10); + + const command = `${downloaderPath} "${mpdUrl}" --save-dir ${workdir} --save-name ${mp4Filename}_encrypted --select-video id="${wantedResolution.id}" ${downloadParams.audioPart} ${downloadParams.subPart}`; + + const { executeCommand, emitter } = runProgressCommand(command, true); + + // Gestion du progress avec meilleure lisibilité + let objectsDownloaded = -1; + let previousPercentage = -1; + + emitter.on('percentage', (percentage) => { + if (percentage < previousPercentage) { + objectsDownloaded++; + } + previousPercentage = percentage; + + const subPercMax = 50 / objectsToDownload; + const newProgress = Math.round((10 + (objectsDownloaded * subPercMax)) + (percentage * subPercMax / 100)); + job.progress(newProgress); + }); + + await executeCommand; +} + +function buildDownloadParameters(wantedAudioTracks, wantedSubtitles) { + // Construire les paramètres bandwidth pour audio + const bwAudio = wantedAudioTracks.length === 1 + ? `:bwMin="${wantedAudioTracks.map(elem => Math.floor(elem.attributes.BANDWIDTH / 1000 - 1)).join('|')}":bwMax="${wantedAudioTracks.map(elem => Math.round(elem.attributes.BANDWIDTH / 1000 + 1)).join('|')}"` + : ''; + + // Construire les paramètres bandwidth pour sous-titres + const bwSubs = wantedSubtitles.length === 1 + ? `:bwMin="${wantedSubtitles.map(elem => Math.floor(elem.attributes.BANDWIDTH / 1000 - 1)).join('|')}":bwMax="${wantedSubtitles.map(elem => Math.round(elem.attributes.BANDWIDTH / 1000 + 1)).join('|')}"` + : ''; + + // Construire les parties de la commande + const subPart = wantedSubtitles.length > 0 + ? `--select-subtitle lang="${wantedSubtitles.map(elem => elem.language).join('|')}"${bwSubs}` + : '--drop-subtitle lang=".*"'; + + const audioPart = wantedAudioTracks.length > 0 + ? `--select-audio lang="${wantedAudioTracks.map(elem => elem.language).join('|')}":codecs="${[...new Set(wantedAudioTracks.map(elem => elem.attributes.CODECS))].join('|')}":for=all${bwAudio}` + : '--drop-audio lang=".*"'; + + return { audioPart, subPart }; +} + +async function decryptVideoFile(config, keys, mp4Filename) { + console.log('🔓 Decrypting video stream...'); + const { workdir, mp4decryptPath } = config; + + const keyParams = keys.map(k => `--key ${k.key}:${k.value}`).join(' '); + const command = `${mp4decryptPath} ${keyParams} "${workdir}/${mp4Filename}_encrypted.mp4" "${workdir}/${mp4Filename}_decrypted.mp4"`; + + await runCommand(command); +} + +async function decryptAudioFiles(config, keys, mp4Filename) { + console.log('🔓 Decrypting audio streams...'); + const { workdir, mp4decryptPath } = config; + + const audioFiles = fs.readdirSync(workdir); + const encryptedAudioFiles = audioFiles.filter(file => + file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.m4a') + ); + + const decryptedAudioFiles = []; + const keyParams = keys.map(k => `--key ${k.key}:${k.value}`).join(' '); + + // Déchiffrer tous les fichiers audio + for (const file of encryptedAudioFiles) { + const baseName = path.basename(file, '.m4a'); + const decryptedPath = `${workdir}/${baseName}_decrypted.m4a`; + + const command = `${mp4decryptPath} ${keyParams} "${workdir}/${file}" "${decryptedPath}"`; + await runCommand(command); + + decryptedAudioFiles.push(decryptedPath); + } + + return decryptedAudioFiles; +} + +async function combineMediaFiles(config, decryptedAudioFiles, mp4Filename) { + console.log('🎬 Combining media files with FFmpeg...'); + const { workdir, finalPath, mp4FilenameWithExt } = config; + + // Construction de la commande FFmpeg + let ffmpegCommand = `ffmpeg -y -i ${workdir}/${mp4Filename}_decrypted.mp4`; + let mapCommand = ' -map 0:v'; + + // Ajouter les inputs audio et leurs mappings + decryptedAudioFiles.forEach((file, index) => { + const inputIndex = index + 1; + ffmpegCommand += ` -i ${file}`; + mapCommand += ` -map ${inputIndex}:a`; + }); + + ffmpegCommand += `${mapCommand} -c copy ${finalPath}/${mp4FilenameWithExt}`; + await runCommand(ffmpegCommand); +} + +async function performMKVRemux(config, { wantedAudioTracks, wantedSubtitles, videoInfo, mp4Filename }) { + console.log('🎬 Starting optional MKV remux...'); + + try { + await remuxToMKV({ + mp4FilePath: `${config.finalPath}/${config.mp4FilenameWithExt}`, + outputPath: config.finalPath, + filename: mp4Filename, + wantedAudioTracks, + wantedSubtitles, + videoInfo + }); + + console.log('✅ MKV remux completed successfully'); + } catch (remuxError) { + console.error('⚠️ MKV remux failed, keeping MP4:', remuxError); + // On continue sans faire échouer le job + } +} + +async function handleSubtitlesAndCleanup(config, mp4Filename) { + console.log('📝 Handling subtitles and cleanup...'); + const { workdir, finalPath } = config; + + // Renommage des fichiers SRT + const subFiles = fs.readdirSync(workdir); + const srtFiles = subFiles.filter(file => + file.startsWith(`${mp4Filename}_encrypted`) && file.endsWith('.srt') + ); + + for (let i = 0; i < srtFiles.length; i++) { + const file = srtFiles[i]; + const sourcePath = `${workdir}/${file}`; + const destPath = `${finalPath}/${mp4Filename}_${i + 1}.srt`; + + await safeMove(sourcePath, destPath); + } + + // Nettoyage du répertoire de travail + console.log('🧹 Cleaning up temporary files...'); + await runCommand(`rm -rf ${workdir}`); +} + app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); const dirsToCheck = [{ diff --git a/package.json b/package.json index f47d5c5..4e5e403 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "cheerio": "^1.1.2", "cors": "^2.8.5", "express": "^5.1.0", + "ffprobe": "^1.1.2", + "ffprobe-static": "^3.1.0", "fs": "^0.0.1-security", "mpd-parser": "^1.3.1", "path": "^0.12.7", diff --git a/services/softwares.js b/services/softwares.js index 886ef7b..1d19377 100644 --- a/services/softwares.js +++ b/services/softwares.js @@ -126,6 +126,10 @@ const getLocalBinFileInfo = (binType) => { stat: fs.existsSync(path) ? fs.statSync(path) : null, version: getBinVersion(`${BIN_PATH}/.${binType}.version`) } + } else if (binType === 'realesrgan') { + return { + path: `${BIN_PATH}/realesrgan_macos/realesrgan-ncnn-vulkan` + }; } else { throw new Error(`Bad binType "${binType}" provided`); }