diff --git a/.github/workflows/windows-build-nsis.yml b/.github/workflows/windows-build-nsis.yml new file mode 100644 index 00000000..2d29818c --- /dev/null +++ b/.github/workflows/windows-build-nsis.yml @@ -0,0 +1,96 @@ +name: Windows Build with NSIS + +on: + push: + branches: [ "main", "dev", "win-*" ] + pull_request: + branches: [ "main", "dev" ] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller==5.13.0 + pip install pywin32 + + - name: Create directories + run: | + mkdir -p config/logs + dir + dir config + + - name: Build with PyInstaller + run: | + # Copy spec file from distribution directory to root + cp distribution/windows/huntarr.spec . + + # Use the dedicated build script from the distribution directory + python -m pip install -r requirements.txt + python -m pip install pywin32 + pyinstaller -y distribution/windows/huntarr.spec + + # Display contents of dist/Huntarr + dir dist/Huntarr + + - name: Install NSIS + run: | + choco install nsis -y + + - name: Build NSIS Installer + run: | + # Display current directory structure + dir + + # Create installer output directory + mkdir -Force distribution\windows\installer\installer + mkdir -Force installer + + # Prepare version file path + $versionContent = Get-Content version.txt -Raw + Write-Host "Version from file: $versionContent" + $AbsVersionFile = Join-Path -Path $PWD.Path -ChildPath "version.txt" + Write-Host "Absolute path for VERSIONFILE: $AbsVersionFile" + + # Prepare arguments for makensis.exe + $MakensisArgs = @( + "/DVERSIONFILE=$AbsVersionFile", + "/DPROJECT_ROOT=$($PWD.Path)", + "distribution\windows\installer\huntarr_installer.nsi" + ) + + # Run NSIS compiler + Write-Host "Running makensis.exe with arguments: $MakensisArgs" + & "C:\Program Files (x86)\NSIS\makensis.exe" $MakensisArgs + + # Check if installer was created + $installerPath = "distribution\windows\installer\installer\Huntarr_Setup.exe" + if (Test-Path $installerPath) { + Write-Host "Installer created successfully at $installerPath" + # Copy to expected upload location + Copy-Item -Path $installerPath -Destination "installer\Huntarr_Setup.exe" -Force + } else { + Write-Error "Installer was not created. Check the logs above for errors." + exit 1 + } + + # List any exe files in the installer directory + Get-ChildItem -Path installer -Filter *.exe | ForEach-Object { Write-Host $_.FullName } + + - name: Upload installer + uses: actions/upload-artifact@v4 + with: + name: huntarr-installer + path: installer/Huntarr_Setup.exe diff --git a/distribution/windows/build.py b/distribution/windows/build.py new file mode 100644 index 00000000..cb4e1d00 --- /dev/null +++ b/distribution/windows/build.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Huntarr Windows Build Script +This script builds the Windows executable and installer for Huntarr. +""" + +import os +import sys +import subprocess +import shutil +import argparse +from pathlib import Path + +# Constants +SCRIPT_DIR = Path(os.path.dirname(os.path.abspath(__file__))) +ROOT_DIR = SCRIPT_DIR.parent.parent # Navigate up two directories to project root + +def run_command(cmd, cwd=None): + """Run a command and return the result + + Args: + cmd: Command list to run + cwd: Current working directory for the command + + Returns: + True if command succeeded, False otherwise + """ + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, check=False, cwd=cwd) + return result.returncode == 0 + +def build_exe(): + """Build the Windows executable using PyInstaller""" + print("Building Huntarr Windows executable...") + + # Make sure PyInstaller is installed + try: + import PyInstaller + except ImportError: + print("PyInstaller not found. Installing...") + run_command([sys.executable, "-m", "pip", "install", "pyinstaller"]) + + # Make sure all requirements are installed + run_command([sys.executable, "-m", "pip", "install", "-r", str(ROOT_DIR / "requirements.txt")]) + run_command([sys.executable, "-m", "pip", "install", "pywin32"]) + + # Build using the spec file + spec_file = SCRIPT_DIR / "huntarr.spec" + + # Verify the main.py file exists in the expected location + main_file = ROOT_DIR / "main.py" + if not main_file.exists(): + print(f"ERROR: Main file not found at {main_file}") + print("Listing files in root directory:") + for file in ROOT_DIR.glob("*"): + print(f" {file}") + return False + + # Make sure we're in the project root directory when running PyInstaller + # This helps with finding relative paths + # Add the -y option to force overwrite of the output directory + result = run_command([sys.executable, "-m", "PyInstaller", "-y", str(spec_file)], cwd=str(ROOT_DIR)) + + if not result: + print("ERROR: PyInstaller failed to build the executable") + return False + + # Check if Huntarr.exe was created + exe_path = ROOT_DIR / "dist" / "Huntarr" / "Huntarr.exe" + if not exe_path.exists(): + print(f"ERROR: Executable not created at {exe_path}") + return False + + print("Executable build complete.") + return True + +def build_installer(): + """Build the Windows installer using Inno Setup""" + print("Building Huntarr Windows installer...") + + # Check if Inno Setup is installed + inno_compiler = "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" + if not os.path.exists(inno_compiler): + print(f"ERROR: Inno Setup compiler not found at {inno_compiler}") + print("Please install Inno Setup 6 from https://jrsoftware.org/isdl.php") + return False + + # Check if the exe file was created by PyInstaller + exe_path = ROOT_DIR / "dist" / "Huntarr" / "Huntarr.exe" + if not exe_path.exists(): + print(f"ERROR: Executable not found at {exe_path}") + print("PyInstaller did not create the executable. Please run build_exe() first.") + return False + + # Create installer directory if it doesn't exist + installer_dir = ROOT_DIR / "installer" + os.makedirs(str(installer_dir), exist_ok=True) + + # Make sure the dist directory exists and has the expected structure + dist_dir = ROOT_DIR / "dist" / "Huntarr" + resources_dir = dist_dir / "resources" + scripts_dir = dist_dir / "scripts" + + os.makedirs(str(resources_dir), exist_ok=True) + os.makedirs(str(scripts_dir), exist_ok=True) + + # Copy resources and scripts if they don't exist in the dist directory + src_resources = SCRIPT_DIR / "resources" + src_scripts = SCRIPT_DIR / "scripts" + + if src_resources.exists(): + # Copy all files from resources directory + for src_file in src_resources.glob("*"): + dst_file = resources_dir / src_file.name + if src_file.is_file(): + shutil.copy2(str(src_file), str(dst_file)) + + if src_scripts.exists(): + # Copy all files from scripts directory + for src_file in src_scripts.glob("*"): + dst_file = scripts_dir / src_file.name + if src_file.is_file(): + shutil.copy2(str(src_file), str(dst_file)) + + # Copy the installer script to the root + installer_script = SCRIPT_DIR / "installer" / "huntarr_installer.iss" + target_script = ROOT_DIR / "huntarr_installer.iss" + shutil.copy2(str(installer_script), str(target_script)) + + # Ensure LICENSE file exists at the root + license_path = ROOT_DIR / "LICENSE" + if not license_path.exists(): + print(f"ERROR: LICENSE file not found at {license_path}") + print("Checking for LICENSE file in other locations...") + for possible_license in ROOT_DIR.glob("*LICENSE*"): + print(f" Found license-like file: {possible_license}") + return False + + # Run the Inno Setup compiler + result = run_command([inno_compiler, str(target_script)]) + + # Check if the installer was created + installer_path = ROOT_DIR / "installer" / "Huntarr_Setup.exe" + if not installer_path.exists(): + print(f"ERROR: Installer not created at {installer_path}") + print("The Inno Setup compiler failed to create the installer.") + return False + + # Clean up + if target_script.exists(): + target_script.unlink() + + print("Installer build complete.") + return True + +def clean(): + """Clean up build artifacts""" + print("Cleaning up build artifacts...") + + # Remove PyInstaller build directories + build_dir = ROOT_DIR / "build" + dist_dir = ROOT_DIR / "dist" + + if build_dir.exists(): + shutil.rmtree(build_dir) + + if dist_dir.exists(): + shutil.rmtree(dist_dir) + + # Remove any .spec files in the root directory + for spec_file in ROOT_DIR.glob("*.spec"): + spec_file.unlink() + + print("Cleanup complete.") + return True + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser(description="Build Huntarr for Windows") + parser.add_argument("--clean", action="store_true", help="Clean up build artifacts") + parser.add_argument("--exe-only", action="store_true", help="Build only the executable, not the installer") + parser.add_argument("--installer-only", action="store_true", help="Build only the installer, assuming executable is already built") + + args = parser.parse_args() + + if args.clean: + clean() + if not (args.exe_only or args.installer_only): + return 0 + + if args.installer_only: + build_installer() + elif args.exe_only: + build_exe() + else: + # Build both + if build_exe(): + build_installer() + + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/distribution/windows/huntarr.spec b/distribution/windows/huntarr.spec new file mode 100644 index 00000000..38483806 --- /dev/null +++ b/distribution/windows/huntarr.spec @@ -0,0 +1,166 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import sys +import pathlib +import glob + +# Find the project root directory from the spec file location +spec_dir = pathlib.Path(os.path.dirname(os.path.abspath(SPECPATH))) +project_dir = spec_dir.parent.parent # Go up two levels to project root + +# In GitHub Actions, the current working directory is already the project root +# Check if we're in GitHub Actions by looking at the environment +if os.environ.get('GITHUB_ACTIONS'): + # Use the current directory instead + project_dir = pathlib.Path(os.getcwd()) + +# Print current directory and list files for debugging +print(f"Current directory: {os.getcwd()}") +print(f"Project directory: {project_dir}") +print("Files in current directory:") +for file in os.listdir(os.getcwd()): + print(f" {file}") + +# Find main.py file +main_py_path = project_dir / 'main.py' +if not main_py_path.exists(): + main_py_files = list(glob.glob(f"{project_dir}/**/main.py", recursive=True)) + if main_py_files: + main_py_path = pathlib.Path(main_py_files[0]) + print(f"Found main.py at: {main_py_path}") + else: + print("ERROR: main.py not found!") + # Use a placeholder that will cause an error with a clearer message + main_py_path = project_dir / 'main.py' + +block_cipher = None + +# Create a list of data files to include with absolute paths +datas = [ + (str(project_dir / 'frontend'), 'frontend'), + (str(project_dir / 'src'), 'src'), +] + +# Add tools directory if it exists +if os.path.exists(str(project_dir / 'tools')): + datas.append((str(project_dir / 'tools'), 'tools')) + +# Add assets directory if it exists +if os.path.exists(str(project_dir / 'assets')): + datas.append((str(project_dir / 'assets'), 'assets')) + +# Ensure all frontend template files are included +if os.path.exists(str(project_dir / 'frontend')): + print(f"Including frontend directory at {str(project_dir / 'frontend')}") + # Make sure we include all frontend template files + datas.append((str(project_dir / 'frontend/templates'), 'templates')) + datas.append((str(project_dir / 'frontend/static'), 'static')) + + # Explicitly check for the login template + login_template = project_dir / 'frontend/templates/login.html' + if os.path.exists(login_template): + print(f"Found login.html at {login_template}") + else: + print(f"WARNING: login.html not found at {login_template}") + + # List all available templates for debugging + template_dir = project_dir / 'frontend/templates' + if os.path.exists(template_dir): + print("Available templates:") + for template_file in os.listdir(template_dir): + print(f" - {template_file}") + else: + print(f"WARNING: Template directory not found at {template_dir}") + +a = Analysis( + [str(main_py_path)], + pathex=[str(project_dir)], + binaries=[], + datas=datas, + hiddenimports=[ + 'waitress', + 'pyotp', + 'win32serviceutil', + 'win32service', + 'win32event', + 'servicemanager', + 'win32timezone', + 'pywin32', + 'bcrypt', + 'qrcode', + 'PIL.Image', + 'flask', + 'flask.json', + 'flask.sessions', + 'markupsafe', + 'jinja2', + 'jinja2.ext', + 'werkzeug', + 'werkzeug.exceptions', + 'itsdangerous', + 'logging.handlers', + 'email', + 'importlib', + 'json', + 'sqlite3', + 'requests', + 'urllib3', + 'certifi', + 'idna', + 'charset_normalizer', + 'queue', + 'threading', + 'socket', + 'datetime', + 'time', + 'os', + 'sys', + 're', + 'winreg', + 'hashlib', + 'base64', + 'uuid', + 'pathlib', + 'concurrent.futures', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Huntarr', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(project_dir / 'frontend/static/logo/huntarr.ico'), +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Huntarr', +) diff --git a/distribution/windows/installer/huntarr_installer.iss b/distribution/windows/installer/huntarr_installer.iss new file mode 100644 index 00000000..b8cad2d1 --- /dev/null +++ b/distribution/windows/installer/huntarr_installer.iss @@ -0,0 +1,333 @@ +#define MyAppName "Huntarr" +#define ReadVersionFile(str fileName) \ + Local[0] = FileOpen(fileName), \ + Local[1] = FileRead(Local[0]), \ + FileClose(Local[0]), \ + Local[1] + +#define MyAppVersion ReadVersionFile("version.txt") +#define MyAppPublisher "Huntarr" +#define MyAppURL "https://github.com/plexguide/Huntarr.io" +#define MyAppExeName "Huntarr.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. +; Do not use the same AppId value in installers for other applications. +AppId={{22AE2CDB-5F87-4E42-B5C3-28E121D4BDFF} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={pf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +LicenseFile=LICENSE +OutputDir=installer +OutputBaseFilename=Huntarr_Setup +SetupIconFile=frontend\static\logo\huntarr.ico +Compression=lzma +SolidCompression=yes +PrivilegesRequired=admin +ArchitecturesInstallIn64BitMode=x64 +DisableDirPage=no +DisableProgramGroupPage=yes +UninstallDisplayIcon={app}\{#MyAppExeName} +WizardStyle=modern +CloseApplications=no +RestartApplications=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" +Name: "autostart"; Description: "Start Huntarr automatically when Windows starts"; GroupDescription: "Startup options:"; Flags: checkedonce + +[Files] +Source: "dist\Huntarr\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; Create empty config directories to ensure they exist with proper permissions +Source: "LICENSE"; DestDir: "{app}\config"; Flags: ignoreversion; AfterInstall: CreateConfigDirs + +[Icons] +; Create Start Menu shortcuts +Name: "{group}\{#MyAppName}"; Filename: "http://localhost:9705"; IconFilename: "{app}\{#MyAppExeName}" +Name: "{group}\Run {#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "--no-service"; Flags: runminimized +Name: "{group}\Open {#MyAppName} Web Interface"; Filename: "http://localhost:9705"; IconFilename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" + +; Create Desktop shortcut if requested +Name: "{commondesktop}\{#MyAppName}"; Filename: "http://localhost:9705"; IconFilename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +; Add startup shortcut if the user selected that option +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "--no-service"; Flags: runminimized; Tasks: autostart + +[Run] +; Make sure any existing service is removed (for upgrades from service to non-service mode) +Filename: "{app}\{#MyAppExeName}"; Parameters: "--remove-service"; Flags: runhidden; Check: IsAdminLoggedOn +; Wait a moment for the service to be properly removed +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 3"; Flags: runhidden +; Grant permissions to the config directory and all subdirectories +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\config"" /grant Everyone:(OI)(CI)F /T"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\logs"" /grant Everyone:(OI)(CI)F /T"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\frontend"" /grant Everyone:(OI)(CI)F /T"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +; Ensure proper permissions for each important subdirectory (in case the recursive permission failed) +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\config\logs"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\config\stateful"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\config\user"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\config\settings"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\config\history"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\frontend\templates"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +Filename: "{sys}\cmd.exe"; Parameters: "/c icacls ""{app}\frontend\static"" /grant Everyone:(OI)(CI)F"; Flags: runhidden shellexec; Check: IsAdminLoggedOn +; Launch Huntarr directly after installation +Filename: "{app}\{#MyAppExeName}"; Parameters: "--no-service"; Description: "Start Huntarr"; Flags: nowait postinstall + +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 5 && start http://localhost:9705"; Description: "Open Huntarr Web Interface"; Flags: nowait postinstall shellexec + +; Final verification of directory permissions +Filename: "{sys}\cmd.exe"; Parameters: "/c echo Verifying installation permissions..."; Flags: runhidden shellexec postinstall; AfterInstall: VerifyInstallation + +; Verify executable exists before attempting to run it +Filename: "{sys}\cmd.exe"; Parameters: "/c if exist ""{app}\{#MyAppExeName}"" (echo Executable found) else (echo ERROR: Executable not found)"; Flags: runhidden +[UninstallRun] +; Kill any running instances of Huntarr +Filename: "{sys}\taskkill.exe"; Parameters: "/F /IM ""{#MyAppExeName}"""; Flags: runhidden +; Wait a moment for processes to terminate +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 2"; Flags: runhidden +; Remove the Huntarr startup entry if it exists +Filename: "{sys}\cmd.exe"; Parameters: "/c if exist \"{userstartup}\{#MyAppName}.lnk\" del /f \"{userstartup}\{#MyAppName}.lnk\""; Flags: runhidden + +[Code] +procedure CreateConfigDirs; +var + DirCreationResult: Boolean; +{{ ... }} + DirPath: String; + WriteTestPath: String; + WriteTestResult: Boolean; + PermissionSetResult: Integer; + ConfigDirs: array of String; + i: Integer; +begin + // Define all required configuration directories + SetArrayLength(ConfigDirs, 14); + ConfigDirs[0] := '\config'; + ConfigDirs[1] := '\config\logs'; + ConfigDirs[2] := '\config\stateful'; + ConfigDirs[3] := '\config\user'; + ConfigDirs[4] := '\config\settings'; + ConfigDirs[5] := '\config\history'; + ConfigDirs[6] := '\config\scheduler'; + ConfigDirs[7] := '\config\reset'; + ConfigDirs[8] := '\config\tally'; + ConfigDirs[9] := '\config\swaparr'; + ConfigDirs[10] := '\config\eros'; + ConfigDirs[11] := '\logs'; + ConfigDirs[12] := '\frontend\templates'; + ConfigDirs[13] := '\frontend\static'; + + // Create all necessary configuration directories with explicit permissions + for i := 0 to GetArrayLength(ConfigDirs) - 1 do + begin + DirPath := ExpandConstant('{app}' + ConfigDirs[i]); + DirCreationResult := ForceDirectories(DirPath); + + if not DirCreationResult then + begin + Log('Failed to create directory: ' + DirPath); + // Add fallback attempt with system command if ForceDirectories fails + if not DirExists(DirPath) then + begin + Log('Attempting fallback directory creation for: ' + DirPath); + Exec(ExpandConstant('{sys}\cmd.exe'), '/c mkdir "' + DirPath + '"', '', SW_HIDE, ewWaitUntilTerminated, PermissionSetResult); + end; + end else begin + Log('Successfully created directory: ' + DirPath); + end; + end; + + // Create a small test file in each important directory to verify write permissions + for i := 0 to GetArrayLength(ConfigDirs) - 1 do + begin + WriteTestPath := ExpandConstant('{app}' + ConfigDirs[i] + '\write_test.tmp'); + try + WriteTestResult := SaveStringToFile(WriteTestPath, 'Installation test file', False); + if WriteTestResult then + begin + Log('Write test succeeded for: ' + WriteTestPath); + DeleteFile(WriteTestPath); + end else begin + Log('Write test failed for: ' + WriteTestPath); + end; + except + Log('Exception during write test for: ' + WriteTestPath); + end; + end; +end; + +// Check for admin rights and warn user if they're not an admin +// Verify that all directories have proper permissions after installation +procedure VerifyInstallation; +var + VerifyResult: Integer; + ConfigPath: String; + ExePath: String; + ExeVerifyResult: Integer; +begin + ConfigPath := ExpandConstant('{app}\config'); + ExePath := ExpandConstant('{app}\{#MyAppExeName}'); + + // Log paths for troubleshooting + Log('Verifying installation paths:'); + Log('- Application directory: ' + ExpandConstant('{app}')); + Log('- Config directory: ' + ConfigPath); + Log('- Executable path: ' + ExePath); + + // Verify the executable file exists + if FileExists(ExePath) then + begin + Log('Executable file exists: ' + ExePath); + + // Ensure proper permissions on the executable + if IsAdminLoggedOn then + begin + Log('Setting permissions on executable file...'); + Exec(ExpandConstant('{sys}\cmd.exe'), '/c icacls "' + ExePath + '" /grant Everyone:RX', '', SW_HIDE, ewWaitUntilTerminated, ExeVerifyResult); + end; + end + else + begin + Log('ERROR: Executable file not found: ' + ExePath); + // Try to find the executable anywhere in the application directory + Exec(ExpandConstant('{sys}\cmd.exe'), '/c dir /s /b "' + ExpandConstant('{app}') + '\*.exe"', '', SW_HIDE, ewWaitUntilTerminated, ExeVerifyResult); + end; + + // Create a verification file in the main config directory + if SaveStringToFile(ConfigPath + '\verification.tmp', 'Verification file', False) then + begin + Log('Successfully created verification file in: ' + ConfigPath); + DeleteFile(ConfigPath + '\verification.tmp'); + end + else + begin + Log('WARNING: Failed to create verification file in: ' + ConfigPath); + // Try to repair permissions if verification fails + if IsAdminLoggedOn then + begin + Log('Attempting to repair permissions...'); + Exec(ExpandConstant('{sys}\cmd.exe'), '/c icacls "' + ConfigPath + '" /grant Everyone:(OI)(CI)F /T', '', SW_HIDE, ewWaitUntilTerminated, VerifyResult); + end; + end; +end; + +function InitializeSetup(): Boolean; +var + ResultCode: Integer; + NonAdminWarningResult: Integer; +begin + Log('Starting Huntarr installation...'); + + // Warn if user is not an admin - we'll still allow installation but with limitations + if not IsAdminLoggedOn() then + begin + NonAdminWarningResult := MsgBox( + 'Huntarr is being installed without administrator privileges.' + #13#10 + #13#10 + + 'This means:' + #13#10 + + '- The Windows service option will not be available' + #13#10 + + '- You will need to run Huntarr manually' + #13#10 + #13#10 + + 'Do you want to continue with limited installation?', + mbConfirmation, + MB_YESNO + ); + + if NonAdminWarningResult = IDNO then + begin + Log('User canceled installation due to lack of admin rights'); + Result := False; + Exit; + end; + end; + + // Check if an instance is already running and try to stop it + if IsAdminLoggedOn() then + begin + try + // Try to stop the service if it exists and is running + Exec(ExpandConstant('{sys}\net.exe'), 'stop Huntarr', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Log('Attempted to stop Huntarr service. Result: ' + IntToStr(ResultCode)); + // Give it a moment to stop + Sleep(3000); + except + // Ignore errors - service might not exist yet + Log('Exception occurred while stopping service - probably not installed'); + end; + end; + + // Check if port 9705 is already in use + try + Exec(ExpandConstant('{sys}\netstat.exe'), '-ano | find "9705"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + if ResultCode = 0 then + begin + if MsgBox('Huntarr uses port 9705, which appears to be in use. ' + + 'Installation can continue, but Huntarr may not start properly until this port is free. ' + + 'Do you want to continue anyway?', mbConfirmation, MB_YESNO) = IDNO then + begin + Result := False; + Exit; + end; + end; + except + // Ignore errors checking port usage + Log('Exception occurred while checking port usage - continuing anyway'); + end; + + Result := True; +end; + +// Executed when the installer is about to end +// Fix permissions if non-admin installation +procedure CurStepChanged(CurStep: TSetupStep); +var + ErrorCode: Integer; + Permissions: TArrayOfString; + i: Integer; + DirPath: String; +begin + if CurStep = ssPostInstall then + begin + // For non-admin installations, we can't use icacls easily, so try making each directory + // writable by removing read-only attributes + if not IsAdminLoggedOn() then + begin + Log('Non-admin installation - attempting to ensure directories are writable...'); + + SetArrayLength(Permissions, 14); + Permissions[0] := '\config'; + Permissions[1] := '\config\logs'; + Permissions[2] := '\config\stateful'; + Permissions[3] := '\config\user'; + Permissions[4] := '\config\settings'; + Permissions[5] := '\config\history'; + Permissions[6] := '\config\scheduler'; + Permissions[7] := '\config\reset'; + Permissions[8] := '\config\tally'; + Permissions[9] := '\config\swaparr'; + Permissions[10] := '\config\eros'; + Permissions[11] := '\logs'; + Permissions[12] := '\frontend\templates'; + Permissions[13] := '\frontend\static'; + + for i := 0 to GetArrayLength(Permissions) - 1 do + begin + DirPath := ExpandConstant('{app}' + Permissions[i]); + if DirExists(DirPath) then + begin + // Try to make directory writable by removing read-only attribute + Exec(ExpandConstant('{sys}\attrib.exe'), '-R "' + DirPath + '" /S /D', '', SW_HIDE, ewWaitUntilTerminated, ErrorCode); + Log('Set writable attributes for directory: ' + DirPath + ' (Result: ' + IntToStr(ErrorCode) + ')'); + end; + end; + end; + end; +end; diff --git a/distribution/windows/installer/huntarr_installer.nsi b/distribution/windows/installer/huntarr_installer.nsi new file mode 100644 index 00000000..1ddbc319 --- /dev/null +++ b/distribution/windows/installer/huntarr_installer.nsi @@ -0,0 +1,183 @@ +; Huntarr NSIS Installer Script +; Modern UI +!include "MUI2.nsh" +!include "FileFunc.nsh" +!include "LogicLib.nsh" + +!verbose 4 ; Increase verbosity for debugging defines +!define DEFAULT_VERSION "1.0.0-default" + +!ifdef VERSIONFILE + !echo "VERSIONFILE is defined by command line as: '${VERSIONFILE}'" + !if /FILEEXISTS "${VERSIONFILE}" + !define /file VERSION "${VERSIONFILE}" + !searchreplace VERSION "${VERSION}" "\n" "" + !searchreplace VERSION "${VERSION}" "\r" "" + !echo "Successfully read version '${VERSION}' from '${VERSIONFILE}'" + !else + !error "VERSIONFILE was defined as '${VERSIONFILE}', but this file was NOT FOUND! Using default version." + !define VERSION "${DEFAULT_VERSION}" ; Fallback + !endif +!else + !warning "VERSIONFILE was NOT defined on the command line. Trying relative 'version.txt'." + !if /FILEEXISTS "version.txt" ; Relative to script path, or project root if lucky + !define /file VERSION "version.txt" + !searchreplace VERSION "${VERSION}" "\n" "" + !searchreplace VERSION "${VERSION}" "\r" "" + !echo "Successfully read version '${VERSION}' from relative 'version.txt'" + !else + !warning "Relative 'version.txt' also not found. Using default version '${DEFAULT_VERSION}'." + !define VERSION "${DEFAULT_VERSION}" + !endif +!endif + +!echo "Final VERSION defined as: '${VERSION}'" + +; Application details +!define APPNAME "Huntarr" +!define EXENAME "Huntarr.exe" +!define PUBLISHER "Huntarr" +!define URL "https://github.com/plexguide/Huntarr.io" + +; General settings +Name "${APPNAME}" +OutFile "installer\${APPNAME}_Setup.exe" +InstallDir "$PROGRAMFILES64\${APPNAME}" +InstallDirRegKey HKLM "Software\${APPNAME}" "Install_Dir" +RequestExecutionLevel admin ; Request admin privileges + +; Interface settings +!ifdef PROJECT_ROOT + !echo "DEBUG: Inside !ifdef PROJECT_ROOT. Value is: '${PROJECT_ROOT}'" + !define MUI_ICON "${PROJECT_ROOT}\frontend\static\logo\huntarr.ico" + !define MUI_UNICON "${PROJECT_ROOT}\frontend\static\logo\huntarr.ico" +!else + !error "PROJECT_ROOT was not defined on the command line." +!endif +!define MUI_ABORTWARNING +!define MUI_FINISHPAGE_RUN "$INSTDIR\${EXENAME}" +!define MUI_FINISHPAGE_RUN_PARAMETERS "--no-service" +!define MUI_FINISHPAGE_RUN_TEXT "Start Huntarr after installation" +!define MUI_FINISHPAGE_SHOWREADME "" +!define MUI_FINISHPAGE_SHOWREADME_NOTCHECKED +!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create Desktop Shortcut" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut + +; Pages +!insertmacro MUI_PAGE_WELCOME +; License page commented out - no license file available +; !insertmacro MUI_PAGE_LICENSE "LICENSE" +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +; Languages +!insertmacro MUI_LANGUAGE "English" + +; Install options/components +Section "Huntarr Application (required)" SecCore + SectionIn RO ; Read-only, always selected + + ; Set output path to installation directory + SetOutPath "$INSTDIR" + + ; Delete existing service if present (for upgrading from service to non-service) + nsExec::ExecToLog '"$INSTDIR\${EXENAME}" --remove-service' + + ; Copy all files from dist directory + !echo "Copying files from '${PROJECT_ROOT}\dist\Huntarr\*.*'" + File /r "${PROJECT_ROOT}\dist\Huntarr\*.*" + + ; Copy version.txt file + !echo "Copying version.txt from '${PROJECT_ROOT}\version.txt'" + File "${PROJECT_ROOT}\version.txt" + + ; Create required directories + CreateDirectory "$INSTDIR\config" + CreateDirectory "$INSTDIR\config\logs" + CreateDirectory "$INSTDIR\config\stateful" + CreateDirectory "$INSTDIR\config\user" + CreateDirectory "$INSTDIR\config\settings" + CreateDirectory "$INSTDIR\config\history" + CreateDirectory "$INSTDIR\config\scheduler" + CreateDirectory "$INSTDIR\config\reset" + CreateDirectory "$INSTDIR\config\tally" + CreateDirectory "$INSTDIR\config\swaparr" + CreateDirectory "$INSTDIR\config\eros" + CreateDirectory "$INSTDIR\logs" + CreateDirectory "$INSTDIR\frontend\templates" + CreateDirectory "$INSTDIR\frontend\static" + + ; Set permissions (using PowerShell to avoid quoting issues) + nsExec::ExecToLog 'powershell -Command "& {Set-Acl -Path \"$INSTDIR\config\" -AclObject (Get-Acl -Path \"$INSTDIR\config\")}"' + nsExec::ExecToLog 'powershell -Command "& {$acl = Get-Acl -Path \"$INSTDIR\config\"; $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule(\"Everyone\", \"FullControl\", \"ContainerInherit,ObjectInherit\", \"None\", \"Allow\"); $acl.SetAccessRule($accessRule); Set-Acl -Path \"$INSTDIR\config\" -AclObject $acl}"' + nsExec::ExecToLog 'powershell -Command "& {$acl = Get-Acl -Path \"$INSTDIR\logs\"; $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule(\"Everyone\", \"FullControl\", \"ContainerInherit,ObjectInherit\", \"None\", \"Allow\"); $acl.SetAccessRule($accessRule); Set-Acl -Path \"$INSTDIR\logs\" -AclObject $acl}"' + nsExec::ExecToLog 'powershell -Command "& {$acl = Get-Acl -Path \"$INSTDIR\frontend\"; $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule(\"Everyone\", \"FullControl\", \"ContainerInherit,ObjectInherit\", \"None\", \"Allow\"); $acl.SetAccessRule($accessRule); Set-Acl -Path \"$INSTDIR\frontend\" -AclObject $acl}"' + + ; Write uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Write registry keys for uninstall + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayName" "${APPNAME}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayIcon" "$INSTDIR\${EXENAME}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "Publisher" "${PUBLISHER}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "URLInfoAbout" "${URL}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayVersion" "${VERSION}" + WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "NoModify" 1 + WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "NoRepair" 1 + + ; Create Start Menu shortcuts + CreateDirectory "$SMPROGRAMS\${APPNAME}" + CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "http://localhost:9705" "" "$INSTDIR\${EXENAME}" 0 + CreateShortcut "$SMPROGRAMS\${APPNAME}\Run ${APPNAME}.lnk" "$INSTDIR\${EXENAME}" "--no-service" "$INSTDIR\${EXENAME}" 0 SW_SHOWMINIMIZED + CreateShortcut "$SMPROGRAMS\${APPNAME}\Open ${APPNAME} Web Interface.lnk" "http://localhost:9705" "" "$INSTDIR\${EXENAME}" 0 + CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe" +SectionEnd + +Section "Auto-start with Windows" SecAutoStart + ; Create shortcut in startup folder + CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" "$INSTDIR\${EXENAME}" "--no-service" "$INSTDIR\${EXENAME}" 0 SW_SHOWMINIMIZED +SectionEnd + +; Function to create desktop shortcut from the finish page option +Function CreateDesktopShortcut + CreateShortcut "$DESKTOP\${APPNAME}.lnk" "http://localhost:9705" "" "$INSTDIR\${EXENAME}" 0 +FunctionEnd + +; Uninstaller +Section "Uninstall" + ; Kill any running instances of Huntarr + nsExec::ExecToLog 'taskkill /F /IM ${EXENAME}' + Sleep 2000 ; Wait for processes to terminate + + ; Remove the startup shortcut if present + Delete "$SMSTARTUP\${APPNAME}.lnk" + + ; Remove Start Menu shortcuts + Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" + Delete "$SMPROGRAMS\${APPNAME}\Run ${APPNAME}.lnk" + Delete "$SMPROGRAMS\${APPNAME}\Open ${APPNAME} Web Interface.lnk" + Delete "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" + RMDir "$SMPROGRAMS\${APPNAME}" + + ; Remove desktop shortcut + Delete "$DESKTOP\${APPNAME}.lnk" + + ; Remove everything from the installation directory + RMDir /r "$INSTDIR" + + ; Remove registry keys + DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" + DeleteRegKey HKLM "Software\${APPNAME}" +SectionEnd + +; Descriptions +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SecCore} "The core Huntarr application. This is required." + !insertmacro MUI_DESCRIPTION_TEXT ${SecAutoStart} "Automatically start Huntarr when Windows starts." +!insertmacro MUI_FUNCTION_DESCRIPTION_END diff --git a/distribution/windows/main.py b/distribution/windows/main.py new file mode 100644 index 00000000..4b18762d --- /dev/null +++ b/distribution/windows/main.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Windows Build Script for Huntarr +This script is the main entry point for building Windows packages +""" + +import os +import sys +import argparse +from pathlib import Path + +# Add the parent directory to the path so we can import the build module +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Try to import the build module +try: + from build import build_exe, build_installer, clean +except ImportError: + print("Error: Could not import build module. Make sure it's in the same directory.") + sys.exit(1) + +def main(): + """Main entry point for Windows build script""" + parser = argparse.ArgumentParser( + description="Build Huntarr for Windows", + epilog="Example: python main.py --exe-only" + ) + + parser.add_argument("--clean", action="store_true", help="Clean up build artifacts") + parser.add_argument("--exe-only", action="store_true", help="Build only the executable, not the installer") + parser.add_argument("--installer-only", action="store_true", help="Build only the installer, assuming executable is already built") + + args = parser.parse_args() + + if args.clean: + clean() + if not (args.exe_only or args.installer_only): + return 0 + + if args.installer_only: + build_installer() + elif args.exe_only: + build_exe() + else: + # Build both + if build_exe(): + build_installer() + + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/distribution/windows/resources/config_paths.py b/distribution/windows/resources/config_paths.py new file mode 100644 index 00000000..5d3e92b5 --- /dev/null +++ b/distribution/windows/resources/config_paths.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Windows-specific path configuration for Huntarr +Handles path resolution for Windows installations +""" + +import os +import sys +import pathlib +import tempfile +import platform +import time +import ctypes + +# Verify we're running on Windows +OS_TYPE = platform.system() +IS_WINDOWS = (OS_TYPE == "Windows") + +# Get configuration directory - prioritize environment variable +CONFIG_DIR = os.environ.get("HUNTARR_CONFIG_DIR") + +if not CONFIG_DIR: + # Windows default location in %APPDATA% + if IS_WINDOWS: + CONFIG_DIR = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "Huntarr") + else: + # Fallback for non-Windows (should never happen in this Windows-specific file) + CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "huntarr") + +# Initialize the directory structure +CONFIG_PATH = pathlib.Path(CONFIG_DIR) + +# Create the main directory if it doesn't exist +try: + CONFIG_PATH.mkdir(parents=True, exist_ok=True) + print(f"Using configuration directory: {CONFIG_DIR}") + + # Check write permissions with a test file + test_file = CONFIG_PATH / f"write_test_{int(time.time())}.tmp" + try: + with open(test_file, "w") as f: + f.write("test") + if test_file.exists(): + test_file.unlink() # Remove the test file + except Exception as e: + print(f"Warning: Config directory exists but is not writable: {str(e)}") + # If running on Windows, check if admin privileges might help + if IS_WINDOWS: + try: + if not ctypes.windll.shell32.IsUserAnAdmin(): + print("You are not running with administrator privileges, which might affect permissions.") + print("Consider running as administrator or using the service installation option.") + except Exception: + pass +except Exception as e: + print(f"Warning: Could not create or write to config directory at {CONFIG_DIR}: {str(e)}") + # Fall back to temp directory as last resort + temp_base = tempfile.gettempdir() + CONFIG_DIR = os.path.join(temp_base, f"huntarr_config_{os.getpid()}") + CONFIG_PATH = pathlib.Path(CONFIG_DIR) + CONFIG_PATH.mkdir(parents=True, exist_ok=True) + print(f"Using temporary config directory: {CONFIG_DIR}") + + # Write warning to a log file in a location that should be writable + try: + desktop_path = os.path.join(os.path.expanduser("~"), "Desktop") + if os.path.exists(desktop_path): + desktop_log = os.path.join(desktop_path, "huntarr_error.log") + with open(desktop_log, "a") as f: + f.write(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] Using temporary config directory: {CONFIG_DIR}\n") + f.write(f"Original error accessing primary config: {str(e)}\n") + except Exception: + pass + +# Create standard directories +LOG_DIR = CONFIG_PATH / "logs" +SETTINGS_DIR = CONFIG_PATH / "settings" +USER_DIR = CONFIG_PATH / "user" +STATEFUL_DIR = CONFIG_PATH / "stateful" +HISTORY_DIR = CONFIG_PATH / "history" +SCHEDULER_DIR = CONFIG_PATH / "scheduler" +RESET_DIR = CONFIG_PATH / "reset" # Add reset directory +TALLY_DIR = CONFIG_PATH / "tally" # Add tally directory for stats +SWAPARR_DIR = CONFIG_PATH / "swaparr" # Add Swaparr directory +EROS_DIR = CONFIG_PATH / "eros" # Add Eros directory + +# Create all directories with enhanced error reporting +for dir_path in [LOG_DIR, SETTINGS_DIR, USER_DIR, STATEFUL_DIR, HISTORY_DIR, + SCHEDULER_DIR, RESET_DIR, TALLY_DIR, SWAPARR_DIR, EROS_DIR]: + try: + dir_path.mkdir(parents=True, exist_ok=True) + + # Test write access + test_file = dir_path / f"write_test_{int(time.time())}.tmp" + try: + with open(test_file, "w") as f: + f.write("test") + if test_file.exists(): + test_file.unlink() # Remove the test file + except Exception as write_err: + print(f"WARNING: Directory {dir_path} exists but write test failed: {write_err}") + except Exception as e: + print(f"ERROR: Could not create directory {dir_path}: {str(e)}") + +# Set environment variables for backwards compatibility +os.environ["HUNTARR_CONFIG_DIR"] = str(CONFIG_PATH) +os.environ["CONFIG_DIR"] = str(CONFIG_PATH) # For backward compatibility +os.environ["STATEFUL_DIR"] = str(STATEFUL_DIR) + +# Helper functions to get paths +def get_path(*args): + """Get a path relative to the config directory""" + return CONFIG_PATH.joinpath(*args) + +def get_app_config_path(app_type): + """Get the path to an app's config file""" + return CONFIG_PATH / f"{app_type}.json" + +def get_reset_path(app_type): + """Get the path to an app's reset file""" + return RESET_DIR / f"{app_type}.reset" + +def get_swaparr_state_path(): + """Get the Swaparr state directory""" + return SWAPARR_DIR + +def get_eros_config_path(): + """Get the Eros config file path""" + return CONFIG_PATH / "eros.json" diff --git a/distribution/windows/resources/windows_service.py b/distribution/windows/resources/windows_service.py new file mode 100644 index 00000000..f9713e87 --- /dev/null +++ b/distribution/windows/resources/windows_service.py @@ -0,0 +1,455 @@ +""" +Windows Service module for Huntarr. +Allows Huntarr to run as a Windows service. +Includes privilege checks and fallback mechanisms for non-admin users. +""" + +import os +import sys +import time +import logging +import servicemanager +import socket +import ctypes +import win32event +import win32service +import win32security +import win32serviceutil +import win32api +import win32con + +# Add the parent directory to sys.path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +# Import our config paths module early to ensure proper path setup +try: + from primary.utils import config_paths + config_dir = config_paths.CONFIG_DIR + logs_dir = config_paths.LOG_DIR +except Exception as e: + # Fallback if config_paths module can't be imported yet + config_dir = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "Huntarr") + logs_dir = os.path.join(config_dir, 'logs') + os.makedirs(logs_dir, exist_ok=True) + +# Configure basic logging +log_file = os.path.join(logs_dir, 'windows_service.log') +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('HuntarrWindowsService') + +# Also log to console when run directly +console_handler = logging.StreamHandler() +console_handler.setLevel(logging.INFO) +console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +console_handler.setFormatter(console_formatter) +logger.addHandler(console_handler) + +class HuntarrService(win32serviceutil.ServiceFramework): + """Windows Service implementation for Huntarr""" + + _svc_name_ = "Huntarr" + _svc_display_name_ = "Huntarr Service" + _svc_description_ = "Automated media collection management for Arr apps" + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.stop_event = win32event.CreateEvent(None, 0, 0, None) + self.is_running = False + socket.setdefaulttimeout(60) + self.main_thread = None + self.huntarr_app = None + self.stop_flag = None + + def SvcStop(self): + """Stop the service""" + logger.info('Stopping Huntarr service...') + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + win32event.SetEvent(self.stop_event) + self.is_running = False + + # Signal Huntarr to stop properly + if hasattr(self, 'stop_flag') and self.stop_flag: + logger.info('Setting stop flag for Huntarr...') + self.stop_flag.set() + + def SvcDoRun(self): + """Run the service""" + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, '') + ) + self.is_running = True + self.main() + + def main(self): + """Main service loop""" + try: + logger.info('Starting Huntarr service...') + + # Import here to avoid import errors when installing the service + import threading + from primary.background import start_huntarr, stop_event, shutdown_threads + from primary.web_server import app + from waitress import serve + + # Store the stop event for proper shutdown + self.stop_flag = stop_event + + # Configure service environment + os.environ['FLASK_HOST'] = '0.0.0.0' + os.environ['PORT'] = '9705' + os.environ['DEBUG'] = 'false' + + # Start background tasks in a thread + background_thread = threading.Thread( + target=start_huntarr, + name="HuntarrBackground", + daemon=True + ) + background_thread.start() + + # Start the web server in a thread + web_thread = threading.Thread( + target=lambda: serve(app, host='0.0.0.0', port=9705, threads=8), + name="HuntarrWebServer", + daemon=True + ) + web_thread.start() + + logger.info('Huntarr service started successfully') + + # Main service loop - keep running until stop event + while self.is_running: + # Wait for the stop event (or timeout for checking if threads are alive) + event_result = win32event.WaitForSingleObject(self.stop_event, 5000) + + # Check if we should exit + if event_result == win32event.WAIT_OBJECT_0: + break + + # Check if threads are still alive + if not background_thread.is_alive() or not web_thread.is_alive(): + logger.error("Critical: One of the Huntarr threads has died unexpectedly") + # Try to restart the threads if they died + if not background_thread.is_alive(): + logger.info("Attempting to restart background thread...") + background_thread = threading.Thread( + target=start_huntarr, + name="HuntarrBackground", + daemon=True + ) + background_thread.start() + + if not web_thread.is_alive(): + logger.info("Attempting to restart web server thread...") + web_thread = threading.Thread( + target=lambda: serve(app, host='0.0.0.0', port=9705, threads=8), + name="HuntarrWebServer", + daemon=True + ) + web_thread.start() + + # Service is stopping, clean up + logger.info('Huntarr service is shutting down...') + + # Set the stop event for Huntarr's background tasks + if not stop_event.is_set(): + stop_event.set() + + # Wait for threads to finish + logger.info('Waiting for Huntarr threads to finish...') + background_thread.join(timeout=30) + web_thread.join(timeout=10) + + logger.info('Huntarr service shutdown complete') + + except Exception as e: + logger.exception(f"Critical error in Huntarr service: {e}") + servicemanager.LogErrorMsg(f"Huntarr service error: {str(e)}") + + +def is_admin(): + """Check if the script is running with administrator privileges""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except: + return False + +def install_service(): + """Install Huntarr as a Windows service""" + if sys.platform != 'win32': + print("Windows service installation is only available on Windows.") + return False + + # Check for administrator privileges + if not is_admin(): + print("ERROR: Administrator privileges required to install the service.") + print("Please right-click on the installer or command prompt and select 'Run as administrator'.") + print("Alternatively, you can run Huntarr directly without service installation:") + print(" python main.py --no-service") + return False + + try: + # Ensure config directories exist and are writable + try: + from primary.utils import config_paths + print(f"Using config directory: {config_paths.CONFIG_DIR}") + + # Ensure we can write to config directory + try: + test_file = os.path.join(config_paths.CONFIG_DIR, "service_test.tmp") + with open(test_file, "w") as f: + f.write("Service installation test") + if os.path.exists(test_file): + os.remove(test_file) + print("Config directory is writable.") + else: + print("WARNING: Could not verify config directory is writable!") + return False + except Exception as e: + print(f"ERROR: Config directory is not writable: {e}") + print("Service installation cannot continue without writable config directory.") + return False + except Exception as e: + print(f"ERROR: Could not initialize config paths: {e}") + print("Service installation cannot continue.") + return False + + try: + # First, try to remove any existing service with the same name + remove_service(silent=True) + + # If we're running from a PyInstaller binary, use that directly + if getattr(sys, 'frozen', False): + # Running from a PyInstaller bundle + service_cmd = f"\"{os.path.abspath(sys.executable)}\"" # Quoted path to the exe + win32serviceutil.InstallService( + pythonClassString = None, + serviceName = "Huntarr", + displayName = "Huntarr Service", + description = "Automated media collection management for Arr apps", + startType = win32service.SERVICE_AUTO_START, + exeName = service_cmd, + exeArgs = "" + ) + print("Successfully installed Huntarr as a Windows service using the executable.") + else: + # Running from Python source - use pythonClassString for the service + # Get the module path in dot notation + module_path = "src.primary.windows_service.HuntarrService" + win32serviceutil.InstallService( + pythonClassString = module_path, + serviceName = "Huntarr", + displayName = "Huntarr Service", + description = "Automated media collection management for Arr apps", + startType = win32service.SERVICE_AUTO_START, + ) + print("Successfully installed Huntarr as a Windows service using the Python class.") + + # Setup service security + service_acl = win32security.SECURITY_ATTRIBUTES() + service_dacl = win32security.ACL() + service_sid = win32security.GetTokenInformation( + win32security.OpenProcessToken(win32api.GetCurrentProcess(), win32con.TOKEN_QUERY), + win32security.TokenUser + )[0] + service_dacl.AddAccessAllowedAce(win32security.ACL_REVISION, win32con.GENERIC_ALL, service_sid) + service_acl.SetSecurityDescriptorDacl(1, service_dacl, 0) + + schSCManager = win32service.OpenSCManager(None, None, win32service.SC_MANAGER_ALL_ACCESS) + try: + schService = win32service.OpenService(schSCManager, "Huntarr", win32service.SERVICE_ALL_ACCESS) + try: + # Set basic service security + win32service.SetServiceObjectSecurity(schService, + win32security.DACL_SECURITY_INFORMATION, + service_acl.SECURITY_DESCRIPTOR) + print("Successfully set service security.") + except Exception as security_error: + print(f"Warning: Could not set service security: {security_error}") + win32service.CloseServiceHandle(schService) + except Exception as service_error: + print(f"Warning: Could not open service for security setup: {service_error}") + win32service.CloseServiceHandle(schSCManager) + + # Set permissions for config and log directories + try: + from primary.utils import config_paths + config_dir = config_paths.CONFIG_DIR + + # Use icacls to set broad permissions for service access + # This is similar to what the installer does but as a fallback + os.system(f'icacls "{config_dir}" /grant Everyone:(OI)(CI)F /T') + print(f"Set permissions on config directory: {config_dir}") + + # Also ensure the parent directory of the executable has permissions if using PyInstaller + if getattr(sys, 'frozen', False): + exe_dir = os.path.dirname(os.path.abspath(sys.executable)) + os.system(f'icacls "{exe_dir}" /grant Everyone:(OI)(CI)F /T') + print(f"Set permissions on executable directory: {exe_dir}") + except Exception as perm_error: + print(f"Warning: Could not set directory permissions: {perm_error}") + + print("\nHuntarr service installation complete.") + print("You can now start the service with: net start Huntarr") + print("Or access Huntarr at: http://localhost:9705") + + # Try to start the service automatically + try: + win32serviceutil.StartService("Huntarr") + print("\nHuntarr service started successfully.") + + # If service starts successfully, open the browser after a short delay + import webbrowser + import threading + threading.Timer(3.0, lambda: webbrowser.open('http://localhost:9705')).start() + print("Opening web browser to Huntarr interface...") + except Exception as start_error: + print(f"\nNote: Could not automatically start the service: {start_error}") + print("Please start it manually using: net start Huntarr") + + return True + + except Exception as install_error: + print(f"Error installing service: {install_error}") + print("\nYou can still run Huntarr without the service using:") + print(" python main.py --no-service") + if 'Access is denied' in str(install_error): + print("\nAccess denied error detected. Try running as Administrator.") + return False + + except Exception as outer_error: + print(f"Unexpected error during service installation: {outer_error}") + return False + + +def remove_service(silent=False): + """Remove the Huntarr Windows service + + Args: + silent (bool): If True, don't print status messages + + Returns: + bool: True if removal succeeded or service doesn't exist, False otherwise + """ + if sys.platform != 'win32': + if not silent: + print("Windows service removal is only available on Windows.") + return False + + # Check for administrator privileges + if not is_admin(): + if not silent: + print("ERROR: Administrator privileges required to remove the Huntarr service.") + print("Please right-click on the command prompt and select 'Run as administrator'.\n") + return False + + try: + # Stop the service if it's running + try: + win32serviceutil.StopService("Huntarr") + # Give it a moment to stop + time.sleep(2) + if not silent: + print("Stopped Huntarr service.") + except Exception as e: + logger.info(f"Could not stop service (it may not be running): {e}") + + # Remove the service + win32serviceutil.RemoveService("Huntarr") + if not silent: + print("Huntarr service removed successfully.") + return True + except Exception as e: + if not silent: + print(f"Error removing Huntarr service: {e}") + if 'service does not exist' in str(e).lower(): + print("(Service was not installed or was already removed)") + # Consider it a success if the service doesn't exist + if 'service does not exist' in str(e).lower(): + return True + return False + + +def run_as_cli(): + """Run Huntarr as a command-line application (non-service fallback)""" + try: + print("Starting Huntarr in CLI mode (non-service)...") + + # Import the necessary modules + from primary.background import start_huntarr, stop_event, shutdown_threads + from primary.web_server import app + from waitress import serve + import threading + + # Configure CLI environment + os.environ['FLASK_HOST'] = '0.0.0.0' + os.environ['PORT'] = '9705' + os.environ['DEBUG'] = 'false' + + # Ensure config directories exist + from primary.utils import config_paths + print(f"Using config directory: {config_paths.CONFIG_DIR}") + + # Verify config directories are writable with a test file + try: + test_file = os.path.join(config_paths.CONFIG_DIR, "cli_test.tmp") + with open(test_file, "w") as f: + f.write("CLI mode test") + if os.path.exists(test_file): + os.remove(test_file) + print("Config directory is writable.") + else: + print("WARNING: Could not verify config directory is writable!") + except Exception as e: + print(f"WARNING: Config directory may not be writable: {e}") + print(f"Some features may not work correctly.") + + # Start background tasks in a thread + background_thread = threading.Thread( + target=start_huntarr, + name="HuntarrBackground", + daemon=True + ) + background_thread.start() + + # Print a welcome message + print("="*80) + print(" Huntarr Started Successfully in CLI Mode") + print(" Web interface available at: http://localhost:9705") + print(" Press Ctrl+C to exit") + print("="*80) + + # Start the web server (blocking) + serve(app, host='0.0.0.0', port=9705, threads=8) + + except KeyboardInterrupt: + print("\nKeyboard interrupt received. Shutting down...") + if not stop_event.is_set(): + stop_event.set() + except Exception as e: + print(f"\nError in CLI mode: {e}") + if 'stop_event' in locals() and not stop_event.is_set(): + stop_event.set() + finally: + print("Huntarr shutdown complete.") + return 0 + + +if __name__ == '__main__': + if len(sys.argv) > 1: + if sys.argv[1] == 'install': + install_service() + elif sys.argv[1] == 'remove': + remove_service() + elif sys.argv[1] == 'cli': + run_as_cli() + else: + win32serviceutil.HandleCommandLine(HuntarrService) + else: + win32serviceutil.HandleCommandLine(HuntarrService) diff --git a/distribution/windows/resources/windows_startup.py b/distribution/windows/resources/windows_startup.py new file mode 100644 index 00000000..1addb31d --- /dev/null +++ b/distribution/windows/resources/windows_startup.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Windows Startup Helper for Huntarr +Provides Windows-specific initialization and error handling for clean startup +""" + +import os +import sys +import json +import logging +import tempfile +import traceback +import pathlib +import time +import threading +from functools import wraps + +def windows_startup_check(): + """ + Perform Windows-specific startup checks and initialization + + This function should be called early in the startup process to ensure + proper Windows compatibility and prevent 500 errors on startup. + """ + logger = logging.getLogger("WindowsStartup") + logger.info("Running Windows-specific startup checks...") + + # Verify Python path and sys.executable + logger.info(f"Python executable: {sys.executable}") + logger.info(f"Python paths: {sys.path}") + + # Check for key directories + try: + from src.primary.utils import config_paths + logger.info(f"Using config directory: {config_paths.CONFIG_DIR}") + + # Ensure config directories are properly set up + if not os.path.exists(config_paths.CONFIG_DIR): + logger.warning(f"Config directory does not exist: {config_paths.CONFIG_DIR}") + logger.info("Creating config directory...") + os.makedirs(config_paths.CONFIG_DIR, exist_ok=True) + + # Test write access to config directory + test_file = os.path.join(config_paths.CONFIG_DIR, f"windows_startup_test_{int(time.time())}.tmp") + try: + with open(test_file, "w") as f: + f.write("Windows startup test") + if os.path.exists(test_file): + os.remove(test_file) + logger.info("Config directory is writable") + else: + logger.warning("Config directory write test file could not be created") + except Exception as e: + logger.error(f"Config directory is not writable: {e}") + logger.info("Attempting to create alternate config location...") + + # Try to create config in user's Documents folder as fallback + alt_config = os.path.join(os.path.expanduser("~"), "Documents", "Huntarr") + os.makedirs(alt_config, exist_ok=True) + os.environ["HUNTARR_CONFIG_DIR"] = alt_config + logger.info(f"Set alternate config directory: {alt_config}") + + # Create a test file in the alternate location + test_file = os.path.join(alt_config, f"windows_startup_test_{int(time.time())}.tmp") + try: + with open(test_file, "w") as f: + f.write("Windows startup test (alternate location)") + if os.path.exists(test_file): + os.remove(test_file) + logger.info("Alternate config directory is writable") + else: + logger.warning("Alternate config directory write test file could not be created") + except Exception as alt_err: + logger.error(f"Alternate config directory is not writable: {alt_err}") + # Last resort - use temp directory + temp_dir = os.path.join(tempfile.gettempdir(), f"huntarr_config_{os.getpid()}") + os.makedirs(temp_dir, exist_ok=True) + os.environ["HUNTARR_CONFIG_DIR"] = temp_dir + logger.info(f"Using temporary directory as last resort: {temp_dir}") + except Exception as e: + logger.error(f"Error during Windows startup check: {e}") + logger.error(traceback.format_exc()) + +def patch_flask_paths(app): + """ + Patch Flask paths to handle Windows-specific path issues + + This addresses issues with template and static file loading on Windows + """ + logger = logging.getLogger("WindowsStartup") + logger.info("Patching Flask paths for Windows compatibility...") + + # Get base directory + if getattr(sys, 'frozen', False): + # PyInstaller bundle + base_dir = os.path.dirname(sys.executable) + else: + # Regular Python execution + base_dir = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + + # Define template directory candidates + template_candidates = [ + os.path.join(base_dir, 'frontend', 'templates'), + os.path.join(base_dir, 'templates'), + os.path.abspath(os.path.join(base_dir, '..', 'frontend', 'templates')), + ] + + # Find the first existing template directory + for template_dir in template_candidates: + if os.path.exists(template_dir) and os.path.isdir(template_dir): + logger.info(f"Setting Flask template folder to: {template_dir}") + app.template_folder = template_dir + break + + # Define static file candidates + static_candidates = [ + os.path.join(base_dir, 'frontend', 'static'), + os.path.join(base_dir, 'static'), + os.path.abspath(os.path.join(base_dir, '..', 'frontend', 'static')), + ] + + # Find the first existing static directory + for static_dir in static_candidates: + if os.path.exists(static_dir) and os.path.isdir(static_dir): + logger.info(f"Setting Flask static folder to: {static_dir}") + app.static_folder = static_dir + break + + return app + +def windows_exception_handler(func): + """ + Decorator to catch and log Windows-specific exceptions + This helps prevent 500 errors by providing better error handling + """ + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except PermissionError as e: + logger = logging.getLogger("WindowsStartup") + logger.error(f"Windows permission error in {func.__name__}: {e}") + logger.error(traceback.format_exc()) + + # Try to provide helpful error message for common Windows permission issues + if "access is denied" in str(e).lower(): + logger.error("This appears to be a Windows permission issue. Try running as Administrator or check folder permissions.") + + return {"error": f"Windows permission error: {str(e)}"}, 500 + except Exception as e: + logger = logging.getLogger("WindowsStartup") + logger.error(f"Error in {func.__name__}: {e}") + logger.error(traceback.format_exc()) + return {"error": str(e)}, 500 + + return wrapper + +def apply_windows_patches(app): + """ + Apply all Windows-specific patches to the Flask app + + Args: + app: The Flask application to patch + + Returns: + The patched Flask app + """ + # Run startup checks + windows_startup_check() + + # Patch Flask paths + app = patch_flask_paths(app) + + # Apply exception handler to key routes that might cause 500 errors + for endpoint in ['home', 'logs_stream', 'api_settings', 'api_app_settings', 'api_app_status']: + if hasattr(app.view_functions.get(endpoint, {}), '__call__'): + app.view_functions[endpoint] = windows_exception_handler(app.view_functions[endpoint]) + + return app diff --git a/distribution/windows/scripts/configure_paths.py b/distribution/windows/scripts/configure_paths.py new file mode 100644 index 00000000..6376d1dc --- /dev/null +++ b/distribution/windows/scripts/configure_paths.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Windows path configuration helper for Huntarr +Sets up proper config directories and permissions for Windows installations +""" + +import os +import sys +import pathlib +import tempfile +import ctypes +import time +import subprocess + +def is_admin(): + """Check if script is running with administrator privileges""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except: + return False + +def setup_config_directories(base_dir=None): + """ + Set up and verify all required Huntarr configuration directories + + Args: + base_dir: Optional base directory, if None will use %APPDATA%\Huntarr + + Returns: + tuple: (config_dir, success_flag) + """ + # Determine config directory location + if base_dir: + config_dir = base_dir + elif os.environ.get("HUNTARR_CONFIG_DIR"): + config_dir = os.environ.get("HUNTARR_CONFIG_DIR") + else: + # Use Windows standard location + config_dir = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "Huntarr") + + config_path = pathlib.Path(config_dir) + success = True + + # Create required directories + directories = [ + config_path, + config_path / "logs", + config_path / "user", + config_path / "settings", + config_path / "stateful", + config_path / "history", + config_path / "scheduler", + config_path / "reset", + config_path / "tally", + config_path / "swaparr", + config_path / "eros" + ] + + print(f"Setting up Huntarr configuration in: {config_dir}") + + for directory in directories: + try: + directory.mkdir(exist_ok=True, parents=True) + + # Verify directory exists + if not directory.exists(): + print(f"ERROR: Failed to create directory: {directory}") + success = False + else: + # Test writability + test_file = directory / f"config_test_{int(time.time())}.tmp" + try: + with open(test_file, "w") as f: + f.write("Configuration test") + if test_file.exists(): + test_file.unlink() # Remove test file + else: + print(f"WARNING: Write test file was not created in {directory}") + success = False + except Exception as e: + print(f"ERROR: Directory {directory} is not writable: {e}") + success = False + except Exception as e: + print(f"ERROR: Could not create or verify directory {directory}: {e}") + success = False + + # Set permissions if we're running as admin + if is_admin(): + try: + # Use icacls to grant permissions + subprocess.run(['icacls', str(config_path), '/grant', 'Everyone:(OI)(CI)F', '/T'], + check=True, capture_output=True) + print("Set permissions on config directory for all users") + except Exception as e: + print(f"WARNING: Could not set permissions on config directory: {e}") + else: + print("NOTE: Running without administrator privileges. Some permission settings skipped.") + + # Set environment variable + os.environ["HUNTARR_CONFIG_DIR"] = str(config_path) + os.environ["CONFIG_DIR"] = str(config_path) # For backward compatibility + + return config_dir, success + +def verify_service_config(): + """ + Verify service configuration and permissions + + Returns: + bool: True if verification passed + """ + # Only applicable on Windows + if sys.platform != 'win32': + return False + + config_dir, success = setup_config_directories() + + if not success: + print("WARNING: Configuration directory setup had issues.") + print("Some features of Huntarr may not work correctly.") + + # Try to fall back to a temporary directory if needed + if not success: + temp_dir = os.path.join(tempfile.gettempdir(), f"huntarr_config_{os.getpid()}") + print(f"Attempting to use temporary directory: {temp_dir}") + temp_config_dir, temp_success = setup_config_directories(temp_dir) + + if temp_success: + print(f"Successfully using temporary directory: {temp_config_dir}") + return True + + return success + +if __name__ == "__main__": + # When run directly, set up the config directories + config_dir, success = setup_config_directories() + + if success: + print("\nConfiguration directory setup completed successfully!") + print(f"Huntarr config directory: {config_dir}") + else: + print("\nConfiguration directory setup had issues!") + print("Please run this script as Administrator for best results.") + sys.exit(1) diff --git a/distribution/windows/scripts/windows_setup.py b/distribution/windows/scripts/windows_setup.py new file mode 100644 index 00000000..d8b7d004 --- /dev/null +++ b/distribution/windows/scripts/windows_setup.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Windows Setup Helper for Huntarr +Assists with configuring Huntarr for Windows environments +""" + +import os +import sys +import shutil +import subprocess +import ctypes +import winreg +import tempfile +import time +import traceback + +def is_admin(): + """Check if the script is running with administrator privileges""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except: + return False + +def setup_environment(): + """Set up Huntarr environment variables and paths""" + # Determine the base config directory + app_data = os.environ.get("APPDATA", os.path.expanduser("~")) + config_dir = os.path.join(app_data, "Huntarr") + + # Create main directories + directories = [ + os.path.join(config_dir), + os.path.join(config_dir, "logs"), + os.path.join(config_dir, "user"), + os.path.join(config_dir, "settings"), + os.path.join(config_dir, "stateful"), + os.path.join(config_dir, "history"), + os.path.join(config_dir, "scheduler"), + os.path.join(config_dir, "reset"), + os.path.join(config_dir, "tally"), + os.path.join(config_dir, "swaparr"), + os.path.join(config_dir, "eros") + ] + + print(f"Setting up Huntarr configuration in: {config_dir}") + + for directory in directories: + try: + os.makedirs(directory, exist_ok=True) + print(f"Created directory: {directory}") + except Exception as e: + print(f"Error creating directory {directory}: {e}") + + # Set environment variable + try: + set_environment_variable("HUNTARR_CONFIG_DIR", config_dir) + print(f"Set HUNTARR_CONFIG_DIR environment variable to: {config_dir}") + except Exception as e: + print(f"Error setting environment variable: {e}") + + # Set permissions if admin + if is_admin(): + set_directory_permissions(config_dir) + else: + print("Not running as admin - skipping permission setting.") + print("Some features may not work correctly without proper permissions.") + + return config_dir + +def set_environment_variable(name, value): + """Set a persistent environment variable""" + try: + # User environment variable + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_ALL_ACCESS) + winreg.SetValueEx(key, name, 0, winreg.REG_EXPAND_SZ, value) + winreg.CloseKey(key) + + # Also set for current process + os.environ[name] = value + + # Notify Windows of environment change + subprocess.run(["rundll32", "user32.dll,UpdatePerUserSystemParameters"]) + return True + except Exception as e: + print(f"Error setting registry key: {e}") + # Set for current process anyway + os.environ[name] = value + return False + +def set_directory_permissions(directory): + """Set appropriate permissions on directory and children""" + if not is_admin(): + print("WARNING: Cannot set permissions without admin rights") + return False + + try: + # Use icacls to set permissions recursively + subprocess.run(['icacls', directory, '/grant', 'Everyone:(OI)(CI)F', '/T'], + check=True, capture_output=True) + print(f"Set permissions on: {directory}") + return True + except Exception as e: + print(f"Error setting permissions: {e}") + return False + +def check_requirements(): + """Check if all requirements for Huntarr are met""" + print("Checking Windows requirements for Huntarr...") + requirements_met = True + + # Check Python version + python_version = sys.version_info + print(f"Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") + if python_version.major < 3 or (python_version.major == 3 and python_version.minor < 9): + print("WARNING: Huntarr requires Python 3.9 or higher") + requirements_met = False + + # Check for pywin32 + try: + import win32service + print("pywin32 is installed") + except ImportError: + print("WARNING: pywin32 is not installed - Windows service features may not work") + requirements_met = False + + # Check for waitress + try: + import waitress + print("waitress is installed") + except ImportError: + print("WARNING: waitress is not installed - Web server may not function") + requirements_met = False + + # Check port 9705 availability + try: + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + result = s.connect_ex(('127.0.0.1', 9705)) + s.close() + if result == 0: + print("WARNING: Port 9705 is already in use - Huntarr may not start correctly") + requirements_met = False + else: + print("Port 9705 is available") + except: + print("Could not check port availability") + + return requirements_met + +def create_test_file(directory): + """Create a test file to verify write permissions""" + test_path = os.path.join(directory, f"test_{int(time.time())}.tmp") + try: + with open(test_path, 'w') as f: + f.write("Test write permissions") + os.remove(test_path) + return True + except Exception as e: + print(f"WARNING: Write test failed: {e}") + return False + +def main(): + """Main entry point""" + print("Huntarr Windows Setup Helper") + print("===========================") + + try: + # Check if running as admin + if is_admin(): + print("Running with administrator privileges") + else: + print("NOTE: Not running with administrator privileges") + print("Some operations may fail without administrator rights") + + # Check requirements + if check_requirements(): + print("All requirements met") + else: + print("Not all requirements are met - Huntarr may not function correctly") + + # Setup environment + config_dir = setup_environment() + + # Test write permissions + if create_test_file(config_dir): + print("Write permissions verified") + else: + print("WARNING: Write permissions test failed") + print("Huntarr may not function correctly without proper permissions") + + print("\nSetup complete!") + print(f"Huntarr configuration directory: {config_dir}") + return 0 + except Exception as e: + print(f"Error during setup: {e}") + print(traceback.format_exc()) + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/huntarr_installer.iss b/huntarr_installer.iss new file mode 100644 index 00000000..7f599727 --- /dev/null +++ b/huntarr_installer.iss @@ -0,0 +1,113 @@ +#define MyAppName "Huntarr" +#define ReadVersionFile(str fileName) \ + Local[0] = FileOpen(fileName), \ + Local[1] = FileRead(Local[0]), \ + FileClose(Local[0]), \ + Local[1] + +#define MyAppVersion ReadVersionFile("version.txt") +#define MyAppPublisher "Huntarr" +#define MyAppURL "https://github.com/plexguide/Huntarr.io" +#define MyAppExeName "Huntarr.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. +; Do not use the same AppId value in installers for other applications. +AppId={{22AE2CDB-5F87-4E42-B5C3-28E121D4BDFF} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={pf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +LicenseFile=LICENSE +OutputDir=.\installer +OutputBaseFilename=Huntarr_Setup +SetupIconFile=frontend\static\logo\huntarr.ico +Compression=lzma +SolidCompression=yes +PrivilegesRequired=admin +ArchitecturesInstallIn64BitMode=x64 +DisableDirPage=no +DisableProgramGroupPage=yes +UninstallDisplayIcon={app}\{#MyAppExeName} +WizardStyle=modern +CloseApplications=no +RestartApplications=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1 +Name: "installservice"; Description: "Install as Windows Service"; GroupDescription: "Windows Service"; Flags: checkedonce + +[Files] +Source: "dist\Huntarr\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; Create empty config directories to ensure they exist with proper permissions +Source: "LICENSE"; DestDir: "{app}\config"; Flags: ignoreversion; AfterInstall: CreateConfigDirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +; First, remove any existing service +Filename: "{app}\{#MyAppExeName}"; Parameters: "--remove-service"; Flags: runhidden +; Wait a moment for the service to be properly removed +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 3"; Flags: runhidden +; Install the service +Filename: "{app}\{#MyAppExeName}"; Parameters: "--install-service"; Description: "Install Huntarr as a Windows Service"; Tasks: installservice; Flags: runhidden +; Grant permissions to the config directory +Filename: "{sys}\cmd.exe"; Parameters: '/c icacls "{app}\config" /grant Everyone:(OI)(CI)F'; Flags: runhidden shellexec +; Start the service +Filename: "{sys}\net.exe"; Parameters: "start Huntarr"; Flags: runhidden; Tasks: installservice +; Launch Huntarr +Filename: "http://localhost:9705"; Description: "Open Huntarr Web Interface"; Flags: postinstall shellexec nowait +; Launch Huntarr +Filename: "{app}\{#MyAppExeName}"; Description: "Run Huntarr Application"; Flags: nowait postinstall skipifsilent; Check: not IsTaskSelected('installservice') + +[UninstallRun] +; Stop the service first +Filename: "{sys}\net.exe"; Parameters: "stop Huntarr"; Flags: runhidden +; Wait a moment for the service to stop +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 3"; Flags: runhidden +; Then remove it +Filename: "{app}\{#MyAppExeName}"; Parameters: "--remove-service"; Flags: runhidden + +[Code] +procedure CreateConfigDirs; +begin + // Create necessary directories with explicit permissions + ForceDirectories(ExpandConstant('{app}\config\logs')); + ForceDirectories(ExpandConstant('{app}\config\stateful')); + ForceDirectories(ExpandConstant('{app}\config\user')); +end; + +// Check for running services and processes before install +function InitializeSetup(): Boolean; +var + ResultCode: Integer; +begin + // Try to stop the service if it's already running + Exec(ExpandConstant('{sys}\net.exe'), 'stop Huntarr', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + // Give it a moment to stop + Sleep(2000); + Result := True; +end; + +// Handle cleaning up before uninstall +function InitializeUninstall(): Boolean; +var + ResultCode: Integer; +begin + // Try to stop the service before uninstalling + Exec(ExpandConstant('{sys}\net.exe'), 'stop Huntarr', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + Result := True; +end; \ No newline at end of file diff --git a/src/primary/migrate_configs.py b/src/primary/migrate_configs.py index 11c4faf7..077ffeff 100644 --- a/src/primary/migrate_configs.py +++ b/src/primary/migrate_configs.py @@ -3,26 +3,30 @@ Huntarr Configuration Migration Script This module is responsible for migrating legacy JSON configuration files -from the old location (/config/) to the new settings directory (/config/settings/) +from the root config directory to the settings subdirectory """ import os import shutil import logging +import pathlib # Get the logger logger = logging.getLogger('huntarr') +# Import config paths +from src.primary.utils.config_paths import CONFIG_PATH, SETTINGS_DIR + def migrate_json_configs(): """ Migrates JSON configuration files from old location to new settings directory. - Looks for specific JSON configuration files in /config/ and moves them - to /config/settings/ if they exist. + Looks for specific JSON configuration files in the root config directory and moves them + to the settings subdirectory if they exist. """ - # Define the source and destination directories - source_dir = "/config" - dest_dir = "/config/settings" + # Define the source and destination directories using platform-compatible paths + source_dir = CONFIG_PATH + dest_dir = SETTINGS_DIR # List of JSON files to look for and migrate json_files = [ diff --git a/src/primary/utils/windows_integration.py b/src/primary/utils/windows_integration.py new file mode 100644 index 00000000..f79a832a --- /dev/null +++ b/src/primary/utils/windows_integration.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Windows Integration for Huntarr +Integrates Windows-specific helper functions with the main application +""" + +import os +import sys +import platform +import logging +import importlib.util +from pathlib import Path + +# Set up logger +logger = logging.getLogger("windows_integration") + +# Check if running on Windows +IS_WINDOWS = platform.system() == "Windows" + +def is_pyinstaller_bundle(): + """Check if running from a PyInstaller bundle""" + return getattr(sys, 'frozen', False) + +def integrate_windows_helpers(app=None): + """ + Integrate Windows-specific helpers if running on Windows + + Args: + app: Optional Flask application to patch + + Returns: + The patched Flask app if provided, otherwise None + """ + if not IS_WINDOWS: + logger.debug("Not running on Windows, skipping Windows integration") + return app + + logger.info("Windows platform detected, integrating Windows-specific helpers") + + try: + # Attempt to import the Windows startup helper + # First check if we're running from a PyInstaller bundle + if is_pyinstaller_bundle(): + # When running from PyInstaller, the helpers should be in the executable's directory + exe_dir = Path(sys.executable).parent + + # Check several possible locations + windows_helpers_paths = [ + exe_dir / "resources" / "windows_startup.py", + exe_dir / "distribution" / "windows" / "resources" / "windows_startup.py", + exe_dir / "windows_startup.py" + ] + + windows_helpers_module = None + for path in windows_helpers_paths: + if path.exists(): + logger.info(f"Found Windows helpers at: {path}") + spec = importlib.util.spec_from_file_location("windows_startup", path) + windows_helpers_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(windows_helpers_module) + break + + if windows_helpers_module is None: + # Try creating a dynamic import from the embedded code + logger.info("Windows helpers not found in standard locations, using embedded code") + # The code will be embedded when the PyInstaller bundle is created + + # Run basic Windows startup check + from src.primary.utils.config_paths import CONFIG_DIR, CONFIG_PATH + logger.info(f"Using config directory: {CONFIG_DIR}") + + # Ensure paths are properly configured + if app: + # Basic template and static path detection for Windows + base_dir = Path(sys.executable).parent + template_dir = base_dir / "frontend" / "templates" + static_dir = base_dir / "frontend" / "static" + + if template_dir.exists(): + logger.info(f"Setting Flask template folder to: {template_dir}") + app.template_folder = str(template_dir) + + if static_dir.exists(): + logger.info(f"Setting Flask static folder to: {static_dir}") + app.static_folder = str(static_dir) + else: + # When running from source code, check if the helper is in the distribution directory + project_root = Path(__file__).parent.parent.parent.parent + windows_helpers_path = project_root / "distribution" / "windows" / "resources" / "windows_startup.py" + + if windows_helpers_path.exists(): + logger.info(f"Found Windows helpers at: {windows_helpers_path}") + spec = importlib.util.spec_from_file_location("windows_startup", windows_helpers_path) + windows_helpers_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(windows_helpers_module) + + # If Flask app was provided, apply Windows patches + if app and hasattr(windows_helpers_module, 'apply_windows_patches'): + app = windows_helpers_module.apply_windows_patches(app) + else: + logger.warning(f"Windows helpers not found at: {windows_helpers_path}") + # Run basic checks regardless + from src.primary.utils.config_paths import CONFIG_DIR + logger.info(f"Using config directory: {CONFIG_DIR}") + + except Exception as e: + logger.error(f"Error integrating Windows helpers: {e}", exc_info=True) + # Continue execution to avoid crashing the application + + return app + + +def prepare_windows_environment(): + """ + Prepare Windows environment before application startup + This runs without needing the Flask app object + """ + if not IS_WINDOWS: + return + + logger.info("Preparing Windows environment") + + try: + # Create a special error log on the desktop for visibility + desktop_path = os.path.join(os.path.expanduser("~"), "Desktop") + if os.path.exists(desktop_path): + error_log_path = os.path.join(desktop_path, "huntarr_startup.log") + + # Configure a file handler to log startup issues + file_handler = logging.FileHandler(error_log_path) + file_handler.setLevel(logging.INFO) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + file_handler.setFormatter(formatter) + + # Add the handler to the root logger + root_logger = logging.getLogger() + root_logger.addHandler(file_handler) + + logger.info(f"Logging Windows startup info to: {error_log_path}") + + # Ensure key directories exist + from src.primary.utils.config_paths import CONFIG_DIR, LOG_DIR, USER_DIR, SETTINGS_DIR + + # Double-check that directories were created correctly + os.makedirs(CONFIG_DIR, exist_ok=True) + os.makedirs(LOG_DIR, exist_ok=True) + os.makedirs(USER_DIR, exist_ok=True) + os.makedirs(SETTINGS_DIR, exist_ok=True) + + # Test write permissions + for directory in [CONFIG_DIR, LOG_DIR, USER_DIR, SETTINGS_DIR]: + test_file = os.path.join(directory, f"windows_test.tmp") + try: + with open(test_file, "w") as f: + f.write("Windows environment test") + os.remove(test_file) + logger.info(f"Successfully verified write access to: {directory}") + except Exception as e: + logger.error(f"Cannot write to {directory}: {e}") + + # Try to create a fallback location if this is the config directory + if directory == CONFIG_DIR: + fallback_dir = os.path.join(os.path.expanduser("~"), "Documents", "Huntarr") + logger.info(f"Attempting to use fallback location: {fallback_dir}") + os.makedirs(fallback_dir, exist_ok=True) + os.environ["HUNTARR_CONFIG_DIR"] = fallback_dir + + except Exception as e: + logger.error(f"Error preparing Windows environment: {e}", exc_info=True) diff --git a/src/primary/web_server.py b/src/primary/web_server.py index 5793cad9..4614c5d9 100644 --- a/src/primary/web_server.py +++ b/src/primary/web_server.py @@ -148,8 +148,32 @@ def get_base_url(): # Define base_url at module level base_url = '' +# Check for Windows platform and integrate Windows-specific helpers +import platform +if platform.system() == "Windows": + # Import Windows integration module for startup support + try: + from src.primary.utils.windows_integration import prepare_windows_environment + # Prepare Windows environment before creating Flask app + prepare_windows_environment() + except Exception as e: + print(f"Error integrating Windows helpers: {e}") + # Create Flask app with additional debug logging -app = Flask(__name__, template_folder=template_dir, static_folder=static_dir) +app = Flask(__name__, + template_folder=template_dir, + static_folder=static_dir, + static_url_path='/static') + +# Apply Windows-specific patches to Flask app if on Windows +if platform.system() == "Windows": + try: + from src.primary.utils.windows_integration import integrate_windows_helpers + app = integrate_windows_helpers(app) + except Exception as e: + print(f"Error applying Windows patches: {e}") + +app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' print(f"Flask app created with template_folder: {app.template_folder}") print(f"Flask app created with static_folder: {app.static_folder}") diff --git a/src/primary/windows_service.py b/src/primary/windows_service.py index 27b67b10..762c8096 100644 --- a/src/primary/windows_service.py +++ b/src/primary/windows_service.py @@ -1,6 +1,7 @@ """ Windows Service module for Huntarr. Allows Huntarr to run as a Windows service. +Includes privilege checks and fallback mechanisms for non-admin users. """ import os @@ -9,17 +10,31 @@ import logging import servicemanager import socket +import ctypes import win32event import win32service +import win32security import win32serviceutil +import win32api +import win32con # Add the parent directory to sys.path for imports sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) +# Import our config paths module early to ensure proper path setup +try: + from primary.utils import config_paths + config_dir = config_paths.CONFIG_DIR + logs_dir = config_paths.LOGS_DIR +except Exception as e: + # Fallback if config_paths module can't be imported yet + config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config') + logs_dir = os.path.join(config_dir, 'logs') + os.makedirs(logs_dir, exist_ok=True) + # Configure basic logging logging.basicConfig( - filename=os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - 'config', 'logs', 'windows_service.log'), + filename=os.path.join(logs_dir, 'windows_service.log'), level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) @@ -55,24 +70,69 @@ def SvcStop(self): def SvcDoRun(self): """Run the service""" - servicemanager.LogMsg( - servicemanager.EVENTLOG_INFORMATION_TYPE, - servicemanager.PYS_SERVICE_STARTED, - (self._svc_name_, '') - ) - self.is_running = True - self.main() + # Report service as starting but not started yet + # This critical step signals Windows that service initialization is underway + self.ReportServiceStatus(win32service.SERVICE_START_PENDING) + + try: + # Log the start attempt first + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTING, + (self._svc_name_, 'Service is starting...') + ) + + # Update logging to ensure output is properly captured + logging.basicConfig( + filename=os.path.join(logs_dir, 'windows_service.log'), + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # Mark service as running + self.is_running = True + + # Enter the main service function + self.main() + + except Exception as e: + # Log any startup errors and mark service as stopped + servicemanager.LogErrorMsg(f"Failed to start service: {e}") + self.ReportServiceStatus(win32service.SERVICE_STOPPED) def main(self): """Main service loop""" try: logger.info('Starting Huntarr service...') - # Import here to avoid import errors when installing the service + # Create a service-specific log file + service_log_file = os.path.join(logs_dir, 'huntarr_service_runtime.log') + try: + with open(service_log_file, 'a') as f: + f.write(f"\n\n--- Service started at {time.ctime()} ---\n") + f.write(f"Current directory: {os.getcwd()}\n") + f.write(f"Python executable: {sys.executable}\n") + if hasattr(sys, 'frozen'): + f.write("Running as PyInstaller bundle\n") + else: + f.write("Running as Python script\n") + except Exception as e: + logger.error(f"Could not write to service log file: {e}") + + # Signal we're starting + self.ReportServiceStatus(win32service.SERVICE_START_PENDING) + + # Import dependencies - do this early to catch import errors import threading - from primary.background import start_huntarr, stop_event, shutdown_threads - from primary.web_server import app - from waitress import serve + try: + from primary.background import start_huntarr, stop_event, shutdown_threads + from primary.web_server import app + from waitress import serve + logger.info("Successfully imported required modules") + except Exception as e: + servicemanager.LogErrorMsg(f"Failed to import required modules: {e}") + logger.error(f"Critical error importing modules: {e}") + return # Store the stop event for proper shutdown self.stop_flag = stop_event @@ -82,23 +142,69 @@ def main(self): os.environ['PORT'] = '9705' os.environ['DEBUG'] = 'false' - # Start background tasks in a thread - background_thread = threading.Thread( - target=start_huntarr, - name="HuntarrBackground", - daemon=True - ) - background_thread.start() + # Report status update to prevent timeout + self.ReportServiceStatus(win32service.SERVICE_START_PENDING) - # Start the web server in a thread - web_thread = threading.Thread( - target=lambda: serve(app, host='0.0.0.0', port=9705, threads=8), - name="HuntarrWebServer", - daemon=True - ) - web_thread.start() + # Make sure config directories exist before proceeding + try: + # Import config paths directly to avoid circular imports + from primary.utils.config_paths import ensure_directories + ensure_directories() + logger.info("Service verified config directories exist") + except Exception as e: + logger.error(f"Error ensuring config directories: {e}") + servicemanager.LogErrorMsg(f"Error ensuring directories: {e}") + # Create basic directories as fallback + try: + app_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + for dir_name in ['config', 'logs', 'config/stateful']: + os.makedirs(os.path.join(app_root, dir_name), exist_ok=True) + logger.info("Created basic directories as fallback") + except Exception as e2: + logger.error(f"Critical error creating directories: {e2}") + servicemanager.LogErrorMsg(f"Failed to create basic directories: {e2}") + return # Exit if we can't create basic directories + + # Start background tasks in a thread with better error handling + try: + background_thread = threading.Thread( + target=start_huntarr, + name="HuntarrBackground", + daemon=True + ) + background_thread.start() + logger.info("Started background thread successfully") + except Exception as e: + logger.error(f"Failed to start background thread: {e}") + servicemanager.LogErrorMsg(f"Failed to start background thread: {e}") + + # Start the web server in a thread with better error handling + try: + # One more status update - Windows needs to hear from us regularly during startup + self.ReportServiceStatus(win32service.SERVICE_START_PENDING) + + # Create and start the web server thread + web_thread = threading.Thread( + target=lambda: serve(app, host='0.0.0.0', port=9705, threads=4), # Reduced thread count for better stability + name="HuntarrWebServer", + daemon=True + ) + web_thread.start() + logger.info("Started web server thread successfully") + except Exception as e: + logger.error(f"Failed to start web server thread: {e}") + servicemanager.LogErrorMsg(f"Failed to start web server thread: {e}") + return # Exit service if web server can't start + + # Now finally report that service is running + self.ReportServiceStatus(win32service.SERVICE_RUNNING) logger.info('Huntarr service started successfully') + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, 'Service is running') + ) # Main service loop - keep running until stop event while self.is_running: @@ -150,24 +256,226 @@ def main(self): servicemanager.LogErrorMsg(f"Huntarr service error: {str(e)}") +def is_admin(): + """Check if the script is running with administrator privileges""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except: + return False + def install_service(): """Install Huntarr as a Windows service""" if sys.platform != 'win32': print("Windows service installation is only available on Windows.") return False + + import subprocess # For SC.EXE commands + + # Check for administrator privileges + if not is_admin(): + print("ERROR: Administrator privileges required to install the service.") + print("Please right-click on the installer or command prompt and select 'Run as administrator'.") + print("Alternatively, you can run Huntarr directly without service installation using '--no-service'.") + return False try: - win32serviceutil.InstallService( - pythonClassString="src.primary.windows_service.HuntarrService", - serviceName="Huntarr", - displayName="Huntarr Service", - description="Automated media collection management for Arr apps", - startType=win32service.SERVICE_AUTO_START - ) - print("Huntarr service installed successfully.") + # Set up logging for service installation + log_file = os.path.join(logs_dir, 'service_install.log') + os.makedirs(os.path.dirname(log_file), exist_ok=True) + with open(log_file, 'a') as f: + f.write(f"\n\n--- Service installation started at {time.ctime()} ---\n") + f.write(f"Python executable: {sys.executable}\n") + f.write(f"Current directory: {os.getcwd()}\n") + + # Get the executable path - simplify based on whether we're running from frozen exe + if getattr(sys, 'frozen', False): + # We're running from a PyInstaller bundle + exe_path = os.path.abspath(sys.executable) + logger.info(f"Using PyInstaller executable for service: {exe_path}") + + with open(log_file, 'a') as f: + f.write(f"Executable path: {exe_path}\n") + f.write(f"Executable exists: {os.path.exists(exe_path)}\n") + + # Verify the executable exists + if not os.path.exists(exe_path): + logger.error(f"ERROR: Executable not found at {exe_path}") + print(f"ERROR: Executable not found at {exe_path}") + return False + else: + # We're running as a Python script + exe_path = sys.executable + script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'main.py')) + logger.info(f"Using Python for service: {exe_path} with script {script_path}") + + with open(log_file, 'a') as f: + f.write(f"Python path: {exe_path}\n") + f.write(f"Script path: {script_path}\n") + f.write(f"Python exists: {os.path.exists(exe_path)}\n") + f.write(f"Script exists: {os.path.exists(script_path)}\n") + + if not os.path.exists(script_path): + logger.error(f"ERROR: Script not found at {script_path}") + print(f"ERROR: Script not found at {script_path}") + return False + python_exe = sys.executable + script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'main.py')) + + # Prepare service command - make it simple and reliable + if getattr(sys, 'frozen', False): + # We're running from a PyInstaller bundle - use direct path to executable + exe_cmd = f'"{exe_path}" --service' + with open(log_file, 'a') as f: + f.write(f"Service command (frozen): {exe_cmd}\n") + else: + # We're running as a Python script - use pythonw.exe to avoid console window + # Use pythonw for service to avoid console window + pythonw_exe = os.path.join(os.path.dirname(sys.executable), 'pythonw.exe') + if not os.path.exists(pythonw_exe): + pythonw_exe = sys.executable # Fall back to regular python if pythonw not found + + exe_cmd = f'"{pythonw_exe}" "{script_path}" --service' + with open(log_file, 'a') as f: + f.write(f"Service command (script): {exe_cmd}\n") + + # No need to manually remove the service as our batch file will handle it + # Just log that we'll be removing any existing service + logger.info("Batch file will remove any existing Huntarr service...") + with open(log_file, 'a') as f: + f.write("Will automatically remove any existing Huntarr service\n") + + with open(log_file, 'a') as f: + f.write("Attempting service installation...\n") + + # === DIRECT SC.EXE INSTALLATION - most reliable method === + try: + # Try a completely different approach - use windows-native batch script to create service + # This will avoid all the quoting hell with SC.exe commands + + # Create a temporary batch file to run the SC commands + batch_file = os.path.join(os.environ.get('TEMP', '.'), 'huntarr_service_install.bat') + with open(batch_file, 'w') as f: + if getattr(sys, 'frozen', False): + # For PyInstaller bundle + f.write(f'@echo off\r\n') + f.write(f'echo Installing Huntarr service...\r\n') + f.write(f'sc stop Huntarr\r\n') + f.write(f'sc delete Huntarr\r\n') + f.write(f'sc create Huntarr binPath= "{exe_path} --service" start= auto DisplayName= "Huntarr Service"\r\n') + f.write(f'sc description Huntarr "Automated media collection management for Arr apps"\r\n') + f.write(f'sc start Huntarr\r\n') + else: + # For Python script + f.write(f'@echo off\r\n') + f.write(f'echo Installing Huntarr service...\r\n') + f.write(f'sc stop Huntarr\r\n') + f.write(f'sc delete Huntarr\r\n') + f.write(f'sc create Huntarr binPath= "{sys.executable} \"{script_path}\" --service" start= auto DisplayName= "Huntarr Service"\r\n') + f.write(f'sc description Huntarr "Automated media collection management for Arr apps"\r\n') + f.write(f'sc start Huntarr\r\n') + + # Log the batch file contents + with open(log_file, 'a') as f: + f.write("Created batch file for service installation:\n") + with open(batch_file, 'r') as bf: + f.write(bf.read() + "\n") + + # Run the batch file + with open(log_file, 'a') as f: + f.write("Running service installation batch file...\n") + + result = subprocess.run(batch_file, shell=True, capture_output=True, text=True) + + # Log the results + with open(log_file, 'a') as f: + f.write(f"Batch execution result code: {result.returncode}\n") + if result.stdout: + f.write(f"STDOUT: {result.stdout}\n") + if result.stderr: + f.write(f"STDERR: {result.stderr}\n") + + # Clean up the batch file + try: + os.unlink(batch_file) + except: + pass + + # Check for errors + if result.returncode != 0: + raise Exception(f"Batch service installation failed with code {result.returncode}") + + # Log success + with open(log_file, 'a') as f: + f.write("Service created successfully through batch script\n") + + # Sleep briefly to give the service time to start + time.sleep(3) + + logger.info("Huntarr service installed successfully") + + # Quick check if service is now present + query_result = subprocess.run('sc query Huntarr', shell=True, capture_output=True, text=True) + with open(log_file, 'a') as f: + if "STATE" in query_result.stdout: + f.write(f"Service was created and is present in system\n") + f.write(query_result.stdout) + else: + f.write(f"NOTE: Service may not be present in system\n") + + print("Huntarr service was created successfully.") + + # We'll skip the manual starting here since the batch file already tries to start it + # The batch file approach combines service creation and starting + + except Exception as e: + logger.error(f"Service installation failed: {e}") + with open(log_file, 'a') as f: + f.write(f"SERVICE INSTALLATION FAILED: {e}\n") + print(f"Service installation failed: {e}") + return False + + # The batch file already tries to start the service, but we can check its status + try: + # Brief pause to allow service to start up + time.sleep(1) + + logger.info("Checking Huntarr service status...") + with open(log_file, 'a') as f: + f.write("Checking service status...\n") + + # Check service state + query_result = subprocess.run('sc query Huntarr', shell=True, capture_output=True, text=True) + is_running = "RUNNING" in query_result.stdout + + with open(log_file, 'a') as f: + f.write(f"Service query result:\n{query_result.stdout}\n") + + if is_running: + print("Huntarr service installed and started successfully!") + logger.info("Huntarr service is running") + with open(log_file, 'a') as f: + f.write("Service is running successfully!\n") + else: + # Service was created but may not be running yet + print("Service was created but might not be running yet.") + print("Options:") + print("1. Wait a moment and check Services control panel") + print("2. Try running Huntarr with the 'Run without service' option") + with open(log_file, 'a') as f: + f.write("Service installed but may not be running yet.\n") + except Exception as e: + # This is just for checking service status, not critical + logger.warning(f"Could not check service status: {e}") + with open(log_file, 'a') as f: + f.write(f"NOTE: Could not verify service status: {e}\n") + print("Service was installed but status could not be verified.") + print("You can check Services control panel or try the 'Run without service' option.") + return True except Exception as e: + logger.error(f"Error installing Huntarr service: {e}") print(f"Error installing Huntarr service: {e}") + print("Fallback: You can run Huntarr directly using the 'Run without service' option.") return False @@ -176,8 +484,23 @@ def remove_service(): if sys.platform != 'win32': print("Windows service removal is only available on Windows.") return False + + # Check for administrator privileges + if not is_admin(): + print("ERROR: Administrator privileges required to remove the service.") + print("Please right-click on the command prompt and select 'Run as administrator'.") + return False try: + # Stop the service first if it's running + try: + win32serviceutil.StopService("Huntarr") + print("Huntarr service stopped.") + time.sleep(2) # Wait for service to fully stop + except Exception as e: + print(f"Note: Could not stop the service (it may already be stopped): {e}") + + # Now remove the service win32serviceutil.RemoveService("Huntarr") print("Huntarr service removed successfully.") return True @@ -186,12 +509,51 @@ def remove_service(): return False +def run_as_cli(): + """Run Huntarr as a command-line application (non-service fallback)""" + try: + # Import required modules + import threading + from primary.background import start_huntarr, stop_event + from primary.web_server import app + from waitress import serve + + print("Starting Huntarr in command-line mode...") + + # Start background tasks in a thread + background_thread = threading.Thread( + target=start_huntarr, + name="HuntarrBackground", + daemon=True + ) + background_thread.start() + + # Run the web server directly (blocking) + print("Starting web server on http://localhost:9705") + print("Press Ctrl+C to stop") + serve(app, host='0.0.0.0', port=9705, threads=8) + except KeyboardInterrupt: + print("\nStopping Huntarr...") + if not stop_event.is_set(): + stop_event.set() + # Give threads time to clean up + time.sleep(2) + except Exception as e: + print(f"Error running Huntarr in command-line mode: {e}") + if __name__ == '__main__': if len(sys.argv) > 1: - if sys.argv[1] == 'install': + if sys.argv[1] == '--install-service' or sys.argv[1] == 'install': install_service() - elif sys.argv[1] == 'remove': + elif sys.argv[1] == '--remove-service' or sys.argv[1] == 'remove': remove_service() + elif sys.argv[1] == '--no-service' or sys.argv[1] == 'cli': + run_as_cli() + elif sys.argv[1] == '--service': + # Service mode - let win32serviceutil handle it directly + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(HuntarrService) + servicemanager.StartServiceCtrlDispatcher() else: win32serviceutil.HandleCommandLine(HuntarrService) else: diff --git a/version.txt b/version.txt index c60ebc18..ec997167 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -7.0.5 \ No newline at end of file +7.0.6 \ No newline at end of file