From 4802f808cf630859b63793c45fd7ef7f63fcafcb Mon Sep 17 00:00:00 2001 From: Joris Bertomeu Date: Tue, 1 Oct 2024 17:44:39 +0200 Subject: [PATCH] Add new binary update mechanism --- .dockerignore | 1 + .gitignore | 1 + index.js | 52 +++++++- package.json | 6 +- services/softwares.js | 275 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 services/softwares.js diff --git a/.dockerignore b/.dockerignore index 164cfb4..7fbe254 100644 --- a/.dockerignore +++ b/.dockerignore @@ -47,3 +47,4 @@ jspm_packages # Docker compose spec docker-compose.* +data/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 158c0e6..5e17433 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ jspm_packages [Uu]ploads/ dist/ +data/ \ No newline at end of file diff --git a/index.js b/index.js index e47b832..56abdcd 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,13 @@ const express = require('express'); const Queue = require('bull'); -const { exec, spawn } = require('child_process'); -const fs = require('fs').promises; +const { spawn } = require('child_process'); +const fs = require('fs'); const path = require('path'); const cors = require('cors'); const EventEmitter = require('events'); +const softwareService = require('./services/softwares'); -const BASE_PATH = `/data`; +const BASE_PATH = process.env.DATA_PATH || `./data`; const OUTPUT_PATH = `${BASE_PATH}/output`; const TMP_PATH = `${BASE_PATH}/tmp`; @@ -134,6 +135,28 @@ app.get('/download/:filename', async (req, res) => { res.download(file); }); +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) => { return new Promise(async (resolve, reject) => { @@ -235,7 +258,7 @@ videoQueue.process((job) => { let counter = 1; for (const file of audioFiles) { if (file.startsWith(`${mp4TmpFilepath}_encrypted`) && file.endsWith('.srt')) { - await fs.rename(file, `${mp4FinalFilepath}_${counter}.srt`); + fs.renameSync(file, `${mp4FinalFilepath}_${counter}.srt`); counter++; } } @@ -254,4 +277,25 @@ videoQueue.process((job) => { 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(); + }); \ No newline at end of file diff --git a/package.json b/package.json index 279ae6b..82adf2f 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,14 @@ "author": "", "license": "ISC", "dependencies": { + "axios": "^1.7.7", "bull": "^4.16.3", + "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^4.21.0", "fs": "^0.0.1-security", - "path": "^0.12.7" + "path": "^0.12.7", + "tar": "^7.4.3", + "unzipper": "^0.12.3" } } diff --git a/services/softwares.js b/services/softwares.js new file mode 100644 index 0000000..cc8c8f2 --- /dev/null +++ b/services/softwares.js @@ -0,0 +1,275 @@ +const axios = require('axios'); +const os = require('os'); +const fs = require("fs"); +const cheerio = require('cheerio'); +const unzipper = require('unzipper'); +const tar = require('tar'); + + +const BASE_PATH = process.env.DATA_PATH || `./data`; +const BIN_PATH = `${BASE_PATH}/bin`; + +const fetchAndParseHTML = async (url) => { + try { + const { data: html } = await axios.get(url); + + const $ = cheerio.load(html); + + const files = []; + + $('a').each((index, element) => { + const fileName = $(element).text().trim(); + const href = $(element).attr('href'); + + const nextElement = $(element).nextAll().filter((i, el) => $(el).text().trim() !== '-').first(); + const details = nextElement.text().trim().split(/\s+/); + if (href && fileName !== "Parent Directory") { + files.push({ + fileName: href, + href: `https://www.bok.net/Bento4/binaries/${href}` + }); + } + }); + + return files; + } catch (error) { + console.error('Erreur lors de la récupération/parsing:', error); + } +} + +const getLatestBentoBinary = async (platform) => { + try { + const last = (await fetchAndParseHTML('https://www.bok.net/Bento4/binaries/')).slice(-3); + + const releases = last.map((elem) => { + let version = elem.fileName.split('.'); + version = version?.[0]?.split('Bento4-SDK-')?.[1] || null; + + return { + downloadUrl: elem.href, + filename: elem.fileName, + platform: elem.fileName.includes('macosx') ? 'darwin' : elem.fileName.includes('linux') ? 'linux' : 'win32', + version + } + }); + return releases.find((elem) => elem.platform === platform); + } catch(e) { + throw e; + } +} + +const translateDownloaderArch = () => { + const platform = os.platform(); + const arch = os.arch(); + + switch (platform) { + case 'linux': + if (arch === 'x64') { + return 'linux-x64'; + } else if (arch === 'arm64') { + return 'linux-arm64'; + } + break; + + case 'darwin': // macOS + if (arch === 'x64') { + return 'osx-x64'; + } else if (arch === 'arm64') { + return 'osx-arm64'; + } + break; + + case 'win32': // Windows + if (arch === 'x64') { + return 'win-x64'; + } else if (arch === 'arm64') { + return 'win-arm64'; + } + break; + + default: + throw new Error(`OS or architecture unsuported : ${platform}, ${arch}`); + } + + throw new Error(`Unsuported architecture for this platform ${platform} : ${arch}`); +}; + +const writeBinVersion = (versionFilePath, version) => { + const data = { version }; + + try { + fs.writeFileSync(versionFilePath, JSON.stringify(data, null, 2), 'utf8'); + } catch (error) { + throw e; + } +} + +getBinVersion = (versionFilePath) => { + try { + const data = fs.readFileSync(versionFilePath, 'utf8'); + const json = JSON.parse(data); + return json.version || null; + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } else { + throw error; + } + } +} + +const getLocalBinFileInfo = (binType) => { + if (['downloader', 'mp4decrypt'].includes(binType)) { + const path = `${BIN_PATH}/${binType}`; + return { + path, + stat: fs.existsSync(path) ? fs.statSync(path) : null, + version: getBinVersion(`${BIN_PATH}/.${binType}.version`) + } + } else { + throw new Error(`Bad binType "${binType}" provided`); + } +} + +const downloadFile = async (url, dest) => { + const writer = fs.createWriteStream(dest); + const response = await axios({ + url, + method: 'GET', + responseType: 'stream', + }); + response.data.pipe(writer); + return new Promise((resolve, reject) => { + writer.on('finish', resolve); + writer.on('error', reject); + }); +} + +const checkDownloaderUpdate = async () => { + try { + const localInfos = getLocalBinFileInfo('downloader'); + const remoteInfos = await getLatestGithubReleaseAssetUrl('nilaoda', 'N_m3u8DL-RE', translateDownloaderArch()); + return { + details: { + localInfos, + remoteInfos + }, + newReleaseAvailable: localInfos?.version ? parseInt(localInfos.version) !== remoteInfos?.id : remoteInfos?.name || false, + filsIsPresent: localInfos?.stat ? true : false + } + } catch(e) { + throw e; + } +} + +const extractFile = (source, destination) => { + return new Promise((resolve, reject) => { + if (source.endsWith('.zip')) { + fs.createReadStream(source) + .pipe(unzipper.Extract({ path: destination })) + .on('close', resolve) + .on('error', reject); + } else if (source.endsWith('.tar.gz')) { + fs.createReadStream(source) + .pipe(tar.x({ C: destination })) + .on('close', resolve) + .on('error', reject); + } else { + reject(new Error('Unsupported file type')); + } + }); +} + +const processUpdate = async (data) => { + const tmpUpdatePath = `${BIN_PATH}/update/`; + + try { + let downloadURL = data.binType === 'downloader' ? data.details.remoteInfos.browser_download_url : data.details.remoteInfos.downloadUrl; + let filename = data.binType === 'downloader' ? data.details.remoteInfos.name : data.details.remoteInfos.filename; + fs.mkdirSync(tmpUpdatePath); + const zipDest = `${tmpUpdatePath}/${filename}`; + console.log('Will download file from ' + downloadURL); + await downloadFile(downloadURL, zipDest); + console.log('File downloaded to ' + zipDest) + console.log('Will decompress downloaded file'); + const unzippedFolderDest = `${tmpUpdatePath}/${data.binType}_tmp` + fs.mkdirSync(unzippedFolderDest); + await extractFile(zipDest, unzippedFolderDest); + console.log('Unzipped !'); + const uncompressedFoldersS = fs.readdirSync(unzippedFolderDest); + console.log(uncompressedFoldersS) + if (uncompressedFoldersS.length !== 1) + throw new Error('Unable to retrieve decompressed folder'); + const uncompressedFolders = fs.readdirSync(`${unzippedFolderDest}/${uncompressedFoldersS[0]}`); + if (uncompressedFolders.length === 0) + throw new Error('Unable to retrieve archive content'); + if (data.binType === 'downloader') + fs.renameSync(`${unzippedFolderDest}/${uncompressedFoldersS[0]}/${uncompressedFolders[0]}`, data.details.localInfos.path); + else if (data.binType === 'mp4decrypt') + fs.renameSync(`${unzippedFolderDest}/${uncompressedFoldersS[0]}/bin/mp4decrypt`, data.details.localInfos.path); + writeBinVersion(`${BIN_PATH}/.${data.binType}.version`, data.details.remoteInfos[data.binType === 'downloader' ? 'id' : 'version']) + } catch(e) { + throw e; + } finally { + fs.rmSync(tmpUpdatePath, { recursive: true, force: true }); + } +} + +const checkMp4decryptUpdate = async () => { + try { + const localInfos = getLocalBinFileInfo('mp4decrypt'); + const remoteInfos = await getLatestBentoBinary(os.platform()); + console.log(remoteInfos); + return { + details: { + localInfos, + remoteInfos + }, + newReleaseAvailable: localInfos?.version ? localInfos.version !== remoteInfos?.version : remoteInfos?.filename || false, + filsIsPresent: localInfos?.stat ? true : false + } + } catch(e) { + throw e; + } +} + +const getLatestGithubReleaseAssetUrl = async (owner, repo, assetName) => { + try { + const response = await axios.get(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, { + headers: { 'User-Agent': 'node.js' } + }); + + const release = response.data; + const asset = release.assets.find(a => a.name.includes(assetName)); + + if (asset) { + return asset; + } else { + throw new Error('Asset not found'); + } + } catch (error) { + console.error('Erreur:', error.message); + throw error; + } +} + +const init = async () => { + try { + if (!fs.existsSync(BIN_PATH)) { + fs.mkdirSync(BIN_PATH); + console.log('Creating bin path...') + } + } catch(e) { + throw e; + } +} + +module.exports = { + init, + getLatestGithubReleaseAssetUrl, + translateDownloaderArch, + getLocalBinFileInfo, + checkDownloaderUpdate, + checkMp4decryptUpdate, + processUpdate +};