1751 lines
58 KiB
JavaScript
1751 lines
58 KiB
JavaScript
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, `${TMP_PATH}/manifest.mpd`);
|
|
|
|
// 1. Parser le XML pour récupérer les KID
|
|
const mpdContent = fs.readFileSync(`${TMP_PATH}/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} "${TMP_PATH}/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();
|
|
|
|
}); |