const express = require('express'); const Queue = require('bull'); const { spawn, exec } = require('child_process'); const fs = require('fs'); const path = require('path'); const cors = require('cors'); 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 { stringify } = require('querystring'); const xml2js = require('xml2js'); const { promisify } = require('util'); const execAsync = promisify(exec); const BASE_PATH = process.env.DATA_PATH || `./data`; const OUTPUT_PATH = process.env.OUTPUT_PATH || `${BASE_PATH}/output`; const TMP_PATH = `${BASE_PATH}/tmp`; const app = express(); const port = 3000; app.use(cors()); app.use(express.json()); const videoQueue = new Queue('crawlflix_queue', 'redis://192.168.1.230:6379'); 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 { 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 }; }; app.use((req, res, next) => { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] ${req.method} ${req.url} - ${req.ip}`); next(); }); app.post('/start-process', async (req, res, next) => { try { 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, mpdUrl, keys, wantedResolution, wantedAudioTracks, wantedSubtitles, wantedRemux, wantToUpscale }); res.json({ jobId: job.id }); } catch(e) { console.log(e); next(); } }); 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.delete('/job/:jobId', async (req, res) => { try { const job = await videoQueue.getJob(req.params.jobId); if (job === null) { return res.status(404).json({ error: 'Job not found' }); } const state = await job.getState(); if (state === 'active' || state === 'waiting') { const { mp4Filename } = job.data; const workdir = path.join(TMP_PATH, mp4Filename); if (fs.existsSync(workdir)) { await runCommand(`rm -rf ${workdir}`); console.log(`Cleaned up temp files for cancelled job: ${workdir}`); } } await job.remove(); res.json({ message: 'Job deleted successfully', jobId: req.params.jobId, previousState: state }); } catch (error) { console.error('Error deleting job:', error); res.status(500).json({ error: error.message || 'Failed to delete job' }); } }); app.delete('/jobs/completed', async (req, res) => { try { const completedJobs = await videoQueue.getJobs(['completed', 'failed'], 0, -1); if (completedJobs.length === 0) { return res.json({ message: 'No completed jobs to delete', deletedCount: 0 }); } const deletePromises = completedJobs.map(job => job.remove()); await Promise.all(deletePromises); res.json({ message: `Successfully deleted ${completedJobs.length} completed jobs`, deletedCount: completedJobs.length }); } catch (error) { console.error('Error deleting completed jobs:', error); res.status(500).json({ error: error.message || 'Failed to delete completed jobs' }); } }); app.delete('/jobs/all', async (req, res) => { try { const allJobs = await videoQueue.getJobs(['waiting', 'active', 'completed', 'failed', 'delayed'], 0, -1); if (allJobs.length === 0) { return res.json({ message: 'Queue is already empty', deletedCount: 0 }); } let cleanedDirs = 0; for (const job of allJobs) { const state = await job.getState(); if (state === 'active' || state === 'waiting') { const { mp4Filename } = job.data; const workdir = path.join(TMP_PATH, mp4Filename); if (fs.existsSync(workdir)) { await runCommand(`rm -rf ${workdir}`); cleanedDirs++; } } } const deletePromises = allJobs.map(job => job.remove()); await Promise.all(deletePromises); res.json({ message: `Successfully deleted all ${allJobs.length} jobs`, deletedCount: allJobs.length, cleanedTempDirs: cleanedDirs }); } catch (error) { console.error('Error deleting all jobs:', error); res.status(500).json({ error: error.message || 'Failed to delete all jobs' }); } }); app.get('/jobs/stats', async (req, res) => { try { const waiting = await videoQueue.getWaiting(); const active = await videoQueue.getActive(); const completed = await videoQueue.getCompleted(); const failed = await videoQueue.getFailed(); res.json({ waiting: waiting.length, active: active.length, completed: completed.length, failed: failed.length, total: waiting.length + active.length + completed.length + failed.length }); } catch (error) { console.error('Error getting queue stats:', error); res.status(500).json({ error: error.message || 'Failed to get queue stats' }); } }); 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', async (req, res) => { try { const { filename } = req.query; if (filename.includes('..') || filename.includes('\\')) { return res.status(400).json({ error: 'Invalid filename' }); } const filePath = path.join(OUTPUT_PATH, filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'File not found' }); } const stat = fs.statSync(filePath); if (!stat.isFile()) { return res.status(400).json({ error: 'Not a file' }); } const allowedExtensions = ['.mp4', '.mkv', '.srt', '.m4a']; const fileExt = path.extname(filename).toLowerCase(); if (!allowedExtensions.includes(fileExt)) { return res.status(403).json({ error: 'File type not allowed' }); } console.log(`📥 Download requested: ${filename} (${stat.size} bytes)`); res.download(filePath, filename, (err) => { if (err) { console.error('Download error:', err); if (!res.headersSent) { res.status(500).json({ error: 'Download failed' }); } } else { console.log(`✅ Download completed: ${filename}`); } }); } catch (error) { console.error('Download route error:', error); if (!res.headersSent) { res.status(500).json({ error: 'Internal server error' }); } } }); app.get('/hello', async (req, res, next) => { try { res.json({ downloader: await softwareService.checkDownloaderUpdate(), mp4decrypt: await softwareService.checkMp4decryptUpdate() }) } catch(e) { console.log(e); next(e); } }); app.post('/processUpdate', async (req, res, next) => { try { console.log(req.body); await softwareService.processUpdate(req.body); res.end(); } catch(e) { console.log(e); next(e); } }); const checkFilesExistance = (pattern, path) => { return new Promise(async (resolve, reject) => { try { const files = fs.readdirSync(path); 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)); } const formatDuration = (seconds) => { 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 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'; } const parseMPDStream = async (mpdUrl) => { // Télécharger le manifest d'abord await downloadMPDManifest(mpdUrl, `./data/tmp/manifest.mpd`); // 1. Parser le XML pour récupérer les KID const mpdContent = fs.readFileSync('./data/tmp/manifest.mpd', 'utf8'); const parser = new xml2js.Parser({ explicitArray: false, mergeAttrs: true }); const parsedXML = await parser.parseStringPromise(mpdContent); // Extraire les KID du XML par contentType et bandwidth const kidMapping = {}; if (parsedXML.MPD?.Period) { const periods = Array.isArray(parsedXML.MPD.Period) ? parsedXML.MPD.Period : [parsedXML.MPD.Period]; periods.forEach(period => { if (period.AdaptationSet) { const adaptationSets = Array.isArray(period.AdaptationSet) ? period.AdaptationSet : [period.AdaptationSet]; adaptationSets.forEach(adaptSet => { let defaultKID = null; // Chercher ContentProtection if (adaptSet.ContentProtection) { if (Array.isArray(adaptSet.ContentProtection)) { defaultKID = adaptSet.ContentProtection[0]['cenc:default_KID']; } else { defaultKID = adaptSet.ContentProtection['cenc:default_KID']; } } if (defaultKID && adaptSet.Representation) { const representations = Array.isArray(adaptSet.Representation) ? adaptSet.Representation : [adaptSet.Representation]; representations.forEach(rep => { const key = `${adaptSet.contentType}_${rep.bandwidth}`; kidMapping[key] = defaultKID.replace(/-/g, ''); }); } }); } }); } // 2. Parser avec N_m3u8DL-RE pour avoir les bonnes données const baseUrl = mpdUrl.substring(0, mpdUrl.lastIndexOf('/') + 1); const downloaderPath = softwareService.getLocalBinFileInfo('downloader').path; const parseCommand = `${downloaderPath} "./data/tmp/manifest.mpd" --base-url "${baseUrl}" --skip-download -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0"`; let stdout; try { const result = await execAsync(parseCommand); stdout = result.stdout; } catch (error) { if (error.stdout) { stdout = error.stdout; } else { throw error; } } // 3. Parser N_m3u8DL-RE et associer les KID const isValidDuration = (durationStr) => { const timeMatch = durationStr.match(/~(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/); if (!timeMatch) return false; const hours = parseInt(timeMatch[1]) || 0; const minutes = parseInt(timeMatch[2]) || 0; const totalMinutes = hours * 60 + minutes; return totalMinutes >= 10 && totalMinutes <= 240; }; const obj = { audioTracks: [], videoTracks: [], subtitles: [] }; const lines = stdout.split('\n'); lines.forEach(line => { if (line.includes('INFO : Vid')) { if (!/\d+x\d+/.test(line)) { console.log('Skipping non-video line:', line); return; } const parts = line.split(' | '); if (parts.length >= 7) { const encrypted = parts[0].includes('*CENC'); const resolution = parts[0].match(/(\d+x\d+)/)[1]; const [width, height] = resolution.split('x').map(Number); const kbps = parseInt(parts[1].match(/(\d+) Kbps/)[1]); const videoId = parts[2].trim(); const fps = parseFloat(parts[3]); const codec = parts[4].trim(); const duration = parts[7].trim(); if (!isValidDuration(duration)) { console.log(`Skipping video track with suspicious duration (${duration}):`, line); return; } const bandwidth = kbps * 1000; const mappingKey1 = `video_${bandwidth}`; const mappingKey2 = videoId.startsWith('video=') ? videoId : `video=${bandwidth}`; const defaultKID = kidMapping[mappingKey1] || kidMapping[mappingKey2] || null; obj.videoTracks.push({ id: videoId, name: resolution, resolution: { width, height }, bandwidth, fps, codec, encrypted, formatedDuration: duration, defaultKID }); } } else if (line.includes('INFO : Aud')) { const audioMatch = line.match(/Aud (\*CENC )?(.+?) \| (\d+) Kbps \| ([^|]+) \| ([^|]+) \| ([^|]+) \| \d+ Segments? \| [^|]+ \| (~.+)$/); if (audioMatch) { const [, encrypted, audioId, kbps, codec, lang, channels, duration] = audioMatch; if (!isValidDuration(duration)) return; const bandwidth = parseInt(kbps) * 1000; // Chercher le KID correspondant const kidKey = `audio_${bandwidth}`; const defaultKID = kidMapping[kidKey] || null; obj.audioTracks.push({ id: audioId.trim(), name: audioId.trim(), language: lang.trim(), bandwidth, codec: codec.trim(), channels: channels.trim(), encrypted: !!encrypted, formatedDuration: duration.trim(), defaultKID }); } } else if (line.includes('INFO : Sub')) { const parts = line.split(' | '); if (parts.length >= 6) { const subId = parts[0].replace(/.*INFO : Sub /, '').trim(); const lang = parts[1].trim(); const codec = parts[2].trim(); const duration = parts[5].trim(); if (!isValidDuration(duration)) return; const defaultKID = kidMapping[subId] || kidMapping[`text_${subId}`] || null; obj.subtitles.push({ id: subId, name: subId, language: lang, codec, encrypted: false, formatedDuration: duration, defaultKID }); } } }); // Déduplication et tri obj.videoTracks = obj.videoTracks .filter((track, index, self) => index === self.findIndex(t => t.id === track.id)) .sort((a, b) => b.resolution.width - a.resolution.width); obj.audioTracks = obj.audioTracks .filter((track, index, self) => index === self.findIndex(t => t.id === track.id)) .sort((a, b) => b.bandwidth - a.bandwidth); console.log(`Parsed ${obj.videoTracks.length} video tracks, ${obj.audioTracks.length} audio tracks`); return obj; }; // const parseMPDStream = async (mpdUrl) => { // const mpdResponse = await axios({ // url: mpdUrl, // method: 'GET', // responseType: 'text' // }); // const parser = new xml2js.Parser({ explicitArray: false, mergeAttrs: true }); // const parsedXML = await parser.parseStringPromise(mpdResponse.data); // const obj = { // audioTracks: [], // videoTracks: [], // subtitles: [] // }; // // Fonction pour parser la durée PT format vers secondes // const parseDuration = (duration) => { // if (!duration) return 0; // // PT34M11.34S -> 34*60 + 11.34 = 2051.34 secondes // const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/); // if (!match) return 0; // const hours = parseInt(match[1]) || 0; // const minutes = parseInt(match[2]) || 0; // const seconds = parseFloat(match[3]) || 0; // return hours * 3600 + minutes * 60 + seconds; // }; // // Fonction pour filtrer les periods avec contenu principal (> 10 minutes) // const isMainContent = (duration) => { // const durationInSeconds = parseDuration(duration); // return durationInSeconds > 600; // Plus de 10 minutes // }; // if (parsedXML.MPD && parsedXML.MPD.Period) { // const periods = Array.isArray(parsedXML.MPD.Period) ? parsedXML.MPD.Period : [parsedXML.MPD.Period]; // // Filtrer seulement les periods de contenu principal // const mainPeriods = periods.filter(period => isMainContent(period.duration)); // console.log(`Found ${periods.length} total periods, ${mainPeriods.length} main content periods`); // mainPeriods.forEach((period, periodIndex) => { // console.log(`Processing period: ${period.duration} (${parseDuration(period.duration)}s)`); // if (period.AdaptationSet) { // const adaptationSets = Array.isArray(period.AdaptationSet) ? period.AdaptationSet : [period.AdaptationSet]; // adaptationSets.forEach(adaptSet => { // const contentType = adaptSet.contentType; // let defaultKID = null; // if (adaptSet.ContentProtection) { // if (Array.isArray(adaptSet.ContentProtection)) { // defaultKID = adaptSet.ContentProtection[0]['cenc:default_KID']; // } else { // defaultKID = adaptSet.ContentProtection['cenc:default_KID']; // } // if (defaultKID) { // defaultKID = defaultKID.replace(/-/g, ''); // } // } // if (adaptSet.Representation) { // const representations = Array.isArray(adaptSet.Representation) ? adaptSet.Representation : [adaptSet.Representation]; // representations.forEach(rep => { // const baseTrack = { // id: rep.id, // codec: rep.codecs, // bandwidth: parseInt(rep.bandwidth), // defaultKID: defaultKID ? defaultKID.replace(/-/g, '') : null, // duration: parseDuration(period.duration), // formatedDuration: formatDuration(parseDuration(period.duration)) // }; // if (contentType === 'video') { // obj.videoTracks.push({ // ...baseTrack, // name: `${rep.width}x${rep.height}`, // fps: parseFloat(rep.frameRate), // resolution: { // width: parseInt(rep.width), // height: parseInt(rep.height) // } // }); // } else if (contentType === 'audio') { // obj.audioTracks.push({ // ...baseTrack, // name: rep.id || `${adaptSet.lang || 'unknown'}_${rep.bandwidth}`, // language: adaptSet.lang || 'unknown', // channels: rep.AudioChannelConfiguration ? rep.AudioChannelConfiguration.value + 'CH' : '2CH', // audioSamplingRate: parseInt(rep.audioSamplingRate) // }); // } // }); // } // }); // } // }); // } // obj.videoTracks = obj.videoTracks.sort((a, b) => b.resolution.width - a.resolution.width); // obj.audioTracks = obj.audioTracks.sort((a, b) => b.bandwidth - a.bandwidth); // obj.videoTracks = obj.videoTracks.filter((track, index, self) => // index === self.findIndex(t => t.id === track.id && t.bandwidth === track.bandwidth) // ); // obj.audioTracks = obj.audioTracks.filter((track, index, self) => // index === self.findIndex(t => t.id === track.id && t.bandwidth === track.bandwidth) // ); // return obj; // }; // const parseMPDStream = async (mpdUrl) => { // const mpdResponse = await axios({ // url: mpdUrl, // method: 'GET', // responseType: 'text' // }); // const eventHandler = ({ type, message }) => console.log(`${type}: ${message}`); // const parsedManifest = mpdParser.parse(mpdResponse.data , { mpdUrl, eventHandler }); // const obj = { // audioTracks: [], // videoTracks: [], // subtitles: [] // }; // // Ajoutez ça temporairement dans votre fonction parseMPDStream après le parsing // console.log('=== DEBUG STRUCTURE ==='); // // Debug premier élément vidéo // if (parsedManifest.playlists && parsedManifest.playlists.length > 0) { // console.log('\n🎥 FIRST VIDEO TRACK:'); // console.log(JSON.stringify(parsedManifest.playlists[0], null, 2)); // } // // // Debug premier élément audio // // try { // // const audioGroups = parsedManifest?.mediaGroups?.AUDIO?.audio; // // if (audioGroups) { // // const firstAudioKey = Object.keys(audioGroups)[0]; // // console.log('\n🔊 FIRST AUDIO TRACK:'); // // console.log(`Key: "${firstAudioKey}"`); // // console.log('Group:', JSON.stringify(audioGroups[firstAudioKey], null, 2)); // // if (audioGroups[firstAudioKey].playlists[0]) { // // console.log('First playlist:', JSON.stringify(audioGroups[firstAudioKey].playlists[0], null, 2)); // // } // // } // // } catch(e) { // // console.log('No audio debug possible:', e.message); // // } // // // Debug premier élément subtitle // // try { // // const subsGroups = parsedManifest?.mediaGroups?.SUBTITLES?.subs; // // if (subsGroups) { // // const firstSubKey = Object.keys(subsGroups)[0]; // // console.log('\n📝 FIRST SUBTITLE TRACK:'); // // console.log(`Key: "${firstSubKey}"`); // // console.log('Group:', JSON.stringify(subsGroups[firstSubKey], null, 2)); // // if (subsGroups[firstSubKey].playlists[0]) { // // console.log('First playlist:', JSON.stringify(subsGroups[firstSubKey].playlists[0], null, 2)); // // } // // } // // } catch(e) { // // console.log('No subtitle debug possible:', e.message); // // } // console.log('=== END DEBUG ===\n'); // // Fonction pour filtrer les durées anormales (< 10min ou > 4h) // const isValidDuration = (duration) => { // if (!duration) return true; // Garder si pas de durée spécifiée // const durationInSeconds = duration; // Conversion en secondes si nécessaire // return durationInSeconds >= 600 && durationInSeconds <= 14400; // Entre 10min et 4h // }; // // Traitement des groupes de média (AUDIO et SUBTITLES) // const toParse = [{ // rootProp: 'AUDIO', // subProp: 'audio', // targetProp: 'audioTracks' // }, { // rootProp: 'SUBTITLES', // subProp: 'subs', // targetProp: 'subtitles' // }]; // toParse.forEach(({ rootProp, subProp, targetProp }) => { // try { // for (const [key, value] of Object.entries(parsedManifest?.mediaGroups?.[rootProp]?.[subProp])) { // for (let i = 0; i < value.playlists.length; i++) { // const playlist = value.playlists[i]; // // Filtrer les tracks avec des durées anormales // if (!isValidDuration(playlist.targetDuration)) { // console.log(`🚫 Filtered out ${targetProp} "${key}" with suspicious duration: ${playlist.targetDuration}min`); // continue; // } // if (targetProp === 'audioTracks') { // obj.audioTracks.push({ // id: playlist?.attributes?.NAME || key, // name: key, // language: value.language || 'unknown', // codec: playlist?.attributes?.CODECS, // bandwidth: playlist?.attributes?.BANDWIDTH, // channels: playlist?.attributes?.CHANNELS || '2CH', // defaultKID: playlist?.contentProtection?.mp4protection?.attributes['cenc:default_KID']?.replaceAll('-', '') || null, // duration: playlist?.targetDuration, // formatedDuration: formatDuration(playlist?.targetDuration || 0), // attributes: playlist.attributes // }); // } else if (targetProp === 'subtitles') { // obj.subtitles.push({ // id: playlist?.attributes?.NAME || key, // name: key, // language: value.language || 'unknown', // codec: playlist?.attributes?.CODECS, // format: playlist?.attributes?.FORMAT || 'UNKNOWN', // defaultKID: playlist?.contentProtection?.mp4protection?.attributes['cenc:default_KID']?.replaceAll('-', '') || null, // duration: playlist?.targetDuration, // formatedDuration: formatDuration(playlist?.targetDuration || 0), // attributes: playlist.attributes // }); // } // } // } // } catch(e) { // console.log(`No ${targetProp} found in manifest`); // } // }); // // Traitement des tracks vidéo // for (let i = 0; i < parsedManifest.playlists.length; i++) { // const playlist = parsedManifest.playlists[i]; // // Filtrer les tracks vidéo avec des durées anormales // if (!isValidDuration(playlist.targetDuration)) { // console.log(`🚫 Filtered out video track with suspicious duration: ${playlist.targetDuration}min`); // continue; // } // obj.videoTracks.push({ // id: playlist?.attributes?.NAME, // name: `${playlist?.attributes?.RESOLUTION?.width || 'N/C'}x${playlist?.attributes?.RESOLUTION?.height || 'N/C'}`, // codec: playlist?.attributes?.CODECS, // bandwidth: playlist?.attributes?.BANDWIDTH, // defaultKID: playlist?.contentProtection?.mp4protection?.attributes['cenc:default_KID']?.replaceAll('-', '') || null, // fps: playlist?.attributes?.['FRAME-RATE'], // resolution: { // width: playlist?.attributes?.RESOLUTION?.width, // height: playlist?.attributes?.RESOLUTION?.height // }, // duration: playlist?.targetDuration, // formatedDuration: formatDuration(playlist?.targetDuration || 0), // attributes: playlist.attributes // }); // } // // Tri des tracks par qualité/priorité // obj.videoTracks = obj.videoTracks.sort((a, b) => b.resolution.width - a.resolution.width); // obj.audioTracks = obj.audioTracks.sort((a, b) => (b.bandwidth || 0) - (a.bandwidth || 0)); // return obj; // }; app.post('/processMPD', async (req, res, next) => { try { res.json(await parseMPDStream(req.body.mpdUrl)); } catch(e) { console.log(e); next(e); } }); app.use((err, req, res, next) => { res.status(500).json({ error: err.message || err.toString() || 'An error occured' }); }); const safeMove = async (source, destination) => { try { const destDir = path.dirname(destination); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } fs.renameSync(source, destination); console.log(`✓ Moved: ${path.basename(source)} -> ${destination}`); } catch (error) { if (error.code === 'EXDEV') { console.log(`⚠️ Cross-device detected, copying: ${path.basename(source)}`); fs.copyFileSync(source, destination); fs.unlinkSync(source); console.log(`✓ Copied: ${path.basename(source)} -> ${destination}`); } else if (error.code === 'ENOENT') { console.error(`❌ Source file not found: ${source}`); throw new Error(`Source file not found: ${source}`); } else { console.error(`❌ Move failed:`, error); throw error; } } }; // 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 ${scale}x upscale with ${method}...`); const inputDir = path.dirname(inputFilePath); const inputName = path.basename(inputFilePath, '.mp4'); const outputFilePath = path.join(inputDir, `${inputName}${outputSuffix}.mp4`); // 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 videoStream = info.streams.find(stream => stream.codec_type === 'video'); 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'; // 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 (matchingSrtFile) { console.log(`🔗 Matching ${language} subtitle with: ${path.basename(matchingSrtFile)}`); const { isForced, trackName } = extractSubtitleInfo(subtitleInfo, matchingSrtFile); mkvCommand += ` --language 0:${language}`; mkvCommand += ` --track-name "0:${trackName}"`; if (isForced) { mkvCommand += ` --forced-track 0:yes`; } const defaultAudioLang = wantedAudioTracks[0]?.language; const isDefault = (index === 0 && language === defaultAudioLang) ? 'yes' : 'no'; mkvCommand += ` --default-track 0:${isDefault}`; mkvCommand += ` "${matchingSrtFile}"`; } else { console.warn(`⚠️ No subtitle file found for language: ${language}`); } }); 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.codec?.split('.')[0] || 'unknown'; const bitrate = Math.round((audioTrack.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; // 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 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 { 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; // 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); // // 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); // // 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); // // 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++; // } // if (!fs.existsSync(finalPath)) // fs.mkdirSync(finalPath); // ffmpegCommand += `${mapCommand} -c copy ${finalPath}/${mp4FilenameWithExt}`; // await runCommand(ffmpegCommand); // job.progress(90); // if (wantedRemux) { // console.log('🎬 Starting optional MKV remux...'); // try { // await remuxToMKV({ // mp4FilePath: `${finalPath}/${mp4FilenameWithExt}`, // outputPath: finalPath, // filename: mp4Filename, // wantedAudioTracks, // wantedSubtitles, // videoInfo: wantedResolution // }); // job.progress(95); // console.log('✅ MKV remux completed successfully'); // } 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`; // await safeMove(sourcePath, destPath); // counter++; // } // } // // 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}`)); // } // }); // }); 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 }); } } const downloadMPDManifest = async (url, filepath) => { try { const response = await axios({ method: 'GET', url, headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', 'Accept-Encoding': 'gzip, deflate, br, zstd' }, decompress: true }); fs.writeFileSync(filepath, response.data); return response.data; } catch (error) { console.error('Erreur lors du téléchargement:', error.message); throw error; } }; 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...'); const downloadParams = buildDownloadParameters(wantedAudioTracks, wantedSubtitles); const objectsToDownload = 1 + wantedAudioTracks.length + wantedSubtitles.length; job.progress(10); await downloadMPDManifest(mpdUrl, `${workdir}/manifest.mpd`); const baseUrl = mpdUrl.substring(0, mpdUrl.lastIndexOf('/') + 1); const command = `${downloaderPath} "${workdir}/manifest.mpd" --base-url "${baseUrl}" --save-dir "${workdir}" --save-name "${mp4Filename}_encrypted" --select-video id="${wantedResolution.id}" ${downloadParams.audioPart} ${downloadParams.subPart} -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0"`; 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 la sélection par ID pour audio const audioPart = wantedAudioTracks.length > 0 ? `--select-audio id="${wantedAudioTracks.map(elem => elem.id).join('|')}":for=all` : '--drop-audio id=".*"'; // Construire la sélection par ID pour sous-titres const subPart = wantedSubtitles.length > 0 ? `--select-subtitle id="${wantedSubtitles.map(elem => elem.id).join('|')}":for=all` : '--drop-subtitle id=".*"'; 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 = [{ path: BASE_PATH, name: 'data' }, { path: OUTPUT_PATH, name: 'output' }, { path: TMP_PATH, name: 'tmp' }]; for (let i = 0; i < dirsToCheck.length; i++) { const dir = dirsToCheck[i]; if (!fs.existsSync(dir.path)) { console.log(`Creating ${dir.name} directory...`); fs.mkdirSync(dir.path); } } softwareService.init(); });