diff --git a/.gitignore b/.gitignore index 3da1aec..89ee0c0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ __pycache__/ # C extensions *.so +# runtime config +runtime_config.json + # Distribution / packaging .Python build/ diff --git a/MediaProcessor/cmake/test.cmake b/MediaProcessor/cmake/test.cmake index 1bd67b9..0310695 100644 --- a/MediaProcessor/cmake/test.cmake +++ b/MediaProcessor/cmake/test.cmake @@ -14,6 +14,7 @@ endif() # Setup test media directory set(TEST_MEDIA_DIR "${CMAKE_SOURCE_DIR}/tests/TestMedia" CACHE PATH "Path to test media files") +file(TO_CMAKE_PATH "${TEST_MEDIA_DIR}" TEST_MEDIA_DIR) FetchContent_Declare( fmt @@ -22,8 +23,29 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(fmt) -# Common libraries for all test targets -set(COMMON_LIBRARIES gtest_main ${CMAKE_SOURCE_DIR}/lib/libdf.so ${SNDFILE_LIBRARIES} fmt::fmt) +if(APPLE) + include(CheckCXXCompilerFlag) + + # This fixes arch detection on macOS + check_cxx_compiler_flag("-arch arm64" COMPILER_SUPPORTS_ARM64) + + if(COMPILER_SUPPORTS_ARM64 AND CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL "arm64") + set(DF_LIBRARY ${CMAKE_SOURCE_DIR}/lib/libdf.dylib) + else() + message(FATAL_ERROR "Unsupported macOS architecture: ${CMAKE_HOST_SYSTEM_PROCESSOR}") + # set(DF_LIBRARY ${CMAKE_SOURCE_DIR}/lib/libdf.dylib) + endif() +elseif(WIN32) + set(DF_LIBRARY ${CMAKE_SOURCE_DIR}/lib/libdf.dll.a) # for linktime + set(DF_DLL_PATH ${CMAKE_SOURCE_DIR}/lib/df.dll) # for runtime + +elseif(UNIX) + set(DF_LIBRARY ${CMAKE_SOURCE_DIR}/lib/libdf.so) +else() + message(FATAL_ERROR "Unsupported platform") +endif() + +set(COMMON_LIBRARIES gtest_main ${DF_LIBRARY} ${SNDFILE_LIBRARIES} fmt::fmt) # Macro for adding a test executable macro(add_test_executable name) diff --git a/MediaProcessor/lib/libdf.dylib b/MediaProcessor/lib/libdf.dylib index 2e42192..33d6812 100755 Binary files a/MediaProcessor/lib/libdf.dylib and b/MediaProcessor/lib/libdf.dylib differ diff --git a/MediaProcessor/src/AudioProcessor.cpp b/MediaProcessor/src/AudioProcessor.cpp index 496d6c6..77d47c5 100644 --- a/MediaProcessor/src/AudioProcessor.cpp +++ b/MediaProcessor/src/AudioProcessor.cpp @@ -167,7 +167,9 @@ bool AudioProcessor::invokeDeepFilterFFI(fs::path chunkPath, DFState* df_state, std::vector& inputBuffer, std::vector& outputBuffer) { SF_INFO sfInfoIn; - SNDFILE* inputFile = sf_open(chunkPath.c_str(), SFM_READ, &sfInfoIn); + // First Convert to string + // On windows c_str converts to utf_16 but sf_open requires utf_8 + SNDFILE* inputFile = sf_open(chunkPath.string().c_str(), SFM_READ, &sfInfoIn); if (!inputFile) { std::cerr << "Error: Could not open input WAV file: " << chunkPath << std::endl; return false; @@ -175,7 +177,7 @@ bool AudioProcessor::invokeDeepFilterFFI(fs::path chunkPath, DFState* df_state, // Prepare output file fs::path processedChunkPath = m_processedChunksPath / chunkPath.filename(); - SNDFILE* outputFile = sf_open(processedChunkPath.c_str(), SFM_WRITE, &sfInfoIn); + SNDFILE* outputFile = sf_open(processedChunkPath.string().c_str(), SFM_WRITE, &sfInfoIn); if (!outputFile) { std::cerr << "Error: Could not open output WAV file: " << processedChunkPath << std::endl; sf_close(inputFile); @@ -213,8 +215,8 @@ bool AudioProcessor::filterChunks() { for (int i = 0; i < m_numChunks; ++i) { results.emplace_back(pool.enqueue([&, i]() { // Per-thread DFState instance - DFState* df_state = - df_create(deepFilterTarballPath.c_str(), m_filterAttenuationLimit, nullptr); + DFState* df_state = df_create(deepFilterTarballPath.string().c_str(), + m_filterAttenuationLimit, nullptr); if (!df_state) { std::cerr << "Error: Failed to insantiate DFState in thread." << std::endl; return false; diff --git a/MediaProcessor/src/Engine.cpp b/MediaProcessor/src/Engine.cpp index 68ed419..cfa5157 100644 --- a/MediaProcessor/src/Engine.cpp +++ b/MediaProcessor/src/Engine.cpp @@ -14,7 +14,7 @@ Engine::Engine(const std::filesystem::path& mediaPath) bool Engine::processMedia() { ConfigManager& configManager = ConfigManager::getInstance(); - if (!configManager.loadConfig("config.json")) { + if (!configManager.loadConfig("runtime_config.json")) { std::cerr << "Error: Could not load configuration." << std::endl; return false; } diff --git a/MediaProcessor/src/main.cpp b/MediaProcessor/src/main.cpp index a55c331..1dce5cc 100644 --- a/MediaProcessor/src/main.cpp +++ b/MediaProcessor/src/main.cpp @@ -13,7 +13,7 @@ int main(int argc, char* argv[]) { * It supports both audio and video files, adapting the workflow based on the file type. * * Workflow: - * 1. Load configuration from "config.json". + * 1. Load configuration from "runtime_config.json". * 2. Determine if the input file is audio or video. * 3. For audio files: * - Directly process audio to isolate vocals. diff --git a/MediaProcessor/tests/AudioProcessorTester.cpp b/MediaProcessor/tests/AudioProcessorTester.cpp index 368c9a5..0a4e691 100644 --- a/MediaProcessor/tests/AudioProcessorTester.cpp +++ b/MediaProcessor/tests/AudioProcessorTester.cpp @@ -24,6 +24,7 @@ class AudioProcessorTester : public ::testing::Test { } void SetUp() override { + testMediaPath.make_preferred(); fs::path currentPath = fs::current_path(); testVideoPath = testMediaPath / "test_video.mkv"; diff --git a/MediaProcessor/tests/TestUtils.cpp b/MediaProcessor/tests/TestUtils.cpp index 3a609df..da544b6 100644 --- a/MediaProcessor/tests/TestUtils.cpp +++ b/MediaProcessor/tests/TestUtils.cpp @@ -80,12 +80,12 @@ bool CompareFiles::compareFilesByteByByte(const fs::path& filePath1, const fs::p bool CompareFiles::compareAudioFiles(const fs::path& filePath1, const fs::path& filePath2, double tolerance, size_t chunkSize) { SF_INFO sfInfo1, sfInfo2; - SNDFILE* sndFile1 = sf_open(filePath1.c_str(), SFM_READ, &sfInfo1); + SNDFILE* sndFile1 = sf_open(filePath1.string().c_str(), SFM_READ, &sfInfo1); if (!sndFile1) { throw std::runtime_error("Failed to open file 1: " + filePath1.string()); } - SNDFILE* sndFile2 = sf_open(filePath2.c_str(), SFM_READ, &sfInfo2); + SNDFILE* sndFile2 = sf_open(filePath2.string().c_str(), SFM_READ, &sfInfo2); if (!sndFile2) { sf_close(sndFile1); throw std::runtime_error("Failed to open file 2: " + filePath2.string()); diff --git a/MediaProcessor/tests/TestUtils.h b/MediaProcessor/tests/TestUtils.h index b86d846..75f08fe 100644 --- a/MediaProcessor/tests/TestUtils.h +++ b/MediaProcessor/tests/TestUtils.h @@ -83,7 +83,7 @@ class TestConfigFile { (m_rootPath / "res/DeepFilterNet3_ll_onnx/tmp/export/enc.onnx")}, {"deep_filter_decoder_path", (m_rootPath / "res/DeepFilterNet3_ll_onnx/tmp/export/df_dec.onnx")}, - {"ffmpeg_path", "/usr/bin/ffmpeg"}, + {"ffmpeg_path", "ffmpeg"}, {"downloads_path", "downloads"}, {"uploads_path", "uploads"}, {"use_thread_cap", false}, diff --git a/MediaProcessor/tests/VideoProcessorTester.cpp b/MediaProcessor/tests/VideoProcessorTester.cpp index dfbf722..01eaf2a 100644 --- a/MediaProcessor/tests/VideoProcessorTester.cpp +++ b/MediaProcessor/tests/VideoProcessorTester.cpp @@ -28,6 +28,7 @@ class VideoProcessorTester : public ::testing::Test { } void SetUp() override { + testMediaPath.make_preferred(); testVideoPath = testMediaPath / "test_video.mkv"; testAudioPath = testMediaPath / "test_audio_processed.wav"; @@ -36,18 +37,6 @@ class VideoProcessorTester : public ::testing::Test { testOutputDir = fs::current_path() / "test_output"; fs::create_directories(testOutputDir); - - nlohmann::json jsonObject = { - {"ffmpeg_path", "/usr/bin/ffmpeg"}, - {"deep_filter_path", "MediaProcessor/res/deep-filter-0.5.6-x86_64-unknown-linux-musl"}, - {"downloads_path", "downloads"}, - {"uploads_path", "uploads"}, - {"use_thread_cap", true}, - {"max_threads_if_capped", 4}}; - testConfigFile.generateConfigFile("testConfig.json", jsonObject); - - ASSERT_TRUE(configManager.loadConfig(testConfigFile.getFilePath())) - << "Failed to load test configuration file."; } void TearDown() override { @@ -62,6 +51,9 @@ TEST_F(VideoProcessorTester, MergeMedia_MergesAudioAndVideoCorrectly) { * is already being checked within the audio tester. * Eventually we need check for sensible metrics here. */ + ConfigManager& configManager = ConfigManager::getInstance(); + ASSERT_TRUE(configManager.loadConfig(testConfigFile.getFilePath())) + << "Unable to Load TestConfigFile"; fs::path testOutputVideoPath = testOutputDir / "test_output_video.mp4"; VideoProcessor videoProcessor(testVideoPath, testAudioPath, testOutputVideoPath); diff --git a/app.py b/app.py index 6345014..601c03f 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ import os import re import subprocess +from sys import exit from urllib.parse import urlparse import yt_dlp @@ -27,8 +28,12 @@ app = Flask(__name__) # Load config and set paths -with open("config.json") as config_file: - config = json.load(config_file) +try: + with open("runtime_config.json") as config_file: + config = json.load(config_file) +except FileNotFoundError: + print("Please run launcher.py.") + exit(1) # Define base paths using absolute references BASE_DIR = os.path.abspath(os.path.dirname(__file__)) diff --git a/config.json b/config.json index 4b41997..36ad7a7 100644 --- a/config.json +++ b/config.json @@ -3,7 +3,7 @@ "deep_filter_tarball_path": "MediaProcessor/res/DeepFilterNet3_ll_onnx.tar.gz", "deep_filter_encoder_path": "MediaProcessor/res/DeepFilterNet3_ll_onnx/tmp/export/enc.onnx", "deep_filter_decoder_path": "MediaProcessor/res/DeepFilterNet3_ll_onnx/tmp/export/df_dec.onnx", - "ffmpeg_path": "/usr/bin/ffmpeg", + "ffmpeg_path": "ffmpeg", "downloads_path": "downloads", "uploads_path": "uploads", "use_thread_cap": false, diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..4e8f736 --- /dev/null +++ b/launcher.py @@ -0,0 +1,575 @@ +import argparse +import atexit +import json +import logging +import logging.config +import os +import platform +import subprocess +import sys +import threading +import time +import webbrowser +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Optional + +PROCESSING_ENGINE_PATH = Path("MediaProcessor") / "build" +LOG_LOCK = threading.Lock() + + +class ConfigManager: + """ + Manages Config files and logging Configuration. + """ + + CONFIG_FILE_PATH = Path("config.json") + RUNTIME_CONFIG_FILE_PATH = Path("runtime_config.json") + DEFAULT_LOGGING_CONFIG = { + "version": 1, + "formatters": { + "detailed": {"format": "%(asctime)s [%(levelname)s] [%(funcName)s(), %(lineno)d]: %(message)s"}, + "simple": {"format": "[%(levelname)s] %(message)s"}, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "simple", + "stream": "ext://sys.stdout", + }, + }, + "root": {"level": "ERROR", "handlers": ["console"]}, + } + + def __init__(self, system: str, log_level: str, log_file: bool = False) -> None: + self.setup_runtime_config(system) + self.setup_logging(log_level, log_file) + + def update_config_for_windows(self, configuration): + for key, value in configuration.items(): + if type(value) == str: + configuration[key] = value.replace("/", "\\") # change / to \ for windows path compatibility + + def setup_runtime_config(self, system: str): + logging.debug(f"Setting up runtime config for {system}") + try: + with open(ConfigManager.CONFIG_FILE_PATH, "r") as config_file: + config = json.load(config_file) + + if system == "Windows": + self.update_config_for_windows(config) + + with open(ConfigManager.RUNTIME_CONFIG_FILE_PATH, "w") as config_file: + json.dump(config, config_file, indent=4) + + except FileNotFoundError: + logging.error( + f"{ConfigManager.CONFIG_FILE_PATH} not found. Please check a config file exists in project root." + ) + sys.exit(1) + except Exception as e: + logging.error(f"Failed to update config: {e}", exc_info=True) + sys.exit(1) + + def setup_logging(self, log_level: str, log_file=False): + ConfigManager.DEFAULT_LOGGING_CONFIG["root"]["level"] = log_level + if log_file: + ConfigManager.DEFAULT_LOGGING_CONFIG["handlers"]["file"] = { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "detailed", + "filename": f"logfile_{time.strftime('%Y%m%d_%H%M%S')}.log", + "maxBytes": 1024 * 1024 * 5, # 5MB + "backupCount": 3, + } + ConfigManager.DEFAULT_LOGGING_CONFIG["root"]["handlers"].append("file") + + logging.config.dictConfig(ConfigManager.DEFAULT_LOGGING_CONFIG) + + +class Utils: + VENV_NAME = "virtual_env" # For downloading python packages + VENV_DIR_PATH = Path.cwd() / VENV_NAME # Generate the path for the virtual environment + + @staticmethod + def run_command(command: str, cwd: Optional[Path] = None): + """ + Executes a shell command. + + Args: + command (str): The command to run. + cwd (str, optional): The working directory. Defaults to None. + + Raises: + subprocess.CalledProcessError: If any command fails. + """ + logging.debug(f"Executing command: {command}") + process = subprocess.Popen( + command.split(), + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = process.communicate() # wait for process to terminate + if stdout: + logging.debug(f"Command output: {stdout}") + if stderr: + logging.error(f"Command error: {stderr}") + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, command) + + @staticmethod + def check_internet_connectivity(system) -> bool: + try: + cmd = "ping -n 1 8.8.8.8" if system == "Windows" else "ping -c 1 8.8.8.8" + Utils.run_command(cmd) + logging.debug("Internet connectivity OK") + return True + except Exception as e: + logging.error("No internet connection detected.") + logging.debug(f"Error: {e}") + return False + + @staticmethod + def ensure_venv_exists(): + if Utils.VENV_DIR_PATH.exists(): + logging.debug(f"Virtual environment already exists at {Utils.VENV_DIR_PATH}.") + return + logging.info(f"Generating virtual environment at: {Utils.VENV_DIR_PATH}") + Utils.run_command(f"{sys.executable} -m venv {str(Utils.VENV_DIR_PATH)} --system-site-packages") + logging.info("Successfully generated Virtual environment.") + + @staticmethod + def get_venv_binaries_path() -> Path: + """ + get the path of the virtual environment binaries path + """ + Utils.ensure_venv_exists() + for directory_name in ["bin", "Scripts"]: + path = Utils.VENV_DIR_PATH / directory_name + logging.debug(f"Searching VENV Path: {path}") + if path.exists(): + return path + + logging.error("Could not locate virtual environment folders.") + sys.exit(1) + + +def install_msys2(): + """ + Installs MSYS2 and Update Path enviornment variable for windows platform + """ + try: + installer_url = "https://repo.msys2.org/distrib/x86_64/msys2-base-x86_64-20241208.sfx.exe" + installer_name = "msys2-installer.exe" + + msys2_root_path = "C:\\msys64" + + logging.info("Downloading MSYS2 installer...") + logging.debug(f"Installer URL: {installer_url}") + Utils.run_command(f"curl -L -o {installer_name} {installer_url}") + + logging.info("Running MSYS2 installer...") + logging.debug(f"Installing MSYS2 at {msys2_root_path}") + Utils.run_command(f"{installer_name} -y -oC:\\") + + logging.info("Updating MSYS2 packages...") + Utils.run_command(f"{msys2_root_path}\\usr\\bin\\bash.exe -lc 'pacman -Syu --noconfirm'") + + logging.info("Editing Environment Variables...") + logging.debug( + f"Adding {msys2_root_path}\\usr\\bin, {msys2_root_path}\\mingw64\\bin, {msys2_root_path}\\mingw32\\bin to PATH for current user." + ) + # Set it permanently for the current user + commands = f""" + $oldPath = [Environment]::GetEnvironmentVariable("Path", "User") + $newPath = "{msys2_root_path}\\usr\\bin;{msys2_root_path}\\mingw64\\bin;{msys2_root_path}\\mingw32\\bin;" + $oldPath + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + """ + # Run the PowerShell commands + subprocess.check_call(["powershell", "-Command", commands]) + logging.info("Added MSYS2 to PATH environment variable Permanently for current user.") + + # Add MSYS2 paths to the PATH environment variable for the current session + logging.debug( + f"Adding {msys2_root_path}\\usr\\bin, {msys2_root_path}\\mingw64\\bin, {msys2_root_path}\\mingw32\\bin to current enviornment." + ) + current_path = os.environ.get("PATH", "") + new_path = ( + f"{msys2_root_path}\\usr\\bin;{msys2_root_path}\\mingw64\\bin;{msys2_root_path}\\mingw32\\bin;" + + current_path + ) + os.environ["PATH"] = new_path + + logging.info("MSYS2 installed and updated successfully.") + logging.info("NOTE: Please restart your terminal before running this script again.") + + except subprocess.CalledProcessError as e: + logging.error(f"Error installing MSYS2: {e}", exc_info=True) + sys.exit(1) + finally: + if Path(installer_name).exists(): + os.remove(installer_name) + + +def validate_python_dependencies(): + """ + check if the packages in the requirements.txt are installed + + Raises: + FileNotFoundError : if the requirements are not installed. + + Note: + Currenly Supports `==` and `>=` operators only in the requirements.txt. + """ + try: + requirements = open("requirements.txt", "r").readlines() + for req in requirements: + req = req.strip() + if not req or req.startswith("#"): # Skip empty lines and comments + continue + operator = ">=" if ">=" in req else "==" + + package_name, required_version = req.split(operator) + package_name = package_name.strip() + installed_version = version(package_name) + + required_version = tuple(map(int, required_version.strip().split("."))) + installed_version = tuple(map(int, installed_version.strip().split("."))) + + logging.debug(f"Installed version of {package_name}: {installed_version}") + if (operator == ">=" and installed_version >= required_version) or ( + operator == "==" and installed_version == required_version + ): + logging.debug(f"{package_name} is installed and meets the requirement.") + else: + logging.debug( + f"Version mismatch for {package_name}: " + f"installed {installed_version}, required {required_version}, operator {operator}" + ) + raise FileNotFoundError + except (PackageNotFoundError, FileNotFoundError): + logging.debug(f"{package_name} is not installed.") + raise FileNotFoundError + except Exception as e: + logging.error(f"Error processing requirement {req}: {e}") + sys.exit(1) + + +class DependencyHandler: + """ + Represents a dependency with methods for checking and installing it. + + Attributes: + name (str): The name of the dependency. + package_name (dict): { platform: package_name }. + check_cmd (dict): { platform: list of command or function to check if the dependency is installed }. + install_cmd (dict): { platform: list of command or function to install the dependency }. + + if package_name is not provided, it will be set to the name. + + if check_cmd is not provided + package_name --version will be used to check if the dependency is installed + + if install cmd is not provided + for linux: package manager based on distro + for macos: brew + for windows: msys2 + + Note: + If a function is provided for check_cmd ensure it raise FileNotFoundError if the dependency is not installed. + """ + + def __init__(self, name, package_name=None, check_cmd=None, install_cmd=None): + self.name = name + self.package_name = package_name or {} + self.check_cmd = check_cmd or {} + self.install_cmd = install_cmd or {} + self._installers = { + "Linux": self._get_install_commands_linux, + "Darwin": self._get_install_commands_darwin, + "Windows": self._get_install_commands_windows, + } + # Mapping of Linux distributions to package managers + self._linux_distro_map = { + "ubuntu": "sudo apt-get install -y", + "debian": "sudo apt-get install -y", + "kali": "sudo apt-get install -y ", + "pop": "sudo apt-get install -y ", + "elementary": "sudo apt-get install -y ", + "mint": "sudo apt-get install -y", + "fedora": "sudo dnf install -y", + "rhel": "sudo dnf install -y", + "centos": "sudo dnf install -y", + "rocky": "sudo dnf install -y", + "alma": "sudo dnf install -y", + "arch": "sudo pacman -S --needed --noconfirm", + "manjaro": "sudo pacman -S --needed --noconfirm", + "endeavouros": "sudo pacman -S --needed --noconfirm", + "garuda": "sudo pacman -S --needed --noconfirm", + "opensuse": "sudo zypper install -y", + "suse": "sudo zypper install -y", + "alpine": "sudo apk add", + "solus": "sudo eopkg install -y", + "void": "sudo xbps-install -y", + "clearlinux": "sudo swupd bundle-add", + } + + def ensure_installed(self, system): + """ + Ensures the package is installed on the system. + """ + if self.check_installed(system): + return + self.install_dependency(system) + + def check_installed(self, system) -> bool: + """ + Checks if the dependency is installed. + """ + logging.debug(f"Checking for {self.name} on {system}") + commands = self._get_check_commands(system) + try: + for cmd in commands: + if type(cmd) == str: + Utils.run_command(cmd) + else: + cmd() + return True + except (FileNotFoundError, subprocess.CalledProcessError): + logging.debug(f"{self.name} not Found.") + return False + except Exception as e: + logging.error(f"While Checking Dependency: {e}", exc_info=True) + sys.exit(1) + + def install_dependency(self, system: str): + """ + Installs the dependency for the specified operating system. + + Args: + system (str): The operating system type. + """ + if not Utils.check_internet_connectivity(system): + logging.error("Please Connect to a Internet Connection.") + sys.exit(1) + try: + logging.debug(f"Installing {self.name} on {system}") + commands = self._get_install_commands(system) + for cmd in commands: + if type(cmd) == str: + Utils.run_command(cmd) + else: + cmd() + logging.debug(f"Successfully installed {self.name}") + except Exception as e: + logging.error(f"While Installing: {e}.", exc_info=True) + sys.exit(1) + + def _get_check_commands(self, system): + if system in self.check_cmd: + return self.check_cmd[system] + if "all" in self.check_cmd: + return self.check_cmd["all"] + return [f"{self.package_name.get(system, self.name)} --version"] + + def _get_install_commands(self, system): + if system in self.install_cmd: + return self.install_cmd[system] + if "all" in self.install_cmd: + return self.install_cmd["all"] + if system not in self._installers: + logging.error(f"Unspported sytem {system}.") + sys.exit(1) + + return self._installers[system]() + + def _get_install_commands_linux(self): + import distro + + distro = distro.id().lower() + if distro not in self._linux_distro_map: + logging.error(f"Unsupported linux distro {distro}.") + sys.exit(1) + + return [f"{self._linux_distro_map[distro]} {self.package_name.get('Linux', self.name)}"] + + def _get_install_commands_darwin(self): + return [f"brew install {self.package_name.get('Darwin', self.name)}"] + + def _get_install_commands_windows(self): + MSYS2.ensure_installed("Windows") + return [f"pacman -S --needed --noconfirm {self.package_name.get('Windows', self.name)}"] + + +MSYS2 = DependencyHandler( + "MSYS2", check_cmd={"Windows": ["pacman --version"]}, install_cmd={"Windows": [install_msys2]} +) + + +def log_stream(stream, log_function): + """Logs output from a stream.""" + for line in iter(stream.readline, ""): + with LOG_LOCK: + log_function(line.strip()) + stream.close() + + +def build_processing_engine(system, re_build=False): + """Ensure that the MediaProcessor is build. If rebuild is true, it will rebuild the MediaProcessor.""" + if not PROCESSING_ENGINE_PATH.exists(): + os.makedirs(PROCESSING_ENGINE_PATH) + + MediaProcessor_binary_path = PROCESSING_ENGINE_PATH / ( + "MediaProcessor.exe" if system == "Windows" else "MediaProcessor" + ) + if MediaProcessor_binary_path.exists() and not re_build: + logging.debug(f"{str(MediaProcessor_binary_path)} exists. Skipping re-build.") + return + try: + logging.info("building Processing Engine.") + Utils.run_command("cmake -DCMAKE_BUILD_TYPE=Release ..", cwd=PROCESSING_ENGINE_PATH) + Utils.run_command("cmake --build . --config Release", cwd=PROCESSING_ENGINE_PATH) + logging.info("Processing Engine built successfully.") + except Exception as e: + logging.error(f"Failed to build MediaProcessor: {e}", exc_info=True) + sys.exit(1) + + +class WebApplication: + def __init__(self, system: str, log_level: str, log_file: bool = False): + + # FIXME: ConfigManager is not initialized yet so logging is not setup. + # Following log statements to be swapped once this is fixed. + # logging.info("Setting up Web Application. This will resolve all dependencies, and may take a few minutes.") + # logging.info("Run with `--log-level DEBUG` for more detailed output.") + print("Setting up Web Application. This will resolve all dependencies, and may take a few minutes.") + print("Run with `--log-level DEBUG` for more detailed output.") + + self.dependencies = [ + DependencyHandler("cmake", {"Windows": "mingw-w64-x86_64-cmake"}, {"all": ["cmake --version"]}), + DependencyHandler( + "g++", + {"Windows": "mingw-w64-x86_64-gcc", "Darwin": "gcc"}, + {"all": ["g++ --version"]}, + {"Windows": ["pacman -S --needed --noconfirm base-devel mingw-w64-x86_64-toolchain"]}, + ), + DependencyHandler("pkg-config"), + DependencyHandler("ffmpeg", {"Windows": "mingw-w64-x86_64-ffmpeg"}, {"all": ["ffmpeg -version"]}), + DependencyHandler( + "libsndfile", + {"Windows": "mingw-w64-x86_64-libsndfile", "Linux": "libsndfile1-dev"}, + {"all": ["pkg-config --exists sndfile"]}, + ), + DependencyHandler( + "nlohmann-json", + {"Windows": "mingw-w64-x86_64-nlohmann-json", "Linux": "nlohmann-json3-dev"}, + {"all": ["pkg-config --exists nlohmann_json"]}, + ), + DependencyHandler( + "Python Dependencies", + check_cmd={"all": [validate_python_dependencies]}, + install_cmd={ + "Windows": [f"{str(Utils.get_venv_binaries_path()/ 'pip.exe')} install -r requirements.txt"], + "all": [f"{str(Utils.get_venv_binaries_path()/ 'pip')} install -r requirements.txt"], + }, + ), + ] + self.system = system + self.DEAFULT_URL = "http://127.0.0.1" + self.DEAFULT_PORT = 8080 + self.timeout = 0.5 + self.setup(log_level, log_file) + + def setup(self, log_level: str, log_file: bool): + """ + Installs the dependencies and Setup Configuration for the web application. + """ + + """ FIXME: Common dependencies shouldn't be resolved here. + This should be exclusive to the WebApplication class dependencies. + """ + + self.config = ConfigManager(self.system, log_level, log_file) + for dependency in self.dependencies: + dependency.ensure_installed(self.system) + + def run(self, port: Optional[int] = None): + try: + python_path = Utils.get_venv_binaries_path() / ("python.exe" if self.system == "Windows" else "python") + + # Start the backend + logging.debug("Starting backend") + app_process = subprocess.Popen( + [python_path, "app.py"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + atexit.register(app_process.terminate) + + # Threads to handle stdout and stderr asynchronously + threading.Thread(target=log_stream, args=(app_process.stdout, logging.debug), daemon=True).start() + threading.Thread(target=log_stream, args=(app_process.stderr, logging.debug), daemon=True).start() + + # Give the process some time to initialize + time.sleep(self.timeout) + + # Check if the process is still running + if app_process.poll() is not None: + error_output = app_process.stderr.read() + logging.error(f"Error starting the backend: {error_output}") + sys.exit(1) + + url = f"{self.DEAFULT_URL}:{port if port else self.DEAFULT_PORT}" + logging.debug(f"Web application running on {url}.") + webbrowser.open(url) + + logging.info("Web application running. Press Enter to stop.") + input() # Block until the user presses Enter + + except Exception as e: + logging.error(f"An error occurred: {e}", exc_info=True) + sys.exit(1) + + +Applications = { + "web": WebApplication, +} + + +def main(): + parser = argparse.ArgumentParser(description="Setup for MediaProcessor Application.") + parser.add_argument("--app", choices=["web", "none"], default="web", help="Specify launch mode (default=web).") + parser.add_argument("--install-only", action="store_true", default=False, help="Install dependencies only.") + parser.add_argument("--rebuild", action="store_true", help="Rebuild MediaProcessor") + parser.add_argument( + "--log-level", choices=["DEBUG", "INFO", "ERROR"], default="INFO", help="Set the logging level (default=INFO)." + ) + parser.add_argument("--log-file", action="store_true", help="Outputs log in a log file.") + args = parser.parse_args() + + """ FIXME: Control flow should be more transparent. + It's unclear where dependencies are resolved (currently delegated to WebApplication). + Similar to building the core, resolving common dependencies should be explicit. + Consider adding init/cleanup functions to fix these without cluttering main. + """ + + system = platform.system() + if args.app == "none": + print("Please specify a launch option, e.g. `--app=web`. Start with `--help` to see all options.") + sys.exit(0) + + # FIXME: App starts before ConfigManager is initialized leading to limited intermediary logging capability. + # To be handled separately likely in the to-be-added init function. + app = Applications[args.app](system, args.log_level, args.log_file) + build_processing_engine(system, args.rebuild) + if args.install_only: + sys.exit(0) + + app.run() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 8651de8..f75c30d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,45 +1,8 @@ -bidict==0.23.1 -black==24.10.0 -blinker==1.8.2 -Brotli==1.1.0 -certifi==2024.8.30 -charset-normalizer==3.3.2 -click==8.1.7 -dnspython==2.6.1 -eventlet==0.37.0 -ffmpeg-python==0.2.0 -flake8==7.1.1 -Flask==3.0.3 -Flask-Cors==5.0.0 -Flask-SocketIO==5.3.7 -future==1.0.0 -gevent==24.2.1 -gevent-websocket==0.10.1 -greenlet==3.1.0 -h11==0.14.0 -idna==3.8 -itsdangerous==2.2.0 -Jinja2==3.1.4 -MarkupSafe==2.1.5 -mccabe==0.7.0 -mutagen==1.47.0 -mypy-extensions==1.0.0 -packaging==24.1 -pathspec==0.12.1 -platformdirs==4.3.6 -pycodestyle==2.12.1 -pycryptodomex==3.20.0 -pyflakes==3.2.0 -python-engineio==4.9.1 -python-socketio==5.11.4 -requests==2.32.3 -simple-websocket==1.0.0 -tomli==2.0.2 -typing_extensions==4.12.2 -urllib3==2.2.2 -websockets==13.0.1 -Werkzeug==3.0.4 -wsproto==1.2.0 -yt-dlp==2024.9.27 -zope.event==5.0 -zope.interface==7.0.3 +blinker>=1.9.0 +click>=8.1.8 +Flask>=3.1.0 +itsdangerous>=2.2.0 +Jinja2>=3.1.5 +MarkupSafe>=3.0.2 +Werkzeug>=3.1.3 +yt-dlp>=2024.12.13