From 2e989fbe83f00691ed0a955fe82d0e1bf1b1b7df Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 5 Dec 2023 21:19:17 +0200 Subject: [PATCH] Add BinaryManager --- .gitignore | 2 + server/Server.js | 3 + server/managers/BinaryManager.js | 79 +++++++ test/server/managers/BinaryManager.test.js | 262 +++++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 server/managers/BinaryManager.js create mode 100644 test/server/managers/BinaryManager.test.js diff --git a/.gitignore b/.gitignore index 9360600a..0690f38f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ /deploy/ /coverage/ /.nyc_output/ +/ffmpeg* +/ffprobe* sw.* .DS_STORE diff --git a/server/Server.js b/server/Server.js index 5e8cab76..9104208d 100644 --- a/server/Server.js +++ b/server/Server.js @@ -33,6 +33,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') const ApiCacheManager = require('./managers/ApiCacheManager') +const BinaryManager = require('./managers/BinaryManager') const LibraryScanner = require('./scanner/LibraryScanner') //Import the main Passport and Express-Session library @@ -74,6 +75,7 @@ class Server { this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager) this.apiCacheManager = new ApiCacheManager() + this.binaryManager = new BinaryManager() // Routers this.apiRouter = new ApiRouter(this) @@ -119,6 +121,7 @@ class Server { const libraries = await Database.libraryModel.getAllOldLibraries() await this.cronManager.init(libraries) this.apiCacheManager.init() + await this.binaryManager.init() if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js new file mode 100644 index 00000000..d2a3c1f7 --- /dev/null +++ b/server/managers/BinaryManager.js @@ -0,0 +1,79 @@ +const path = require('path') +const which = require('../libs/which') +const fs = require('../libs/fsExtra') +const Logger = require('../Logger') +const ffbinaries = require('ffbinaries') +const { promisify } = require('util') + +class BinaryManager { + downloadBinaries = promisify(ffbinaries.downloadBinaries) + + defaultRequiredBinaries = [ + { name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }, + { name: 'ffprobe', envVariable: 'FFPROBE_PATH' } + ] + + constructor(requiredBinaries = this.defaultRequiredBinaries) { + this.requiredBinaries = requiredBinaries + this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot + this.altInstallPath = global.ConfigPath + } + + async init() { + if (this.initialized) return + const missingBinaries = await this.findRequiredBinaries() + if (missingBinaries.length == 0) return + await this.install(missingBinaries) + const missingBinariesAfterInstall = await this.findRequiredBinaries() + if (missingBinariesAfterInstall.length != 0) { + Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingBinariesAfterInstall.join(', ')}`) + process.exit(1) + } + this.initialized = true + } + + async findRequiredBinaries() { + const missingBinaries = [] + for (const binary of this.requiredBinaries) { + const binaryPath = await this.findBinary(binary.name, binary.envVariable) + if (binaryPath) { + Logger.info(`[BinaryManager] Found ${binary.name} at ${binaryPath}`) + Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`) + process.env[binary.envVariable] = binaryPath + } else { + Logger.info(`[BinaryManager] ${binary.name} not found`) + missingBinaries.push(binary.name) + } + } + return missingBinaries + } + + async findBinary(name, envVariable) { + const executable = name + (process.platform == 'win32' ? '.exe' : '') + const defaultPath = process.env[envVariable] + if (defaultPath && await fs.pathExists(defaultPath)) return defaultPath + const whichPath = which.sync(executable, { nothrow: true }) + if (whichPath) return whichPath + const mainInstallPath = path.join(this.mainInstallPath, executable) + if (await fs.pathExists(mainInstallPath)) return mainInstallPath + const altInstallPath = path.join(this.altInstallPath, executable) + if (await fs.pathExists(altInstallPath)) return altInstallPath + return null + } + + async install(binaries) { + if (binaries.length == 0) return + Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`) + let destination = this.mainInstallPath + try { + await fs.access(destination, fs.constants.W_OK) + } catch (err) { + destination = this.altInstallPath + } + await this.downloadBinaries(binaries, { destination }) + Logger.info(`[BinaryManager] Binaries installed to ${destination}`) + } + +} + +module.exports = BinaryManager \ No newline at end of file diff --git a/test/server/managers/BinaryManager.test.js b/test/server/managers/BinaryManager.test.js new file mode 100644 index 00000000..8e09f62f --- /dev/null +++ b/test/server/managers/BinaryManager.test.js @@ -0,0 +1,262 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const fs = require('../../../server/libs/fsExtra'); +const which = require('../../../server/libs/which'); +const path = require('path'); +const BinaryManager = require('../../../server/managers/BinaryManager'); + +const expect = chai.expect; + +describe('BinaryManager', () => { + let binaryManager; + + describe('init', () => { + let findStub; + let installStub; + let errorStub; + let exitStub; + + beforeEach(() => { + binaryManager = new BinaryManager(); + findStub = sinon.stub(binaryManager, 'findRequiredBinaries'); + installStub = sinon.stub(binaryManager, 'install'); + errorStub = sinon.stub(console, 'error'); + exitStub = sinon.stub(process, 'exit'); + }); + + afterEach(() => { + findStub.restore(); + installStub.restore(); + errorStub.restore(); + exitStub.restore(); + }); + + it('should not install binaries if they are already found', async () => { + findStub.resolves([]); + + await binaryManager.init(); + + expect(installStub.called).to.be.false; + expect(findStub.calledOnce).to.be.true; + expect(errorStub.called).to.be.false; + expect(exitStub.called).to.be.false; + }); + + it('should install missing binaries', async () => { + const missingBinaries = ['ffmpeg', 'ffprobe']; + const missingBinariesAfterInstall = []; + findStub.onFirstCall().resolves(missingBinaries); + findStub.onSecondCall().resolves(missingBinariesAfterInstall); + + await binaryManager.init(); + + expect(findStub.calledTwice).to.be.true; + expect(installStub.calledOnce).to.be.true; + expect(errorStub.called).to.be.false; + expect(exitStub.called).to.be.false; + }); + + it('exit if binaries are not found after installation', async () => { + const missingBinaries = ['ffmpeg', 'ffprobe']; + const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe']; + findStub.onFirstCall().resolves(missingBinaries); + findStub.onSecondCall().resolves(missingBinariesAfterInstall); + + await binaryManager.init(); + + expect(findStub.calledTwice).to.be.true; + expect(installStub.calledOnce).to.be.true; + expect(errorStub.calledOnce).to.be.true; + expect(exitStub.calledOnce).to.be.true; + expect(exitStub.calledWith(1)).to.be.true; + }); + }); + + + describe('findRequiredBinaries', () => { + let findBinaryStub; + + beforeEach(() => { + const requiredBinaries = [{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }]; + binaryManager = new BinaryManager(requiredBinaries); + findBinaryStub = sinon.stub(binaryManager, 'findBinary'); + }); + + afterEach(() => { + findBinaryStub.restore(); + }); + + it('should put found paths in the correct environment variables', async () => { + const pathToFFmpeg = '/path/to/ffmpeg'; + const missingBinaries = []; + delete process.env.FFMPEG_PATH; + findBinaryStub.resolves(pathToFFmpeg); + + const result = await binaryManager.findRequiredBinaries(); + + expect(result).to.deep.equal(missingBinaries); + expect(findBinaryStub.calledOnce).to.be.true; + expect(process.env.FFMPEG_PATH).to.equal(pathToFFmpeg); + }); + + it('should add missing binaries to result', async () => { + const missingBinaries = ['ffmpeg']; + delete process.env.FFMPEG_PATH; + findBinaryStub.resolves(null); + + const result = await binaryManager.findRequiredBinaries(); + + expect(result).to.deep.equal(missingBinaries); + expect(findBinaryStub.calledOnce).to.be.true; + expect(process.env.FFMPEG_PATH).to.be.undefined; + }); + }); + + describe('install', () => { + let accessStub; + let downloadBinariesStub; + + beforeEach(() => { + binaryManager = new BinaryManager(); + accessStub = sinon.stub(fs, 'access'); + downloadBinariesStub = sinon.stub(binaryManager, 'downloadBinaries'); + binaryManager.mainInstallPath = '/path/to/main/install' + binaryManager.altInstallPath = '/path/to/alt/install' + }); + + afterEach(() => { + accessStub.restore(); + downloadBinariesStub.restore(); + }); + + it('should not install binaries if no binaries are passed', async () => { + const binaries = []; + + await binaryManager.install(binaries); + + expect(accessStub.called).to.be.false; + expect(downloadBinariesStub.called).to.be.false; + }); + + it('should install binaries in main install path if has access', async () => { + const binaries = ['ffmpeg']; + const destination = binaryManager.mainInstallPath; + accessStub.withArgs(destination, fs.constants.W_OK).resolves(); + downloadBinariesStub.resolves(); + + await binaryManager.install(binaries); + + expect(accessStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true; + }); + + it('should install binaries in alt install path if has no access to main', async () => { + const binaries = ['ffmpeg']; + const mainDestination = binaryManager.mainInstallPath; + const destination = binaryManager.altInstallPath; + accessStub.withArgs(mainDestination, fs.constants.W_OK).rejects(); + downloadBinariesStub.resolves(); + + await binaryManager.install(binaries); + + expect(accessStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true; + }); + }); +}); + +describe('findBinary', () => { + let binaryManager; + let fsPathExistsStub; + let whichSyncStub; + let mainInstallPath; + let altInstallPath; + + const name = 'ffmpeg'; + const envVariable = 'FFMPEG_PATH'; + const defaultPath = '/path/to/ffmpeg'; + const executable = name + (process.platform == 'win32' ? '.exe' : ''); + const whichPath = '/usr/bin/ffmpeg'; + + + beforeEach(() => { + binaryManager = new BinaryManager(); + fsPathExistsStub = sinon.stub(fs, 'pathExists'); + whichSyncStub = sinon.stub(which, 'sync'); + binaryManager.mainInstallPath = '/path/to/main/install' + mainInstallPath = path.join(binaryManager.mainInstallPath, executable); + binaryManager.altInstallPath = '/path/to/alt/install' + altInstallPath = path.join(binaryManager.altInstallPath, executable); + }); + + afterEach(() => { + fsPathExistsStub.restore(); + whichSyncStub.restore(); + }); + + it('should return defaultPath if it exists', async () => { + process.env[envVariable] = defaultPath; + fsPathExistsStub.withArgs(defaultPath).resolves(true); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(defaultPath); + expect(fsPathExistsStub.calledOnceWith(defaultPath)).to.be.true; + expect(whichSyncStub.notCalled).to.be.true; + }); + + it('should return whichPath if it exists', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(whichPath); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(whichPath); + expect(fsPathExistsStub.notCalled).to.be.true; + expect(whichSyncStub.calledOnce).to.be.true; + }); + + it('should return mainInstallPath if it exists', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(null); + fsPathExistsStub.withArgs(mainInstallPath).resolves(true); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(mainInstallPath); + expect(whichSyncStub.calledOnce).to.be.true; + expect(fsPathExistsStub.calledOnceWith(mainInstallPath)).to.be.true; + }); + + it('should return altInstallPath if it exists', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(null); + fsPathExistsStub.withArgs(mainInstallPath).resolves(false); + fsPathExistsStub.withArgs(altInstallPath).resolves(true); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(altInstallPath); + expect(whichSyncStub.calledOnce).to.be.true; + expect(fsPathExistsStub.calledTwice).to.be.true; + expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true; + expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true; + }); + + it('should return null if binary is not found', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(null); + fsPathExistsStub.withArgs(mainInstallPath).resolves(false); + fsPathExistsStub.withArgs(altInstallPath).resolves(false); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.be.null; + expect(whichSyncStub.calledOnce).to.be.true; + expect(fsPathExistsStub.calledTwice).to.be.true; + expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true; + expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true; + }); +}); \ No newline at end of file