mirror of
https://github.com/plexguide/Huntarr-Sonarr.git
synced 2025-12-16 20:04:16 -06:00
inital 4 push
This commit is contained in:
16
.github/workflows/docker-image.yml
vendored
16
.github/workflows/docker-image.yml
vendored
@@ -61,8 +61,8 @@ jobs:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
huntarr/4sonarr:latest
|
||||
huntarr/4sonarr:${{ github.sha }}
|
||||
huntarr/huntarr:latest
|
||||
huntarr/huntarr:${{ github.sha }}
|
||||
|
||||
# 7b) Build & Push if on 'dev' branch
|
||||
- name: Build and Push (dev)
|
||||
@@ -73,8 +73,8 @@ jobs:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
huntarr/4sonarr:dev
|
||||
huntarr/4sonarr:${{ github.sha }}
|
||||
huntarr/huntarr:dev
|
||||
huntarr/huntarr:${{ github.sha }}
|
||||
|
||||
# 7c) Build & Push if it's a tag/release
|
||||
- name: Build and Push (release)
|
||||
@@ -85,8 +85,8 @@ jobs:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
huntarr/4sonarr:${{ steps.meta.outputs.VERSION }}
|
||||
huntarr/4sonarr:latest
|
||||
huntarr/huntarr:${{ steps.meta.outputs.VERSION }}
|
||||
huntarr/huntarr:latest
|
||||
|
||||
# 7d) Build & Push for any other branch
|
||||
- name: Build and Push (feature branch)
|
||||
@@ -97,8 +97,8 @@ jobs:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
huntarr/4sonarr:${{ steps.meta.outputs.BRANCH }}
|
||||
huntarr/4sonarr:${{ github.sha }}
|
||||
huntarr/huntarr:${{ steps.meta.outputs.BRANCH }}
|
||||
huntarr/huntarr:${{ github.sha }}
|
||||
|
||||
# 7e) Just build on pull requests
|
||||
- name: Build (PR)
|
||||
|
||||
45
Dockerfile
45
Dockerfile
@@ -1,40 +1,31 @@
|
||||
FROM python:3.9-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
COPY primary/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install Flask for the web interface
|
||||
RUN pip install --no-cache-dir flask
|
||||
|
||||
# Create directory structure
|
||||
RUN mkdir -p /app/primary /app/templates /app/static/css /app/static/js /config/stateful /config/settings /config/user
|
||||
|
||||
# Copy application files
|
||||
COPY *.py ./
|
||||
COPY utils/ ./utils/
|
||||
COPY web_server.py ./
|
||||
# Create templates directory and copy index.html
|
||||
RUN mkdir -p templates static/css static/js
|
||||
COPY primary/ ./primary/
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
# Create required directories
|
||||
RUN mkdir -p /config/stateful /config/settings
|
||||
# Default environment variables
|
||||
ENV API_KEY="your-api-key" \
|
||||
API_URL="http://your-sonarr-address:8989" \
|
||||
API_TIMEOUT="60" \
|
||||
HUNT_MISSING_SHOWS=1 \
|
||||
HUNT_UPGRADE_EPISODES=5 \
|
||||
SLEEP_DURATION=900 \
|
||||
STATE_RESET_INTERVAL_HOURS=168 \
|
||||
RANDOM_SELECTION="true" \
|
||||
MONITORED_ONLY="true" \
|
||||
DEBUG_MODE="false" \
|
||||
ENABLE_WEB_UI="true" \
|
||||
SKIP_FUTURE_EPISODES="true" \
|
||||
SKIP_SERIES_REFRESH="false"
|
||||
COPY primary/default_configs.json .
|
||||
|
||||
# Default environment variables (minimal set)
|
||||
ENV APP_TYPE="sonarr"
|
||||
|
||||
# Create volume mount points
|
||||
VOLUME ["/config"]
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 8988
|
||||
# Add startup script that conditionally starts the web UI
|
||||
COPY start.sh .
|
||||
|
||||
# Add startup script
|
||||
COPY primary/start.sh .
|
||||
RUN chmod +x start.sh
|
||||
# Run the startup script which will decide what to launch
|
||||
|
||||
# Run the startup script
|
||||
CMD ["./start.sh"]
|
||||
166
config.py
166
config.py
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration module for Huntarr-Sonarr
|
||||
Handles all environment variables and configuration settings
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import settings_manager
|
||||
|
||||
# Web UI Configuration
|
||||
ENABLE_WEB_UI = os.environ.get("ENABLE_WEB_UI", "true").lower() == "true"
|
||||
|
||||
# API Configuration
|
||||
API_KEY = os.environ.get("API_KEY", "your-api-key")
|
||||
API_URL = os.environ.get("API_URL", "http://your-sonarr-address:8989")
|
||||
|
||||
# API timeout in seconds - load from environment first, will be overridden by settings if they exist
|
||||
try:
|
||||
API_TIMEOUT = int(os.environ.get("API_TIMEOUT", "60"))
|
||||
except ValueError:
|
||||
API_TIMEOUT = 60
|
||||
print(f"Warning: Invalid API_TIMEOUT value, using default: {API_TIMEOUT}")
|
||||
|
||||
# Settings that can be overridden by the settings manager
|
||||
# Load from environment first, will be overridden by settings if they exist
|
||||
|
||||
# Missing Content Settings
|
||||
try:
|
||||
HUNT_MISSING_SHOWS = int(os.environ.get("HUNT_MISSING_SHOWS", "1"))
|
||||
except ValueError:
|
||||
HUNT_MISSING_SHOWS = 1
|
||||
print(f"Warning: Invalid HUNT_MISSING_SHOWS value, using default: {HUNT_MISSING_SHOWS}")
|
||||
|
||||
# Upgrade Settings
|
||||
try:
|
||||
HUNT_UPGRADE_EPISODES = int(os.environ.get("HUNT_UPGRADE_EPISODES", "5"))
|
||||
except ValueError:
|
||||
HUNT_UPGRADE_EPISODES = 5
|
||||
print(f"Warning: Invalid HUNT_UPGRADE_EPISODES value, using default: {HUNT_UPGRADE_EPISODES}")
|
||||
|
||||
# Sleep duration in seconds after completing one full cycle (default 15 minutes)
|
||||
try:
|
||||
SLEEP_DURATION = int(os.environ.get("SLEEP_DURATION", "900"))
|
||||
except ValueError:
|
||||
SLEEP_DURATION = 900
|
||||
print(f"Warning: Invalid SLEEP_DURATION value, using default: {SLEEP_DURATION}")
|
||||
|
||||
# Reset processed state file after this many hours (default 168 hours = 1 week)
|
||||
try:
|
||||
STATE_RESET_INTERVAL_HOURS = int(os.environ.get("STATE_RESET_INTERVAL_HOURS", "168"))
|
||||
except ValueError:
|
||||
STATE_RESET_INTERVAL_HOURS = 168
|
||||
print(f"Warning: Invalid STATE_RESET_INTERVAL_HOURS value, using default: {STATE_RESET_INTERVAL_HOURS}")
|
||||
|
||||
# Selection Settings
|
||||
RANDOM_SELECTION = os.environ.get("RANDOM_SELECTION", "true").lower() == "true"
|
||||
MONITORED_ONLY = os.environ.get("MONITORED_ONLY", "true").lower() == "true"
|
||||
|
||||
# New Options
|
||||
SKIP_FUTURE_EPISODES = os.environ.get("SKIP_FUTURE_EPISODES", "true").lower() == "true"
|
||||
SKIP_SERIES_REFRESH = os.environ.get("SKIP_SERIES_REFRESH", "false").lower() == "true"
|
||||
|
||||
# Advanced settings - load from environment first, will be overridden by settings if they exist
|
||||
try:
|
||||
COMMAND_WAIT_DELAY = int(os.environ.get("COMMAND_WAIT_DELAY", "1"))
|
||||
except ValueError:
|
||||
COMMAND_WAIT_DELAY = 1
|
||||
print(f"Warning: Invalid COMMAND_WAIT_DELAY value, using default: {COMMAND_WAIT_DELAY}")
|
||||
|
||||
# Number of attempts to wait for a command to complete before giving up (default 600 attempts)
|
||||
try:
|
||||
COMMAND_WAIT_ATTEMPTS = int(os.environ.get("COMMAND_WAIT_ATTEMPTS", "600"))
|
||||
except ValueError:
|
||||
COMMAND_WAIT_ATTEMPTS = 600
|
||||
print(f"Warning: Invalid COMMAND_WAIT_ATTEMPTS value, using default: {COMMAND_WAIT_ATTEMPTS}")
|
||||
|
||||
# Minimum size of the download queue before starting a hunt (default -1)
|
||||
try:
|
||||
MINIMUM_DOWNLOAD_QUEUE_SIZE = int(os.environ.get("MINIMUM_DOWNLOAD_QUEUE_SIZE", "-1"))
|
||||
except ValueError:
|
||||
MINIMUM_DOWNLOAD_QUEUE_SIZE = -1
|
||||
print(f"Warning: Invalid MINIMUM_DOWNLOAD_QUEUE_SIZE value, using default: {MINIMUM_DOWNLOAD_QUEUE_SIZE}")
|
||||
|
||||
# Debug Settings
|
||||
DEBUG_MODE = os.environ.get("DEBUG_MODE", "false").lower() == "true"
|
||||
|
||||
# Random selection for missing and upgrades - default to RANDOM_SELECTION for backward compatibility
|
||||
# These can be overridden by environment variables or settings
|
||||
RANDOM_MISSING = os.environ.get("RANDOM_MISSING", str(RANDOM_SELECTION)).lower() == "true"
|
||||
RANDOM_UPGRADES = os.environ.get("RANDOM_UPGRADES", str(RANDOM_SELECTION)).lower() == "true"
|
||||
|
||||
# Hunt mode: "missing", "upgrade", or "both"
|
||||
HUNT_MODE = os.environ.get("HUNT_MODE", "both")
|
||||
|
||||
def refresh_settings():
|
||||
"""Refresh configuration settings from the settings manager."""
|
||||
global HUNT_MISSING_SHOWS, HUNT_UPGRADE_EPISODES, SLEEP_DURATION
|
||||
global STATE_RESET_INTERVAL_HOURS, MONITORED_ONLY, RANDOM_SELECTION
|
||||
global SKIP_FUTURE_EPISODES, SKIP_SERIES_REFRESH
|
||||
global API_TIMEOUT, DEBUG_MODE, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS
|
||||
global MINIMUM_DOWNLOAD_QUEUE_SIZE, RANDOM_MISSING, RANDOM_UPGRADES
|
||||
|
||||
# Load settings directly from settings manager
|
||||
settings = settings_manager.get_all_settings()
|
||||
huntarr_settings = settings.get("huntarr", {})
|
||||
advanced_settings = settings.get("advanced", {})
|
||||
|
||||
# Update global variables with fresh values
|
||||
HUNT_MISSING_SHOWS = huntarr_settings.get("hunt_missing_shows", HUNT_MISSING_SHOWS)
|
||||
HUNT_UPGRADE_EPISODES = huntarr_settings.get("hunt_upgrade_episodes", HUNT_UPGRADE_EPISODES)
|
||||
SLEEP_DURATION = huntarr_settings.get("sleep_duration", SLEEP_DURATION)
|
||||
STATE_RESET_INTERVAL_HOURS = huntarr_settings.get("state_reset_interval_hours", STATE_RESET_INTERVAL_HOURS)
|
||||
MONITORED_ONLY = huntarr_settings.get("monitored_only", MONITORED_ONLY)
|
||||
RANDOM_SELECTION = huntarr_settings.get("random_selection", RANDOM_SELECTION)
|
||||
SKIP_FUTURE_EPISODES = huntarr_settings.get("skip_future_episodes", SKIP_FUTURE_EPISODES)
|
||||
SKIP_SERIES_REFRESH = huntarr_settings.get("skip_series_refresh", SKIP_SERIES_REFRESH)
|
||||
|
||||
# Advanced settings
|
||||
API_TIMEOUT = advanced_settings.get("api_timeout", API_TIMEOUT)
|
||||
DEBUG_MODE = advanced_settings.get("debug_mode", DEBUG_MODE)
|
||||
COMMAND_WAIT_DELAY = advanced_settings.get("command_wait_delay", COMMAND_WAIT_DELAY)
|
||||
COMMAND_WAIT_ATTEMPTS = advanced_settings.get("command_wait_attempts", COMMAND_WAIT_ATTEMPTS)
|
||||
MINIMUM_DOWNLOAD_QUEUE_SIZE = advanced_settings.get("minimum_download_queue_size", MINIMUM_DOWNLOAD_QUEUE_SIZE)
|
||||
|
||||
# Get the specific random settings - default to RANDOM_SELECTION for backward compatibility
|
||||
# but only if not explicitly set in the advanced settings
|
||||
if "random_missing" in advanced_settings:
|
||||
RANDOM_MISSING = advanced_settings.get("random_missing")
|
||||
else:
|
||||
RANDOM_MISSING = RANDOM_SELECTION
|
||||
|
||||
if "random_upgrades" in advanced_settings:
|
||||
RANDOM_UPGRADES = advanced_settings.get("random_upgrades")
|
||||
else:
|
||||
RANDOM_UPGRADES = RANDOM_SELECTION
|
||||
|
||||
# Log the refresh for debugging
|
||||
import logging
|
||||
logger = logging.getLogger("huntarr-sonarr")
|
||||
logger.debug(f"Settings refreshed: SLEEP_DURATION={SLEEP_DURATION}, HUNT_MISSING_SHOWS={HUNT_MISSING_SHOWS}")
|
||||
logger.debug(f"Advanced settings refreshed: API_TIMEOUT={API_TIMEOUT}, DEBUG_MODE={DEBUG_MODE}")
|
||||
logger.debug(f"Random settings: RANDOM_SELECTION={RANDOM_SELECTION}, RANDOM_MISSING={RANDOM_MISSING}, RANDOM_UPGRADES={RANDOM_UPGRADES}")
|
||||
|
||||
def log_configuration(logger):
|
||||
"""Log the current configuration settings"""
|
||||
# Refresh settings from the settings manager
|
||||
refresh_settings()
|
||||
|
||||
logger.info("=== Huntarr [Sonarr Edition] Starting ===")
|
||||
logger.info(f"API URL: {API_URL}")
|
||||
logger.info(f"API Timeout: {API_TIMEOUT}s")
|
||||
logger.info(f"Missing Content Configuration: HUNT_MISSING_SHOWS={HUNT_MISSING_SHOWS}")
|
||||
logger.info(f"Upgrade Configuration: HUNT_UPGRADE_EPISODES={HUNT_UPGRADE_EPISODES}")
|
||||
logger.info(f"State Reset Interval: {STATE_RESET_INTERVAL_HOURS} hours")
|
||||
logger.info(f"Minimum Download Queue Size: {MINIMUM_DOWNLOAD_QUEUE_SIZE}")
|
||||
logger.info(f"MONITORED_ONLY={MONITORED_ONLY}, RANDOM_SELECTION={RANDOM_SELECTION}")
|
||||
logger.info(f"RANDOM_MISSING={RANDOM_MISSING}, RANDOM_UPGRADES={RANDOM_UPGRADES}")
|
||||
logger.info(f"HUNT_MODE={HUNT_MODE}, SLEEP_DURATION={SLEEP_DURATION}s")
|
||||
logger.info(f"COMMAND_WAIT_DELAY={COMMAND_WAIT_DELAY}, COMMAND_WAIT_ATTEMPTS={COMMAND_WAIT_ATTEMPTS}")
|
||||
logger.info(f"SKIP_FUTURE_EPISODES={SKIP_FUTURE_EPISODES}, SKIP_SERIES_REFRESH={SKIP_SERIES_REFRESH}")
|
||||
logger.info(f"ENABLE_WEB_UI={ENABLE_WEB_UI}, DEBUG_MODE={DEBUG_MODE}")
|
||||
logger.debug(f"API_KEY={API_KEY}")
|
||||
|
||||
# Initial refresh of settings
|
||||
refresh_settings()
|
||||
54
default_configs.json
Normal file
54
default_configs.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"sonarr": {
|
||||
"api_timeout": 60,
|
||||
"monitored_only": true,
|
||||
"hunt_missing_shows": 1,
|
||||
"hunt_upgrade_episodes": 0,
|
||||
"sleep_duration": 900,
|
||||
"state_reset_interval_hours": 168,
|
||||
"debug_mode": false,
|
||||
"skip_future_episodes": true,
|
||||
"skip_series_refresh": false,
|
||||
"command_wait_delay": 1,
|
||||
"command_wait_attempts": 600,
|
||||
"minimum_download_queue_size": -1,
|
||||
"random_missing": true,
|
||||
"random_upgrades": true
|
||||
},
|
||||
"radarr": {
|
||||
"api_timeout": 60,
|
||||
"monitored_only": true,
|
||||
"sleep_duration": 900,
|
||||
"state_reset_interval_hours": 168,
|
||||
"debug_mode": false,
|
||||
"command_wait_delay": 1,
|
||||
"command_wait_attempts": 600,
|
||||
"minimum_download_queue_size": -1,
|
||||
"random_missing": true,
|
||||
"random_upgrades": true
|
||||
},
|
||||
"lidarr": {
|
||||
"api_timeout": 60,
|
||||
"monitored_only": true,
|
||||
"sleep_duration": 900,
|
||||
"state_reset_interval_hours": 168,
|
||||
"debug_mode": false,
|
||||
"command_wait_delay": 1,
|
||||
"command_wait_attempts": 600,
|
||||
"minimum_download_queue_size": -1,
|
||||
"random_missing": true,
|
||||
"random_upgrades": true
|
||||
},
|
||||
"readarr": {
|
||||
"api_timeout": 60,
|
||||
"monitored_only": true,
|
||||
"sleep_duration": 900,
|
||||
"state_reset_interval_hours": 168,
|
||||
"debug_mode": false,
|
||||
"command_wait_delay": 1,
|
||||
"command_wait_attempts": 600,
|
||||
"minimum_download_queue_size": -1,
|
||||
"random_missing": true,
|
||||
"random_upgrades": true
|
||||
}
|
||||
}
|
||||
6
primary/__init__.py
Normal file
6
primary/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Huntarr - Find Missing & Upgrade Media Items
|
||||
A unified tool for Sonarr, Radarr, Lidarr, and Readarr
|
||||
"""
|
||||
|
||||
__version__ = "4.0.0"
|
||||
323
primary/auth.py
Normal file
323
primary/auth.py
Normal file
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Authentication module for Huntarr
|
||||
Handles user creation, verification, and session management
|
||||
Including two-factor authentication
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import pathlib
|
||||
import base64
|
||||
import io
|
||||
import qrcode
|
||||
import pyotp
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from flask import request, redirect, url_for, session
|
||||
|
||||
# User directory setup
|
||||
USER_DIR = pathlib.Path("/config/user")
|
||||
USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||
USER_FILE = USER_DIR / "credentials.json"
|
||||
|
||||
# Session settings
|
||||
SESSION_EXPIRY = 60 * 60 * 24 * 7 # 1 week in seconds
|
||||
SESSION_COOKIE_NAME = "huntarr_session"
|
||||
|
||||
# Store active sessions
|
||||
active_sessions = {}
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password for storage"""
|
||||
# Use SHA-256 with a salt
|
||||
salt = secrets.token_hex(16)
|
||||
pw_hash = hashlib.sha256((password + salt).encode()).hexdigest()
|
||||
return f"{salt}:{pw_hash}"
|
||||
|
||||
def verify_password(stored_password: str, provided_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
try:
|
||||
salt, pw_hash = stored_password.split(':', 1)
|
||||
verify_hash = hashlib.sha256((provided_password + salt).encode()).hexdigest()
|
||||
return secrets.compare_digest(verify_hash, pw_hash)
|
||||
except:
|
||||
return False
|
||||
|
||||
def hash_username(username: str) -> str:
|
||||
"""Create a normalized hash of the username"""
|
||||
# Convert to lowercase and hash
|
||||
return hashlib.sha256(username.lower().encode()).hexdigest()
|
||||
|
||||
def user_exists() -> bool:
|
||||
"""Check if a user has been created"""
|
||||
return USER_FILE.exists()
|
||||
|
||||
def create_user(username: str, password: str) -> bool:
|
||||
"""Create a new user"""
|
||||
if not username or not password:
|
||||
return False
|
||||
|
||||
# Hash the username and password
|
||||
username_hash = hash_username(username)
|
||||
password_hash = hash_password(password)
|
||||
|
||||
# Store the credentials
|
||||
user_data = {
|
||||
"username": username_hash,
|
||||
"password": password_hash,
|
||||
"created_at": time.time(),
|
||||
"2fa_enabled": False,
|
||||
"2fa_secret": None
|
||||
}
|
||||
|
||||
try:
|
||||
with open(USER_FILE, 'w') as f:
|
||||
json.dump(user_data, f)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error creating user: {e}")
|
||||
return False
|
||||
|
||||
def verify_user(username: str, password: str, otp_code: str = None) -> Tuple[bool, bool]:
|
||||
"""
|
||||
Verify user credentials
|
||||
|
||||
Returns:
|
||||
Tuple[bool, bool]: (auth_success, needs_2fa)
|
||||
"""
|
||||
if not user_exists():
|
||||
return False, False
|
||||
|
||||
try:
|
||||
with open(USER_FILE, 'r') as f:
|
||||
user_data = json.load(f)
|
||||
|
||||
# Hash the provided username
|
||||
username_hash = hash_username(username)
|
||||
|
||||
# Compare username and verify password
|
||||
if user_data.get("username") == username_hash:
|
||||
if verify_password(user_data.get("password", ""), password):
|
||||
# Check if 2FA is enabled
|
||||
if user_data.get("2fa_enabled", False):
|
||||
# If 2FA code was provided, verify it
|
||||
if otp_code:
|
||||
totp = pyotp.TOTP(user_data.get("2fa_secret"))
|
||||
if totp.verify(otp_code):
|
||||
return True, False
|
||||
else:
|
||||
return False, True
|
||||
else:
|
||||
# No OTP code provided but 2FA is enabled
|
||||
return False, True
|
||||
else:
|
||||
# 2FA not enabled, password is correct
|
||||
return True, False
|
||||
except Exception as e:
|
||||
print(f"Error verifying user: {e}")
|
||||
|
||||
return False, False
|
||||
|
||||
def create_session(username: str) -> str:
|
||||
"""Create a new session for an authenticated user"""
|
||||
session_id = secrets.token_hex(32)
|
||||
username_hash = hash_username(username)
|
||||
|
||||
# Store session data
|
||||
active_sessions[session_id] = {
|
||||
"username": username_hash,
|
||||
"created_at": time.time(),
|
||||
"expires_at": time.time() + SESSION_EXPIRY
|
||||
}
|
||||
|
||||
return session_id
|
||||
|
||||
def verify_session(session_id: str) -> bool:
|
||||
"""Verify if a session is valid"""
|
||||
if not session_id or session_id not in active_sessions:
|
||||
return False
|
||||
|
||||
session_data = active_sessions[session_id]
|
||||
|
||||
# Check if session has expired
|
||||
if session_data.get("expires_at", 0) < time.time():
|
||||
# Clean up expired session
|
||||
del active_sessions[session_id]
|
||||
return False
|
||||
|
||||
# Extend session expiry
|
||||
active_sessions[session_id]["expires_at"] = time.time() + SESSION_EXPIRY
|
||||
return True
|
||||
|
||||
def get_username_from_session(session_id: str) -> Optional[str]:
|
||||
"""Get the username hash from a session"""
|
||||
if not session_id or session_id not in active_sessions:
|
||||
return None
|
||||
|
||||
return active_sessions[session_id].get("username")
|
||||
|
||||
def authenticate_request():
|
||||
"""Flask route decorator to check if user is authenticated"""
|
||||
# If no user exists, redirect to setup
|
||||
if not user_exists():
|
||||
if request.path != "/setup" and not request.path.startswith(("/static/", "/api/setup")):
|
||||
return redirect("/setup")
|
||||
return None
|
||||
|
||||
# Skip authentication for static files and the login page
|
||||
if request.path.startswith(("/static/", "/login", "/api/login")) or request.path == "/favicon.ico":
|
||||
return None
|
||||
|
||||
# Check for valid session
|
||||
session_id = session.get(SESSION_COOKIE_NAME)
|
||||
if session_id and verify_session(session_id):
|
||||
return None
|
||||
|
||||
# No valid session, redirect to login
|
||||
if request.path != "/login" and not request.path.startswith("/api/"):
|
||||
return redirect("/login")
|
||||
|
||||
# For API calls, return 401 Unauthorized
|
||||
if request.path.startswith("/api/"):
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
return None
|
||||
|
||||
def logout():
|
||||
"""Log out the current user by invalidating their session"""
|
||||
session_id = session.get(SESSION_COOKIE_NAME)
|
||||
if session_id and session_id in active_sessions:
|
||||
del active_sessions[session_id]
|
||||
|
||||
# Clear the session cookie
|
||||
session.pop(SESSION_COOKIE_NAME, None)
|
||||
|
||||
def get_user_data() -> Dict:
|
||||
"""Get the user data from the credentials file"""
|
||||
if not user_exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(USER_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error reading user data: {e}")
|
||||
return {}
|
||||
|
||||
def save_user_data(user_data: Dict) -> bool:
|
||||
"""Save the user data to the credentials file"""
|
||||
try:
|
||||
with open(USER_FILE, 'w') as f:
|
||||
json.dump(user_data, f)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error saving user data: {e}")
|
||||
return False
|
||||
|
||||
def is_2fa_enabled() -> bool:
|
||||
"""Check if 2FA is enabled for the current user"""
|
||||
user_data = get_user_data()
|
||||
return user_data.get("2fa_enabled", False)
|
||||
|
||||
def generate_2fa_secret() -> Tuple[str, str]:
|
||||
"""
|
||||
Generate a new 2FA secret and QR code
|
||||
|
||||
Returns:
|
||||
Tuple[str, str]: (secret, qr_code_url)
|
||||
"""
|
||||
# Generate a random secret
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
# Create a TOTP object
|
||||
totp = pyotp.TOTP(secret)
|
||||
|
||||
# Get the provisioning URI
|
||||
uri = totp.provisioning_uri(name="Huntarr", issuer_name="Huntarr")
|
||||
|
||||
# Generate QR code
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to base64 string
|
||||
buffered = io.BytesIO()
|
||||
img.save(buffered)
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||
|
||||
# Store the secret temporarily
|
||||
user_data = get_user_data()
|
||||
user_data["temp_2fa_secret"] = secret
|
||||
save_user_data(user_data)
|
||||
|
||||
return secret, f"data:image/png;base64,{img_str}"
|
||||
|
||||
def verify_2fa_code(code: str) -> bool:
|
||||
"""Verify a 2FA code against the temporary secret"""
|
||||
user_data = get_user_data()
|
||||
temp_secret = user_data.get("temp_2fa_secret")
|
||||
|
||||
if not temp_secret:
|
||||
return False
|
||||
|
||||
totp = pyotp.TOTP(temp_secret)
|
||||
if totp.verify(code):
|
||||
# Enable 2FA
|
||||
user_data["2fa_enabled"] = True
|
||||
user_data["2fa_secret"] = temp_secret
|
||||
user_data.pop("temp_2fa_secret", None)
|
||||
save_user_data(user_data)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def disable_2fa(password: str) -> bool:
|
||||
"""Disable 2FA for the current user"""
|
||||
user_data = get_user_data()
|
||||
|
||||
# Verify password
|
||||
if verify_password(user_data.get("password", ""), password):
|
||||
user_data["2fa_enabled"] = False
|
||||
user_data["2fa_secret"] = None
|
||||
save_user_data(user_data)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def change_username(current_username: str, new_username: str, password: str) -> bool:
|
||||
"""Change the username for the current user"""
|
||||
user_data = get_user_data()
|
||||
|
||||
# Verify current username and password
|
||||
current_username_hash = hash_username(current_username)
|
||||
if user_data.get("username") != current_username_hash:
|
||||
return False
|
||||
|
||||
if not verify_password(user_data.get("password", ""), password):
|
||||
return False
|
||||
|
||||
# Update username
|
||||
user_data["username"] = hash_username(new_username)
|
||||
return save_user_data(user_data)
|
||||
|
||||
def change_password(current_password: str, new_password: str) -> bool:
|
||||
"""Change the password for the current user"""
|
||||
user_data = get_user_data()
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(user_data.get("password", ""), current_password):
|
||||
return False
|
||||
|
||||
# Update password
|
||||
user_data["password"] = hash_password(new_password)
|
||||
return save_user_data(user_data)
|
||||
210
primary/config.py
Normal file
210
primary/config.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration module for Huntarr
|
||||
Handles all configuration settings with defaults
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from primary import settings_manager
|
||||
|
||||
# Get app type
|
||||
APP_TYPE = settings_manager.get_app_type()
|
||||
|
||||
# API Configuration
|
||||
API_KEY = settings_manager.get_api_key()
|
||||
API_URL = settings_manager.get_api_url()
|
||||
|
||||
# Web UI is always enabled
|
||||
ENABLE_WEB_UI = True
|
||||
|
||||
# Base settings common to all apps
|
||||
API_TIMEOUT = settings_manager.get_setting("advanced", "api_timeout", 60)
|
||||
DEBUG_MODE = settings_manager.get_setting("advanced", "debug_mode", False)
|
||||
COMMAND_WAIT_DELAY = settings_manager.get_setting("advanced", "command_wait_delay", 1)
|
||||
COMMAND_WAIT_ATTEMPTS = settings_manager.get_setting("advanced", "command_wait_attempts", 600)
|
||||
MINIMUM_DOWNLOAD_QUEUE_SIZE = settings_manager.get_setting("advanced", "minimum_download_queue_size", -1)
|
||||
MONITORED_ONLY = settings_manager.get_setting("huntarr", "monitored_only", True)
|
||||
SLEEP_DURATION = settings_manager.get_setting("huntarr", "sleep_duration", 900)
|
||||
STATE_RESET_INTERVAL_HOURS = settings_manager.get_setting("huntarr", "state_reset_interval_hours", 168)
|
||||
RANDOM_MISSING = settings_manager.get_setting("advanced", "random_missing", True)
|
||||
RANDOM_UPGRADES = settings_manager.get_setting("advanced", "random_upgrades", True)
|
||||
|
||||
# App-specific settings based on APP_TYPE
|
||||
if APP_TYPE == "sonarr":
|
||||
HUNT_MISSING_SHOWS = settings_manager.get_setting("huntarr", "hunt_missing_shows", 1)
|
||||
HUNT_UPGRADE_EPISODES = settings_manager.get_setting("huntarr", "hunt_upgrade_episodes", 0)
|
||||
SKIP_FUTURE_EPISODES = settings_manager.get_setting("huntarr", "skip_future_episodes", True)
|
||||
SKIP_SERIES_REFRESH = settings_manager.get_setting("huntarr", "skip_series_refresh", False)
|
||||
|
||||
elif APP_TYPE == "radarr":
|
||||
HUNT_MISSING_MOVIES = settings_manager.get_setting("huntarr", "hunt_missing_movies", 1)
|
||||
HUNT_UPGRADE_MOVIES = settings_manager.get_setting("huntarr", "hunt_upgrade_movies", 0)
|
||||
SKIP_FUTURE_RELEASES = settings_manager.get_setting("huntarr", "skip_future_releases", True)
|
||||
SKIP_MOVIE_REFRESH = settings_manager.get_setting("huntarr", "skip_movie_refresh", False)
|
||||
|
||||
elif APP_TYPE == "lidarr":
|
||||
HUNT_MISSING_ALBUMS = settings_manager.get_setting("huntarr", "hunt_missing_albums", 1)
|
||||
HUNT_UPGRADE_TRACKS = settings_manager.get_setting("huntarr", "hunt_upgrade_tracks", 0)
|
||||
SKIP_FUTURE_RELEASES = settings_manager.get_setting("huntarr", "skip_future_releases", True)
|
||||
SKIP_ARTIST_REFRESH = settings_manager.get_setting("huntarr", "skip_artist_refresh", False)
|
||||
|
||||
elif APP_TYPE == "readarr":
|
||||
HUNT_MISSING_BOOKS = settings_manager.get_setting("huntarr", "hunt_missing_books", 1)
|
||||
HUNT_UPGRADE_BOOKS = settings_manager.get_setting("huntarr", "hunt_upgrade_books", 0)
|
||||
SKIP_FUTURE_RELEASES = settings_manager.get_setting("huntarr", "skip_future_releases", True)
|
||||
SKIP_AUTHOR_REFRESH = settings_manager.get_setting("huntarr", "skip_author_refresh", False)
|
||||
|
||||
# For backward compatibility with Sonarr (the initial implementation)
|
||||
if APP_TYPE != "sonarr":
|
||||
# Add Sonarr specific variables for backward compatibility
|
||||
HUNT_MISSING_SHOWS = 0
|
||||
HUNT_UPGRADE_EPISODES = 0
|
||||
SKIP_FUTURE_EPISODES = True
|
||||
SKIP_SERIES_REFRESH = False
|
||||
|
||||
# Determine hunt mode
|
||||
HUNT_MODE = "both" # Default
|
||||
|
||||
def refresh_settings():
|
||||
"""Refresh configuration settings from the settings manager."""
|
||||
global API_KEY, API_URL, APP_TYPE
|
||||
global API_TIMEOUT, DEBUG_MODE, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS
|
||||
global MINIMUM_DOWNLOAD_QUEUE_SIZE, MONITORED_ONLY, SLEEP_DURATION
|
||||
global STATE_RESET_INTERVAL_HOURS, RANDOM_MISSING, RANDOM_UPGRADES
|
||||
global HUNT_MODE
|
||||
|
||||
# Force reload all settings
|
||||
settings = settings_manager.get_all_settings()
|
||||
|
||||
# Common settings
|
||||
API_KEY = settings.get("api_key", API_KEY)
|
||||
API_URL = settings.get("api_url", API_URL)
|
||||
APP_TYPE = settings.get("app_type", APP_TYPE)
|
||||
|
||||
# Advanced settings
|
||||
advanced = settings.get("advanced", {})
|
||||
API_TIMEOUT = advanced.get("api_timeout", API_TIMEOUT)
|
||||
DEBUG_MODE = advanced.get("debug_mode", DEBUG_MODE)
|
||||
COMMAND_WAIT_DELAY = advanced.get("command_wait_delay", COMMAND_WAIT_DELAY)
|
||||
COMMAND_WAIT_ATTEMPTS = advanced.get("command_wait_attempts", COMMAND_WAIT_ATTEMPTS)
|
||||
MINIMUM_DOWNLOAD_QUEUE_SIZE = advanced.get("minimum_download_queue_size", MINIMUM_DOWNLOAD_QUEUE_SIZE)
|
||||
RANDOM_MISSING = advanced.get("random_missing", RANDOM_MISSING)
|
||||
RANDOM_UPGRADES = advanced.get("random_upgrades", RANDOM_UPGRADES)
|
||||
|
||||
# Huntarr settings
|
||||
huntarr = settings.get("huntarr", {})
|
||||
MONITORED_ONLY = huntarr.get("monitored_only", MONITORED_ONLY)
|
||||
SLEEP_DURATION = huntarr.get("sleep_duration", SLEEP_DURATION)
|
||||
STATE_RESET_INTERVAL_HOURS = huntarr.get("state_reset_interval_hours", STATE_RESET_INTERVAL_HOURS)
|
||||
|
||||
# App-specific settings refresh
|
||||
if APP_TYPE == "sonarr":
|
||||
global HUNT_MISSING_SHOWS, HUNT_UPGRADE_EPISODES, SKIP_FUTURE_EPISODES, SKIP_SERIES_REFRESH
|
||||
HUNT_MISSING_SHOWS = huntarr.get("hunt_missing_shows", HUNT_MISSING_SHOWS)
|
||||
HUNT_UPGRADE_EPISODES = huntarr.get("hunt_upgrade_episodes", HUNT_UPGRADE_EPISODES)
|
||||
SKIP_FUTURE_EPISODES = huntarr.get("skip_future_episodes", SKIP_FUTURE_EPISODES)
|
||||
SKIP_SERIES_REFRESH = huntarr.get("skip_series_refresh", SKIP_SERIES_REFRESH)
|
||||
|
||||
elif APP_TYPE == "radarr":
|
||||
global HUNT_MISSING_MOVIES, HUNT_UPGRADE_MOVIES, SKIP_FUTURE_RELEASES, SKIP_MOVIE_REFRESH
|
||||
HUNT_MISSING_MOVIES = huntarr.get("hunt_missing_movies", HUNT_MISSING_MOVIES)
|
||||
HUNT_UPGRADE_MOVIES = huntarr.get("hunt_upgrade_movies", HUNT_UPGRADE_MOVIES)
|
||||
SKIP_FUTURE_RELEASES = huntarr.get("skip_future_releases", SKIP_FUTURE_RELEASES)
|
||||
SKIP_MOVIE_REFRESH = huntarr.get("skip_movie_refresh", SKIP_MOVIE_REFRESH)
|
||||
|
||||
elif APP_TYPE == "lidarr":
|
||||
global HUNT_MISSING_ALBUMS, HUNT_UPGRADE_TRACKS, SKIP_ARTIST_REFRESH
|
||||
HUNT_MISSING_ALBUMS = huntarr.get("hunt_missing_albums", HUNT_MISSING_ALBUMS)
|
||||
HUNT_UPGRADE_TRACKS = huntarr.get("hunt_upgrade_tracks", HUNT_UPGRADE_TRACKS)
|
||||
SKIP_FUTURE_RELEASES = huntarr.get("skip_future_releases", SKIP_FUTURE_RELEASES)
|
||||
SKIP_ARTIST_REFRESH = huntarr.get("skip_artist_refresh", SKIP_ARTIST_REFRESH)
|
||||
|
||||
elif APP_TYPE == "readarr":
|
||||
global HUNT_MISSING_BOOKS, HUNT_UPGRADE_BOOKS, SKIP_AUTHOR_REFRESH
|
||||
HUNT_MISSING_BOOKS = huntarr.get("hunt_missing_books", HUNT_MISSING_BOOKS)
|
||||
HUNT_UPGRADE_BOOKS = huntarr.get("hunt_upgrade_books", HUNT_UPGRADE_BOOKS)
|
||||
SKIP_FUTURE_RELEASES = huntarr.get("skip_future_releases", SKIP_FUTURE_RELEASES)
|
||||
SKIP_AUTHOR_REFRESH = huntarr.get("skip_author_refresh", SKIP_AUTHOR_REFRESH)
|
||||
|
||||
# Determine hunt mode based on settings
|
||||
if APP_TYPE == "sonarr":
|
||||
if HUNT_MISSING_SHOWS > 0 and HUNT_UPGRADE_EPISODES > 0:
|
||||
HUNT_MODE = "both"
|
||||
elif HUNT_MISSING_SHOWS > 0:
|
||||
HUNT_MODE = "missing"
|
||||
elif HUNT_UPGRADE_EPISODES > 0:
|
||||
HUNT_MODE = "upgrade"
|
||||
else:
|
||||
HUNT_MODE = "none"
|
||||
elif APP_TYPE == "radarr":
|
||||
if HUNT_MISSING_MOVIES > 0 and HUNT_UPGRADE_MOVIES > 0:
|
||||
HUNT_MODE = "both"
|
||||
elif HUNT_MISSING_MOVIES > 0:
|
||||
HUNT_MODE = "missing"
|
||||
elif HUNT_UPGRADE_MOVIES > 0:
|
||||
HUNT_MODE = "upgrade"
|
||||
else:
|
||||
HUNT_MODE = "none"
|
||||
elif APP_TYPE == "lidarr":
|
||||
if HUNT_MISSING_ALBUMS > 0 and HUNT_UPGRADE_TRACKS > 0:
|
||||
HUNT_MODE = "both"
|
||||
elif HUNT_MISSING_ALBUMS > 0:
|
||||
HUNT_MODE = "missing"
|
||||
elif HUNT_UPGRADE_TRACKS > 0:
|
||||
HUNT_MODE = "upgrade"
|
||||
else:
|
||||
HUNT_MODE = "none"
|
||||
elif APP_TYPE == "readarr":
|
||||
if HUNT_MISSING_BOOKS > 0 and HUNT_UPGRADE_BOOKS > 0:
|
||||
HUNT_MODE = "both"
|
||||
elif HUNT_MISSING_BOOKS > 0:
|
||||
HUNT_MODE = "missing"
|
||||
elif HUNT_UPGRADE_BOOKS > 0:
|
||||
HUNT_MODE = "upgrade"
|
||||
else:
|
||||
HUNT_MODE = "none"
|
||||
|
||||
# Log the refresh
|
||||
import logging
|
||||
logger = logging.getLogger("huntarr")
|
||||
logger.debug(f"Settings refreshed for app type: {APP_TYPE}")
|
||||
logger.debug(f"Settings refreshed: HUNT_MODE={HUNT_MODE}, SLEEP_DURATION={SLEEP_DURATION}")
|
||||
|
||||
def log_configuration(logger):
|
||||
"""Log the current configuration settings"""
|
||||
# Refresh settings from the settings manager
|
||||
refresh_settings()
|
||||
|
||||
logger.info(f"=== Huntarr [{APP_TYPE.title()} Edition] Starting ===")
|
||||
logger.info(f"API URL: {API_URL}")
|
||||
logger.info(f"API Timeout: {API_TIMEOUT}s")
|
||||
|
||||
# App-specific logging
|
||||
if APP_TYPE == "sonarr":
|
||||
logger.info(f"Missing Content Configuration: HUNT_MISSING_SHOWS={HUNT_MISSING_SHOWS}")
|
||||
logger.info(f"Upgrade Configuration: HUNT_UPGRADE_EPISODES={HUNT_UPGRADE_EPISODES}")
|
||||
logger.info(f"SKIP_FUTURE_EPISODES={SKIP_FUTURE_EPISODES}, SKIP_SERIES_REFRESH={SKIP_SERIES_REFRESH}")
|
||||
elif APP_TYPE == "radarr":
|
||||
logger.info(f"Missing Content Configuration: HUNT_MISSING_MOVIES={HUNT_MISSING_MOVIES}")
|
||||
logger.info(f"Upgrade Configuration: HUNT_UPGRADE_MOVIES={HUNT_UPGRADE_MOVIES}")
|
||||
logger.info(f"SKIP_FUTURE_RELEASES={SKIP_FUTURE_RELEASES}, SKIP_MOVIE_REFRESH={SKIP_MOVIE_REFRESH}")
|
||||
elif APP_TYPE == "lidarr":
|
||||
logger.info(f"Missing Content Configuration: HUNT_MISSING_ALBUMS={HUNT_MISSING_ALBUMS}")
|
||||
logger.info(f"Upgrade Configuration: HUNT_UPGRADE_TRACKS={HUNT_UPGRADE_TRACKS}")
|
||||
logger.info(f"SKIP_FUTURE_RELEASES={SKIP_FUTURE_RELEASES}, SKIP_ARTIST_REFRESH={SKIP_ARTIST_REFRESH}")
|
||||
elif APP_TYPE == "readarr":
|
||||
logger.info(f"Missing Content Configuration: HUNT_MISSING_BOOKS={HUNT_MISSING_BOOKS}")
|
||||
logger.info(f"Upgrade Configuration: HUNT_UPGRADE_BOOKS={HUNT_UPGRADE_BOOKS}")
|
||||
logger.info(f"SKIP_FUTURE_RELEASES={SKIP_FUTURE_RELEASES}, SKIP_AUTHOR_REFRESH={SKIP_AUTHOR_REFRESH}")
|
||||
|
||||
# Common configuration logging
|
||||
logger.info(f"State Reset Interval: {STATE_RESET_INTERVAL_HOURS} hours")
|
||||
logger.info(f"Minimum Download Queue Size: {MINIMUM_DOWNLOAD_QUEUE_SIZE}")
|
||||
logger.info(f"MONITORED_ONLY={MONITORED_ONLY}, RANDOM_MISSING={RANDOM_MISSING}, RANDOM_UPGRADES={RANDOM_UPGRADES}")
|
||||
logger.info(f"HUNT_MODE={HUNT_MODE}, SLEEP_DURATION={SLEEP_DURATION}s")
|
||||
logger.info(f"COMMAND_WAIT_DELAY={COMMAND_WAIT_DELAY}, COMMAND_WAIT_ATTEMPTS={COMMAND_WAIT_ATTEMPTS}")
|
||||
logger.info(f"ENABLE_WEB_UI=true, DEBUG_MODE={DEBUG_MODE}")
|
||||
|
||||
# Initial refresh of settings
|
||||
refresh_settings()
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Huntarr [Sonarr Edition] - Python Version
|
||||
Main entry point for the application
|
||||
Huntarr - Main entry point for the application
|
||||
Supports multiple Arr applications
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -10,11 +10,10 @@ import os
|
||||
import socket
|
||||
import signal
|
||||
import importlib
|
||||
from utils.logger import logger
|
||||
from config import HUNT_MODE, SLEEP_DURATION, MINIMUM_DOWNLOAD_QUEUE_SIZE, ENABLE_WEB_UI, log_configuration, refresh_settings
|
||||
from missing import process_missing_episodes
|
||||
from state import check_state_reset, calculate_reset_time
|
||||
from api import get_download_queue_size
|
||||
from primary.utils.logger import logger
|
||||
from primary.config import HUNT_MODE, SLEEP_DURATION, MINIMUM_DOWNLOAD_QUEUE_SIZE, APP_TYPE, log_configuration, refresh_settings
|
||||
from primary.state import check_state_reset, calculate_reset_time
|
||||
from primary.api import get_download_queue_size
|
||||
|
||||
# Flag to indicate if cycle should restart
|
||||
restart_cycle = False
|
||||
@@ -33,7 +32,7 @@ def get_ip_address():
|
||||
"""Get the host's IP address from API_URL for display"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
from config import API_URL
|
||||
from primary.config import API_URL
|
||||
|
||||
# Extract the hostname/IP from the API_URL
|
||||
parsed_url = urlparse(API_URL)
|
||||
@@ -57,15 +56,17 @@ def force_reload_all_modules():
|
||||
"""Force reload of all relevant modules to ensure fresh settings"""
|
||||
try:
|
||||
# Force reload the config module
|
||||
import config
|
||||
from primary import config
|
||||
importlib.reload(config)
|
||||
|
||||
# Reload any modules that might cache config values
|
||||
import missing
|
||||
importlib.reload(missing)
|
||||
|
||||
import upgrade
|
||||
importlib.reload(upgrade)
|
||||
# Reload app-specific modules
|
||||
if APP_TYPE == "sonarr":
|
||||
from primary import missing
|
||||
importlib.reload(missing)
|
||||
|
||||
from primary import upgrade
|
||||
importlib.reload(upgrade)
|
||||
# TODO: Add other app type module reloading when implemented
|
||||
|
||||
# Call the refresh function to ensure settings are updated
|
||||
config.refresh_settings()
|
||||
@@ -80,18 +81,17 @@ def force_reload_all_modules():
|
||||
return False
|
||||
|
||||
def main_loop() -> None:
|
||||
"""Main processing loop for Huntarr-Sonarr"""
|
||||
"""Main processing loop for Huntarr"""
|
||||
global restart_cycle
|
||||
|
||||
# Log welcome message for web interface
|
||||
logger.info("=== Huntarr [Sonarr Edition] Starting ===")
|
||||
# Log welcome message
|
||||
logger.info(f"=== Huntarr [{APP_TYPE.title()} Edition] Starting ===")
|
||||
|
||||
# Log web UI information if enabled
|
||||
if ENABLE_WEB_UI:
|
||||
server_ip = get_ip_address()
|
||||
logger.info(f"Web interface available at http://{server_ip}:8988")
|
||||
# Log web UI information (always enabled)
|
||||
server_ip = get_ip_address()
|
||||
logger.info(f"Web interface available at http://{server_ip}:8988")
|
||||
|
||||
logger.info("GitHub: https://github.com/plexguide/huntarr-sonarr")
|
||||
logger.info("GitHub: https://github.com/plexguide/huntarr")
|
||||
|
||||
while True:
|
||||
# Set restart_cycle flag to False at the beginning of each cycle
|
||||
@@ -100,14 +100,10 @@ def main_loop() -> None:
|
||||
# Always force reload all modules at the start of each cycle
|
||||
force_reload_all_modules()
|
||||
|
||||
# Import after reload to ensure we get fresh values
|
||||
from config import HUNT_MODE, HUNT_MISSING_SHOWS, HUNT_UPGRADE_EPISODES
|
||||
from upgrade import process_cutoff_upgrades
|
||||
|
||||
# Check if state files need to be reset
|
||||
check_state_reset()
|
||||
|
||||
logger.info(f"=== Starting Huntarr-Sonarr cycle ===")
|
||||
logger.info(f"=== Starting Huntarr cycle ===")
|
||||
|
||||
# Track if any processing was done in this cycle
|
||||
processing_done = False
|
||||
@@ -116,30 +112,49 @@ def main_loop() -> None:
|
||||
download_queue_size = get_download_queue_size()
|
||||
if MINIMUM_DOWNLOAD_QUEUE_SIZE < 0 or (MINIMUM_DOWNLOAD_QUEUE_SIZE >= 0 and download_queue_size <= MINIMUM_DOWNLOAD_QUEUE_SIZE):
|
||||
|
||||
# Process shows/episodes based on HUNT_MODE
|
||||
# Process items based on APP_TYPE and HUNT_MODE
|
||||
if restart_cycle:
|
||||
logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
|
||||
continue
|
||||
|
||||
if HUNT_MODE in ["missing", "both"] and HUNT_MISSING_SHOWS > 0:
|
||||
if process_missing_episodes():
|
||||
processing_done = True
|
||||
|
||||
# Check if restart signal received
|
||||
if restart_cycle:
|
||||
logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
|
||||
continue
|
||||
if APP_TYPE == "sonarr":
|
||||
if HUNT_MODE in ["missing", "both"]:
|
||||
from primary.missing import process_missing_episodes
|
||||
if process_missing_episodes():
|
||||
processing_done = True
|
||||
|
||||
if HUNT_MODE in ["upgrade", "both"] and HUNT_UPGRADE_EPISODES > 0:
|
||||
logger.info(f"Starting upgrade process with HUNT_UPGRADE_EPISODES={HUNT_UPGRADE_EPISODES}")
|
||||
# Check if restart signal received
|
||||
if restart_cycle:
|
||||
logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
|
||||
continue
|
||||
|
||||
if HUNT_MODE in ["upgrade", "both"]:
|
||||
from primary.upgrade import process_cutoff_upgrades
|
||||
if process_cutoff_upgrades():
|
||||
processing_done
|
||||
from primary.upgrade import process_cutoff_upgrades
|
||||
if process_cutoff_upgrades():
|
||||
processing_done = True
|
||||
|
||||
# Check if restart signal received
|
||||
if restart_cycle:
|
||||
logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
|
||||
continue
|
||||
|
||||
elif APP_TYPE == "radarr":
|
||||
# TODO: Implement Radarr processing
|
||||
logger.info("Radarr processing not yet implemented")
|
||||
time.sleep(5) # Short sleep to avoid log spam
|
||||
|
||||
if process_cutoff_upgrades():
|
||||
processing_done = True
|
||||
elif APP_TYPE == "lidarr":
|
||||
# TODO: Implement Lidarr processing
|
||||
logger.info("Lidarr processing not yet implemented")
|
||||
time.sleep(5) # Short sleep to avoid log spam
|
||||
|
||||
# Check if restart signal received
|
||||
if restart_cycle:
|
||||
logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
|
||||
continue
|
||||
elif APP_TYPE == "readarr":
|
||||
# TODO: Implement Readarr processing
|
||||
logger.info("Readarr processing not yet implemented")
|
||||
time.sleep(5) # Short sleep to avoid log spam
|
||||
|
||||
else:
|
||||
logger.info(f"Download queue size ({download_queue_size}) is above the minimum threshold ({MINIMUM_DOWNLOAD_QUEUE_SIZE}). Skipped processing.")
|
||||
@@ -150,16 +165,15 @@ def main_loop() -> None:
|
||||
# Refresh settings before sleep to get the latest sleep_duration
|
||||
refresh_settings()
|
||||
# Import it directly from the settings manager to ensure latest value
|
||||
from config import SLEEP_DURATION as CURRENT_SLEEP_DURATION
|
||||
from primary.config import SLEEP_DURATION as CURRENT_SLEEP_DURATION
|
||||
|
||||
# Sleep at the end of the cycle only
|
||||
logger.info(f"Cycle complete. Sleeping {CURRENT_SLEEP_DURATION}s before next cycle...")
|
||||
logger.info("⭐ Tool Great? Donate @ https://donate.plex.one for Daughter's College Fund!")
|
||||
|
||||
# Log web UI information if enabled
|
||||
if ENABLE_WEB_UI:
|
||||
server_ip = get_ip_address()
|
||||
logger.info(f"Web interface available at http://{server_ip}:8988")
|
||||
# Log web UI information
|
||||
server_ip = get_ip_address()
|
||||
logger.info(f"Web interface available at http://{server_ip}:8988")
|
||||
|
||||
# Sleep with progress updates for the web interface
|
||||
sleep_start = time.time()
|
||||
@@ -186,7 +200,7 @@ if __name__ == "__main__":
|
||||
try:
|
||||
main_loop()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Huntarr-Sonarr stopped by user.")
|
||||
logger.info("Huntarr stopped by user.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error: {e}")
|
||||
@@ -8,8 +8,8 @@ import random
|
||||
import time
|
||||
import datetime
|
||||
from typing import List
|
||||
from utils.logger import logger
|
||||
from config import (
|
||||
from primary.utils.logger import logger
|
||||
from primary.config import (
|
||||
HUNT_MISSING_SHOWS,
|
||||
MONITORED_ONLY,
|
||||
RANDOM_SELECTION,
|
||||
@@ -17,13 +17,13 @@ from config import (
|
||||
SKIP_FUTURE_EPISODES,
|
||||
SKIP_SERIES_REFRESH
|
||||
)
|
||||
from api import (
|
||||
from primary.api import (
|
||||
get_episodes_for_series,
|
||||
refresh_series,
|
||||
episode_search_episodes,
|
||||
get_series_with_missing_episodes
|
||||
)
|
||||
from state import load_processed_ids, save_processed_id, truncate_processed_list, PROCESSED_MISSING_FILE
|
||||
from primary.state import load_processed_ids, save_processed_id, truncate_processed_list, PROCESSED_MISSING_FILE
|
||||
|
||||
def process_missing_episodes() -> bool:
|
||||
"""
|
||||
5
primary/requirements.txt
Normal file
5
primary/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
requests>=2.25.0
|
||||
flask>=2.0.0
|
||||
pyotp>=2.6.0
|
||||
qrcode>=7.3.1
|
||||
pillow>=8.4.0
|
||||
235
primary/settings_manager.py
Normal file
235
primary/settings_manager.py
Normal file
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Settings manager for Huntarr
|
||||
Handles loading, saving, and providing settings from a JSON file
|
||||
Supports default configurations for different Arr applications
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import pathlib
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Create a simple logger for settings_manager
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
settings_logger = logging.getLogger("settings_manager")
|
||||
|
||||
# Settings directory setup
|
||||
SETTINGS_DIR = pathlib.Path("/config/settings")
|
||||
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SETTINGS_FILE = SETTINGS_DIR / "huntarr.json"
|
||||
|
||||
# Default settings
|
||||
DEFAULT_SETTINGS = {
|
||||
"ui": {
|
||||
"dark_mode": True
|
||||
},
|
||||
"app_type": "sonarr", # Default app type
|
||||
"api_key": "",
|
||||
"api_url": "",
|
||||
"huntarr": {
|
||||
# These will be loaded from default_configs.json based on app_type
|
||||
},
|
||||
"advanced": {
|
||||
# These will be loaded from default_configs.json based on app_type
|
||||
}
|
||||
}
|
||||
|
||||
# Load default configurations from file
|
||||
def load_default_configs():
|
||||
"""Load default configurations for all supported apps"""
|
||||
try:
|
||||
default_configs_path = pathlib.Path("/app/default_configs.json")
|
||||
if default_configs_path.exists():
|
||||
with open(default_configs_path, 'r') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
settings_logger.warning(f"Default configs file not found at {default_configs_path}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error loading default configs: {e}")
|
||||
return {}
|
||||
|
||||
# Initialize default configs
|
||||
DEFAULT_CONFIGS = load_default_configs()
|
||||
|
||||
def get_app_defaults(app_type):
|
||||
"""Get default settings for a specific app type"""
|
||||
if app_type in DEFAULT_CONFIGS:
|
||||
return DEFAULT_CONFIGS[app_type]
|
||||
else:
|
||||
settings_logger.warning(f"No default config found for app_type: {app_type}, falling back to sonarr")
|
||||
return DEFAULT_CONFIGS.get("sonarr", {})
|
||||
|
||||
def get_env_settings():
|
||||
"""Get settings from environment variables"""
|
||||
env_settings = {
|
||||
"api_key": os.environ.get("API_KEY", ""),
|
||||
"api_url": os.environ.get("API_URL", ""),
|
||||
"app_type": os.environ.get("APP_TYPE", "sonarr").lower()
|
||||
}
|
||||
|
||||
# Optional environment variables
|
||||
if "API_TIMEOUT" in os.environ:
|
||||
try:
|
||||
env_settings["api_timeout"] = int(os.environ.get("API_TIMEOUT"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if "MONITORED_ONLY" in os.environ:
|
||||
env_settings["monitored_only"] = os.environ.get("MONITORED_ONLY", "true").lower() == "true"
|
||||
|
||||
# All other environment variables that might override defaults
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(("HUNT_", "SLEEP_", "STATE_", "SKIP_", "RANDOM_", "COMMAND_", "MINIMUM_", "DEBUG_")):
|
||||
# Convert to lowercase with underscores
|
||||
settings_key = key.lower()
|
||||
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ("true", "false"):
|
||||
env_settings[settings_key] = value.lower() == "true"
|
||||
else:
|
||||
try:
|
||||
env_settings[settings_key] = int(value)
|
||||
except ValueError:
|
||||
env_settings[settings_key] = value
|
||||
|
||||
return env_settings
|
||||
|
||||
def load_settings() -> Dict[str, Any]:
|
||||
"""
|
||||
Load settings with the following priority:
|
||||
1. User-defined settings in the settings file
|
||||
2. Environment variables
|
||||
3. Default settings for the selected app_type
|
||||
"""
|
||||
try:
|
||||
# Start with default settings structure
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
|
||||
# Get environment variables
|
||||
env_settings = get_env_settings()
|
||||
|
||||
# If we have an app_type, update the settings
|
||||
app_type = env_settings.get("app_type", "sonarr")
|
||||
settings["app_type"] = app_type
|
||||
|
||||
# Get default settings for this app type
|
||||
app_defaults = get_app_defaults(app_type)
|
||||
|
||||
# Categorize settings
|
||||
huntarr_settings = {}
|
||||
advanced_settings = {}
|
||||
|
||||
# Distribute app defaults into categories
|
||||
for key, value in app_defaults.items():
|
||||
# Simple categorization based on key name
|
||||
if key in ("api_timeout", "debug_mode", "command_wait_delay",
|
||||
"command_wait_attempts", "minimum_download_queue_size",
|
||||
"random_missing", "random_upgrades"):
|
||||
advanced_settings[key] = value
|
||||
else:
|
||||
huntarr_settings[key] = value
|
||||
|
||||
# Apply defaults to settings
|
||||
settings["huntarr"].update(huntarr_settings)
|
||||
settings["advanced"].update(advanced_settings)
|
||||
|
||||
# Apply environment settings, keeping track of whether they're huntarr or advanced
|
||||
for key, value in env_settings.items():
|
||||
if key in ("api_key", "api_url", "app_type"):
|
||||
settings[key] = value
|
||||
elif key in ("api_timeout", "debug_mode", "command_wait_delay",
|
||||
"command_wait_attempts", "minimum_download_queue_size",
|
||||
"random_missing", "random_upgrades"):
|
||||
settings["advanced"][key] = value
|
||||
else:
|
||||
settings["huntarr"][key] = value
|
||||
|
||||
# Finally, load user settings from file (highest priority)
|
||||
if SETTINGS_FILE.exists():
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
user_settings = json.load(f)
|
||||
# Deep merge user settings
|
||||
_deep_update(settings, user_settings)
|
||||
settings_logger.info("Settings loaded from configuration file")
|
||||
else:
|
||||
settings_logger.info("No settings file found, creating with default values")
|
||||
save_settings(settings)
|
||||
|
||||
return settings
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error loading settings: {e}")
|
||||
settings_logger.info("Using default settings due to error")
|
||||
return DEFAULT_SETTINGS
|
||||
|
||||
def _deep_update(d, u):
|
||||
"""Recursively update a dictionary without overwriting entire nested dicts"""
|
||||
for k, v in u.items():
|
||||
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
|
||||
_deep_update(d[k], v)
|
||||
else:
|
||||
d[k] = v
|
||||
|
||||
def save_settings(settings: Dict[str, Any]) -> bool:
|
||||
"""Save settings to the settings file."""
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
settings_logger.info("Settings saved successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error saving settings: {e}")
|
||||
return False
|
||||
|
||||
def update_setting(category: str, key: str, value: Any) -> bool:
|
||||
"""Update a specific setting value."""
|
||||
try:
|
||||
settings = load_settings()
|
||||
|
||||
# Ensure category exists
|
||||
if category not in settings:
|
||||
settings[category] = {}
|
||||
|
||||
# Update the value
|
||||
settings[category][key] = value
|
||||
|
||||
# Save the updated settings
|
||||
return save_settings(settings)
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error updating setting {category}.{key}: {e}")
|
||||
return False
|
||||
|
||||
def get_setting(category: str, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific setting value."""
|
||||
try:
|
||||
settings = load_settings()
|
||||
return settings.get(category, {}).get(key, default)
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error getting setting {category}.{key}: {e}")
|
||||
return default
|
||||
|
||||
def get_all_settings() -> Dict[str, Any]:
|
||||
"""Get all settings."""
|
||||
return load_settings()
|
||||
|
||||
def get_app_type() -> str:
|
||||
"""Get the current app type"""
|
||||
settings = load_settings()
|
||||
return settings.get("app_type", "sonarr")
|
||||
|
||||
def get_api_key() -> str:
|
||||
"""Get the API key"""
|
||||
settings = load_settings()
|
||||
return settings.get("api_key", "")
|
||||
|
||||
def get_api_url() -> str:
|
||||
"""Get the API URL"""
|
||||
settings = load_settings()
|
||||
return settings.get("api_url", "")
|
||||
|
||||
# Initialize settings file if it doesn't exist
|
||||
if not SETTINGS_FILE.exists():
|
||||
save_settings(load_settings())
|
||||
18
primary/start.sh
Normal file
18
primary/start.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# Startup script for Huntarr with always enabled web UI
|
||||
|
||||
# Ensure the configuration directories exist and have proper permissions
|
||||
mkdir -p /config/settings /config/stateful /config/user
|
||||
chmod -R 755 /config
|
||||
|
||||
# Detect app type from environment or use sonarr as default
|
||||
APP_TYPE=${APP_TYPE:-sonarr}
|
||||
echo "Starting Huntarr in ${APP_TYPE} mode"
|
||||
|
||||
# Web UI is always enabled in v4
|
||||
echo "Starting with Web UI enabled on port 8988"
|
||||
|
||||
# Start both the web server and the main application
|
||||
cd /app
|
||||
python -m primary.web_server &
|
||||
python -m primary.main
|
||||
@@ -1,29 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
State management for Huntarr-Sonarr
|
||||
Handles tracking which shows/episodes have been processed
|
||||
State management for Huntarr
|
||||
Handles tracking which items have been processed
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import pathlib
|
||||
from typing import List
|
||||
from utils.logger import logger
|
||||
from config import STATE_RESET_INTERVAL_HOURS
|
||||
from primary.utils.logger import logger
|
||||
from primary.config import STATE_RESET_INTERVAL_HOURS, APP_TYPE
|
||||
|
||||
# State directory setup
|
||||
STATE_DIR = pathlib.Path("/config/stateful")
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_ids.txt"
|
||||
PROCESSED_UPGRADE_FILE = STATE_DIR / "processed_upgrade_ids.txt"
|
||||
# Create app-specific state file paths
|
||||
if APP_TYPE == "sonarr":
|
||||
PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_sonarr.txt"
|
||||
PROCESSED_UPGRADE_FILE = STATE_DIR / "processed_upgrade_sonarr.txt"
|
||||
elif APP_TYPE == "radarr":
|
||||
PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_radarr.txt"
|
||||
PROCESSED_UPGRADE_FILE = STATE_DIR / "processed_upgrade_radarr.txt"
|
||||
elif APP_TYPE == "lidarr":
|
||||
PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_lidarr.txt"
|
||||
PROCESSED_UPGRADE_FILE = STATE_DIR / "processed_upgrade_lidarr.txt"
|
||||
elif APP_TYPE == "readarr":
|
||||
PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_readarr.txt"
|
||||
PROCESSED_UPGRADE_FILE = STATE_DIR / "processed_upgrade_readarr.txt"
|
||||
else:
|
||||
# Default fallback to sonarr
|
||||
PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_sonarr.txt"
|
||||
PROCESSED_UPGRADE_FILE = STATE_DIR / "processed_upgrade_sonarr.txt"
|
||||
|
||||
# Create files if they don't exist
|
||||
PROCESSED_MISSING_FILE.touch(exist_ok=True)
|
||||
PROCESSED_UPGRADE_FILE.touch(exist_ok=True)
|
||||
|
||||
def load_processed_ids(file_path: pathlib.Path) -> List[int]:
|
||||
"""Load processed show/episode IDs from a file."""
|
||||
"""Load processed item IDs from a file."""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return [int(line.strip()) for line in f if line.strip().isdigit()]
|
||||
@@ -32,7 +47,7 @@ def load_processed_ids(file_path: pathlib.Path) -> List[int]:
|
||||
return []
|
||||
|
||||
def save_processed_id(file_path: pathlib.Path, obj_id: int) -> None:
|
||||
"""Save a processed show/episode ID to a file."""
|
||||
"""Save a processed item ID to a file."""
|
||||
try:
|
||||
with open(file_path, 'a') as f:
|
||||
f.write(f"{obj_id}\n")
|
||||
@@ -8,21 +8,21 @@ import random
|
||||
import time
|
||||
import datetime
|
||||
import importlib
|
||||
from utils.logger import logger
|
||||
from config import (
|
||||
from primary.utils.logger import logger
|
||||
from primary.config import (
|
||||
MONITORED_ONLY,
|
||||
RANDOM_SELECTION,
|
||||
RANDOM_UPGRADES,
|
||||
SKIP_FUTURE_EPISODES,
|
||||
SKIP_SERIES_REFRESH
|
||||
)
|
||||
from api import get_cutoff_unmet, get_cutoff_unmet_total_pages, refresh_series, episode_search_episodes, sonarr_request
|
||||
from state import load_processed_ids, save_processed_id, truncate_processed_list, PROCESSED_UPGRADE_FILE
|
||||
from primary.api import get_cutoff_unmet, get_cutoff_unmet_total_pages, refresh_series, episode_search_episodes, arr_request
|
||||
from primary.state import load_processed_ids, save_processed_id, truncate_processed_list, PROCESSED_UPGRADE_FILE
|
||||
|
||||
def get_current_upgrade_limit():
|
||||
"""Get the current HUNT_UPGRADE_EPISODES value directly from config"""
|
||||
# Force reload the config module to get the latest value
|
||||
import config
|
||||
from primary import config
|
||||
importlib.reload(config)
|
||||
return config.HUNT_UPGRADE_EPISODES
|
||||
|
||||
@@ -125,7 +125,7 @@ def process_cutoff_upgrades() -> bool:
|
||||
series_title = ep_obj.get("seriesTitle", None)
|
||||
if not series_title:
|
||||
# fallback: request the series
|
||||
series_data = sonarr_request(f"series/{series_id}", method="GET")
|
||||
series_data = arr_request(f"series/{series_id}", method="GET")
|
||||
if series_data:
|
||||
series_title = series_data.get("title", "Unknown Series")
|
||||
else:
|
||||
@@ -155,7 +155,7 @@ def process_cutoff_upgrades() -> bool:
|
||||
series_monitored = ep_obj["series"].get("monitored", False)
|
||||
else:
|
||||
# retrieve the series
|
||||
series_data = sonarr_request(f"series/{series_id}", "GET")
|
||||
series_data = arr_request(f"series/{series_id}", "GET")
|
||||
series_monitored = series_data.get("monitored", False) if series_data else False
|
||||
|
||||
if not ep_monitored or not series_monitored:
|
||||
7
primary/utils/__init__.py
Normal file
7
primary/utils/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Utility functions for Huntarr
|
||||
"""
|
||||
|
||||
from primary.utils.logger import logger, debug_log
|
||||
|
||||
__all__ = ['logger', 'debug_log']
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Logging configuration for Huntarr-Sonarr
|
||||
Logging configuration for Huntarr
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -29,14 +29,14 @@ def setup_logger(debug_mode=None):
|
||||
|
||||
# Get DEBUG_MODE from config, but only if we haven't been given a value
|
||||
if debug_mode is None:
|
||||
from config import DEBUG_MODE as CONFIG_DEBUG_MODE
|
||||
from primary.config import DEBUG_MODE as CONFIG_DEBUG_MODE
|
||||
use_debug_mode = CONFIG_DEBUG_MODE
|
||||
else:
|
||||
use_debug_mode = debug_mode
|
||||
|
||||
if logger is None:
|
||||
# First-time setup
|
||||
logger = logging.getLogger("huntarr-sonarr")
|
||||
logger = logging.getLogger("huntarr")
|
||||
else:
|
||||
# Reset handlers to avoid duplicates
|
||||
for handler in logger.handlers[:]:
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Web server for Huntarr-Sonarr
|
||||
Provides a web interface to view logs in real-time and manage settings
|
||||
Web server for Huntarr
|
||||
Provides a web interface to view logs in real-time, manage settings, and includes authentication
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -12,29 +12,41 @@ import socket
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
from flask import Flask, render_template, Response, stream_with_context, request, jsonify, send_from_directory
|
||||
import qrcode
|
||||
import pyotp
|
||||
import base64
|
||||
import io
|
||||
from flask import Flask, render_template, Response, stream_with_context, request, jsonify, send_from_directory, redirect, session, url_for
|
||||
import logging
|
||||
from config import ENABLE_WEB_UI
|
||||
import settings_manager
|
||||
from utils.logger import setup_logger
|
||||
|
||||
# Check if web UI is disabled
|
||||
if not ENABLE_WEB_UI:
|
||||
print("Web UI is disabled. Exiting web server.")
|
||||
exit(0)
|
||||
from primary.config import API_URL
|
||||
from primary import settings_manager
|
||||
from primary.utils.logger import setup_logger
|
||||
from primary.auth import (
|
||||
authenticate_request, user_exists, create_user, verify_user, create_session,
|
||||
logout, SESSION_COOKIE_NAME, is_2fa_enabled, generate_2fa_secret,
|
||||
verify_2fa_code, disable_2fa, change_username, change_password
|
||||
)
|
||||
|
||||
# Disable Flask default logging
|
||||
log = logging.getLogger('werkzeug')
|
||||
log.setLevel(logging.ERROR)
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app = Flask(__name__, template_folder='../templates', static_folder='../static')
|
||||
app.secret_key = os.environ.get("SECRET_KEY") or os.urandom(24)
|
||||
|
||||
# Log file location
|
||||
LOG_FILE = "/tmp/huntarr-logs/huntarr.log"
|
||||
LOG_DIR = pathlib.Path("/tmp/huntarr-logs")
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Authentication middleware
|
||||
@app.before_request
|
||||
def before_request():
|
||||
auth_result = authenticate_request()
|
||||
if auth_result:
|
||||
return auth_result
|
||||
|
||||
# Get the PID of the main process
|
||||
def get_main_process_pid():
|
||||
try:
|
||||
@@ -45,7 +57,7 @@ def get_main_process_pid():
|
||||
try:
|
||||
with open(f'/proc/{proc}/cmdline', 'r') as f:
|
||||
cmdline = f.read().replace('\0', ' ')
|
||||
if 'python' in cmdline and 'main.py' in cmdline:
|
||||
if 'python' in cmdline and 'primary/main.py' in cmdline:
|
||||
return int(proc)
|
||||
except (IOError, ProcessLookupError):
|
||||
continue
|
||||
@@ -58,10 +70,191 @@ def index():
|
||||
"""Render the main page"""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/settings')
|
||||
def settings_page():
|
||||
"""Render the settings page"""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/user')
|
||||
def user_page():
|
||||
"""Render the user settings page"""
|
||||
return render_template('user.html')
|
||||
|
||||
@app.route('/setup', methods=['GET'])
|
||||
def setup_page():
|
||||
"""Render the setup page for first-time users"""
|
||||
if user_exists():
|
||||
return redirect('/')
|
||||
return render_template('setup.html')
|
||||
|
||||
@app.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
"""Render the login page"""
|
||||
if not user_exists():
|
||||
return redirect('/setup')
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/login', methods=['POST'])
|
||||
def api_login_form():
|
||||
"""Handle form-based login (for 2FA implementation)"""
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
otp_code = request.form.get('otp_code')
|
||||
|
||||
auth_success, needs_2fa = verify_user(username, password, otp_code)
|
||||
|
||||
if auth_success:
|
||||
# Create a session for the authenticated user
|
||||
session_id = create_session(username)
|
||||
session[SESSION_COOKIE_NAME] = session_id
|
||||
return redirect('/')
|
||||
elif needs_2fa:
|
||||
# Show 2FA input form
|
||||
return render_template('login.html', username=username, password=password, needs_2fa=True)
|
||||
else:
|
||||
# Invalid credentials
|
||||
return render_template('login.html', error="Invalid username or password")
|
||||
|
||||
@app.route('/logout')
|
||||
def logout_page():
|
||||
"""Log out and redirect to login page"""
|
||||
logout()
|
||||
return redirect('/login')
|
||||
|
||||
@app.route('/api/setup', methods=['POST'])
|
||||
def api_setup():
|
||||
"""Create the initial user"""
|
||||
if user_exists():
|
||||
return jsonify({"success": False, "message": "User already exists"}), 400
|
||||
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
confirm_password = data.get('confirm_password')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"success": False, "message": "Username and password required"}), 400
|
||||
|
||||
if password != confirm_password:
|
||||
return jsonify({"success": False, "message": "Passwords do not match"}), 400
|
||||
|
||||
if create_user(username, password):
|
||||
# Create a session for the new user
|
||||
session_id = create_session(username)
|
||||
session[SESSION_COOKIE_NAME] = session_id
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Failed to create user"}), 500
|
||||
|
||||
@app.route('/api/login', methods=['POST'])
|
||||
def api_login():
|
||||
"""Authenticate a user"""
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
otp_code = data.get('otp_code')
|
||||
|
||||
auth_success, needs_2fa = verify_user(username, password, otp_code)
|
||||
|
||||
if auth_success:
|
||||
# Create a session for the authenticated user
|
||||
session_id = create_session(username)
|
||||
session[SESSION_COOKIE_NAME] = session_id
|
||||
return jsonify({"success": True})
|
||||
elif needs_2fa:
|
||||
# Need 2FA code
|
||||
return jsonify({"success": False, "needs_2fa": True})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Invalid credentials"}), 401
|
||||
|
||||
@app.route('/api/user/2fa-status')
|
||||
def api_2fa_status():
|
||||
"""Check if 2FA is enabled for the current user"""
|
||||
return jsonify({"enabled": is_2fa_enabled()})
|
||||
|
||||
@app.route('/api/user/generate-2fa')
|
||||
def api_generate_2fa():
|
||||
"""Generate a new 2FA secret and QR code"""
|
||||
try:
|
||||
secret, qr_code_url = generate_2fa_secret()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"secret": secret,
|
||||
"qr_code_url": qr_code_url
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"Failed to generate 2FA: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@app.route('/api/user/verify-2fa', methods=['POST'])
|
||||
def api_verify_2fa():
|
||||
"""Verify a 2FA code and enable 2FA if valid"""
|
||||
data = request.json
|
||||
code = data.get('code')
|
||||
|
||||
if not code:
|
||||
return jsonify({"success": False, "message": "Verification code is required"}), 400
|
||||
|
||||
if verify_2fa_code(code):
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Invalid verification code"}), 400
|
||||
|
||||
@app.route('/api/user/disable-2fa', methods=['POST'])
|
||||
def api_disable_2fa():
|
||||
"""Disable 2FA for the current user"""
|
||||
data = request.json
|
||||
password = data.get('password')
|
||||
|
||||
if not password:
|
||||
return jsonify({"success": False, "message": "Password is required"}), 400
|
||||
|
||||
if disable_2fa(password):
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Invalid password"}), 400
|
||||
|
||||
@app.route('/api/user/change-username', methods=['POST'])
|
||||
def api_change_username():
|
||||
"""Change the username for the current user"""
|
||||
data = request.json
|
||||
current_username = data.get('current_username')
|
||||
new_username = data.get('new_username')
|
||||
password = data.get('password')
|
||||
|
||||
if not current_username or not new_username or not password:
|
||||
return jsonify({"success": False, "message": "All fields are required"}), 400
|
||||
|
||||
if change_username(current_username, new_username, password):
|
||||
# Force logout
|
||||
logout()
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Invalid username or password"}), 400
|
||||
|
||||
@app.route('/api/user/change-password', methods=['POST'])
|
||||
def api_change_password():
|
||||
"""Change the password for the current user"""
|
||||
data = request.json
|
||||
current_password = data.get('current_password')
|
||||
new_password = data.get('new_password')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({"success": False, "message": "All fields are required"}), 400
|
||||
|
||||
if change_password(current_password, new_password):
|
||||
# Force logout
|
||||
logout()
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Invalid current password"}), 400
|
||||
|
||||
@app.route('/static/<path:path>')
|
||||
def send_static(path):
|
||||
"""Serve static files"""
|
||||
return send_from_directory('static', path)
|
||||
return send_from_directory('../static', path)
|
||||
|
||||
@app.route('/logs')
|
||||
def stream_logs():
|
||||
@@ -250,7 +443,6 @@ def get_ip_address():
|
||||
"""Get the host's IP address from API_URL for display"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
from config import API_URL
|
||||
|
||||
# Extract the hostname/IP from the API_URL
|
||||
parsed_url = urlparse(API_URL)
|
||||
@@ -1,2 +0,0 @@
|
||||
requests>=2.25.0
|
||||
flask>=2.0.0
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Settings manager for Huntarr-Sonarr
|
||||
Handles loading, saving, and providing settings from a JSON file
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import pathlib
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Create a simple logger for settings_manager
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
settings_logger = logging.getLogger("settings_manager")
|
||||
|
||||
# Settings directory setup
|
||||
SETTINGS_DIR = pathlib.Path("/config/settings")
|
||||
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SETTINGS_FILE = SETTINGS_DIR / "huntarr.json"
|
||||
|
||||
# Default settings
|
||||
DEFAULT_SETTINGS = {
|
||||
"ui": {
|
||||
"dark_mode": True
|
||||
},
|
||||
"huntarr": {
|
||||
"sleep_duration": 900, # 15 minutes in seconds
|
||||
"hunt_missing_shows": 1,
|
||||
"hunt_upgrade_episodes": 5,
|
||||
"state_reset_interval_hours": 168, # 1 week in hours
|
||||
"monitored_only": True,
|
||||
"random_selection": True,
|
||||
"skip_future_episodes": True,
|
||||
"skip_series_refresh": False
|
||||
},
|
||||
"advanced": {
|
||||
"api_timeout": 60,
|
||||
"debug_mode": False,
|
||||
"command_wait_delay": 1,
|
||||
"command_wait_attempts": 600,
|
||||
"minimum_download_queue_size": -1,
|
||||
"random_missing": True,
|
||||
"random_upgrades": True
|
||||
}
|
||||
}
|
||||
|
||||
def load_settings() -> Dict[str, Any]:
|
||||
"""Load settings from the settings file, or return defaults if not available."""
|
||||
try:
|
||||
if SETTINGS_FILE.exists():
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
settings = json.load(f)
|
||||
settings_logger.info("Settings loaded from configuration file")
|
||||
return settings
|
||||
else:
|
||||
settings_logger.info("No settings file found, creating with default values")
|
||||
save_settings(DEFAULT_SETTINGS)
|
||||
return DEFAULT_SETTINGS
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error loading settings: {e}")
|
||||
settings_logger.info("Using default settings due to error")
|
||||
return DEFAULT_SETTINGS
|
||||
|
||||
def save_settings(settings: Dict[str, Any]) -> bool:
|
||||
"""Save settings to the settings file."""
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
settings_logger.info("Settings saved successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error saving settings: {e}")
|
||||
return False
|
||||
|
||||
def update_setting(category: str, key: str, value: Any) -> bool:
|
||||
"""Update a specific setting value."""
|
||||
try:
|
||||
settings = load_settings()
|
||||
|
||||
# Ensure category exists
|
||||
if category not in settings:
|
||||
settings[category] = {}
|
||||
|
||||
# Update the value
|
||||
settings[category][key] = value
|
||||
|
||||
# Save the updated settings
|
||||
return save_settings(settings)
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error updating setting {category}.{key}: {e}")
|
||||
return False
|
||||
|
||||
def get_setting(category: str, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific setting value."""
|
||||
try:
|
||||
settings = load_settings()
|
||||
return settings.get(category, {}).get(key, default)
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error getting setting {category}.{key}: {e}")
|
||||
return default
|
||||
|
||||
def get_all_settings() -> Dict[str, Any]:
|
||||
"""Get all settings."""
|
||||
return load_settings()
|
||||
|
||||
# Initialize settings file if it doesn't exist
|
||||
if not SETTINGS_FILE.exists():
|
||||
save_settings(DEFAULT_SETTINGS)
|
||||
20
start.sh
20
start.sh
@@ -1,20 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Startup script for Huntarr-Sonarr that conditionally starts the web UI
|
||||
|
||||
# Ensure the configuration directories exist and have proper permissions
|
||||
mkdir -p /config/settings /config/stateful
|
||||
chmod -R 755 /config
|
||||
|
||||
# Convert to lowercase
|
||||
ENABLE_WEB_UI=$(echo "${ENABLE_WEB_UI:-true}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [ "$ENABLE_WEB_UI" = "true" ]; then
|
||||
echo "Starting with Web UI enabled on port 8988"
|
||||
# Start both the web server and the main application
|
||||
python web_server.py &
|
||||
python main.py
|
||||
else
|
||||
echo "Web UI disabled, starting only the main application"
|
||||
# Start only the main application
|
||||
python main.py
|
||||
fi
|
||||
@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
const logsButton = document.getElementById('logsButton');
|
||||
const settingsButton = document.getElementById('settingsButton');
|
||||
const userButton = document.getElementById('userButton');
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
const settingsContainer = document.getElementById('settingsContainer');
|
||||
const logsElement = document.getElementById('logs');
|
||||
@@ -106,18 +107,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Tab switching
|
||||
logsButton.addEventListener('click', function() {
|
||||
logsContainer.style.display = 'flex';
|
||||
settingsContainer.style.display = 'none';
|
||||
logsButton.classList.add('active');
|
||||
settingsButton.classList.remove('active');
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
settingsButton.addEventListener('click', function() {
|
||||
logsContainer.style.display = 'none';
|
||||
settingsContainer.style.display = 'flex';
|
||||
settingsButton.classList.add('active');
|
||||
logsButton.classList.remove('active');
|
||||
loadSettings();
|
||||
window.location.href = '/settings';
|
||||
});
|
||||
|
||||
userButton.addEventListener('click', function() {
|
||||
window.location.href = '/user';
|
||||
});
|
||||
|
||||
// Log management
|
||||
@@ -173,19 +171,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Add change event listeners to all form elements
|
||||
[huntMissingShowsInput, huntUpgradeEpisodesInput, stateResetIntervalInput,
|
||||
apiTimeoutInput, commandWaitDelayInput, commandWaitAttemptsInput,
|
||||
minimumDownloadQueueSizeInput].forEach(input => {
|
||||
input.addEventListener('input', checkForChanges);
|
||||
});
|
||||
if (huntMissingShowsInput && huntUpgradeEpisodesInput && stateResetIntervalInput &&
|
||||
apiTimeoutInput && commandWaitDelayInput && commandWaitAttemptsInput &&
|
||||
minimumDownloadQueueSizeInput) {
|
||||
|
||||
[huntMissingShowsInput, huntUpgradeEpisodesInput, stateResetIntervalInput,
|
||||
apiTimeoutInput, commandWaitDelayInput, commandWaitAttemptsInput,
|
||||
minimumDownloadQueueSizeInput].forEach(input => {
|
||||
input.addEventListener('input', checkForChanges);
|
||||
});
|
||||
}
|
||||
|
||||
[monitoredOnlyInput, randomMissingInput, randomUpgradesInput,
|
||||
skipFutureEpisodesInput, skipSeriesRefreshInput, debugModeInput].forEach(checkbox => {
|
||||
checkbox.addEventListener('change', checkForChanges);
|
||||
});
|
||||
if (monitoredOnlyInput && randomMissingInput && randomUpgradesInput &&
|
||||
skipFutureEpisodesInput && skipSeriesRefreshInput && debugModeInput) {
|
||||
|
||||
[monitoredOnlyInput, randomMissingInput, randomUpgradesInput,
|
||||
skipFutureEpisodesInput, skipSeriesRefreshInput, debugModeInput].forEach(checkbox => {
|
||||
checkbox.addEventListener('change', checkForChanges);
|
||||
});
|
||||
}
|
||||
|
||||
// Load settings from API
|
||||
function loadSettings() {
|
||||
if (!saveSettingsButton) return; // Skip if not on settings page
|
||||
|
||||
fetch('/api/settings')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@@ -311,16 +320,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Add event listeners to both button sets
|
||||
saveSettingsButton.addEventListener('click', saveSettings);
|
||||
resetSettingsButton.addEventListener('click', resetSettings);
|
||||
|
||||
saveSettingsBottomButton.addEventListener('click', saveSettings);
|
||||
resetSettingsBottomButton.addEventListener('click', resetSettings);
|
||||
if (saveSettingsButton && resetSettingsButton && saveSettingsBottomButton && resetSettingsBottomButton) {
|
||||
saveSettingsButton.addEventListener('click', saveSettings);
|
||||
resetSettingsButton.addEventListener('click', resetSettings);
|
||||
|
||||
saveSettingsBottomButton.addEventListener('click', saveSettings);
|
||||
resetSettingsBottomButton.addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Event source for logs
|
||||
let eventSource;
|
||||
|
||||
function connectEventSource() {
|
||||
if (!logsElement) return; // Skip if not on logs page
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
@@ -364,24 +377,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Observe scroll event to detect manual scrolling
|
||||
logsElement.addEventListener('scroll', function() {
|
||||
// If we're at the bottom or near it (within 20px), ensure auto-scroll stays on
|
||||
const atBottom = (logsElement.scrollHeight - logsElement.scrollTop - logsElement.clientHeight) < 20;
|
||||
if (!atBottom && autoScrollCheckbox.checked) {
|
||||
// User manually scrolled up, disable auto-scroll
|
||||
autoScrollCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
if (logsElement) {
|
||||
logsElement.addEventListener('scroll', function() {
|
||||
// If we're at the bottom or near it (within 20px), ensure auto-scroll stays on
|
||||
const atBottom = (logsElement.scrollHeight - logsElement.scrollTop - logsElement.clientHeight) < 20;
|
||||
if (!atBottom && autoScrollCheckbox.checked) {
|
||||
// User manually scrolled up, disable auto-scroll
|
||||
autoScrollCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-enable auto-scroll when checkbox is checked
|
||||
autoScrollCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
if (autoScrollCheckbox) {
|
||||
autoScrollCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
loadTheme();
|
||||
updateSleepDurationDisplay();
|
||||
connectEventSource();
|
||||
if (sleepDurationInput) {
|
||||
updateSleepDurationDisplay();
|
||||
}
|
||||
if (window.location.pathname === '/settings') {
|
||||
loadSettings();
|
||||
}
|
||||
if (logsElement) {
|
||||
connectEventSource();
|
||||
}
|
||||
});
|
||||
@@ -3,17 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Huntarr-Sonarr</title>
|
||||
<title>Huntarr</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" href="/static/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><a href="https://github.com/plexguide/huntarr-sonarr" target="_blank" class="title-link">Huntarr <span class="edition">[Sonarr Edition]</span></a></h1>
|
||||
<h1><a href="https://github.com/plexguide/huntarr" target="_blank" class="title-link">Huntarr <span class="edition">[Sonarr Edition]</span></a></h1>
|
||||
<div class="buttons">
|
||||
<button id="logsButton" class="active">Logs</button>
|
||||
<button id="settingsButton">Settings</button>
|
||||
<button id="userButton">User</button>
|
||||
</div>
|
||||
<div class="theme-toggle">
|
||||
<label class="switch">
|
||||
@@ -165,7 +166,7 @@
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>⭐ Tool Great? <a href="https://donate.plex.one" target="_blank">Donate @ donate.plex.one</a> for Daughter's College Fund! Also, please <a href="https://github.com/plexguide/huntarr-sonarr" target="_blank">star the project on GitHub</a> if you find it useful!</p>
|
||||
<p>⭐ Tool Great? <a href="https://donate.plex.one" target="_blank">Donate @ donate.plex.one</a> for Daughter's College Fund! Also, please <a href="https://github.com/plexguide/huntarr" target="_blank">star the project on GitHub</a> if you find it useful!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
138
templates/login.html
Normal file
138
templates/login.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Huntarr</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" href="/static/favicon.ico">
|
||||
<style>
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
padding: 30px;
|
||||
background-color: var(--log-background);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background-color: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
{% if not error %}display: none;{% endif %}
|
||||
}
|
||||
|
||||
.reset-instructions {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reset-instructions a {
|
||||
color: var(--button-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.reset-instructions a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="dark-theme">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>Login to Huntarr</h1>
|
||||
{% if needs_2fa %}
|
||||
<p>Please enter your verification code</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form class="login-form" method="POST" action="/login">
|
||||
{% if needs_2fa %}
|
||||
<input type="hidden" name="username" value="{{ username }}">
|
||||
<input type="hidden" name="password" value="{{ password }}">
|
||||
<div class="form-group">
|
||||
<label for="otp_code">Verification Code</label>
|
||||
<input type="text" id="otp_code" name="otp_code" placeholder="Enter 6-digit code" maxlength="6" required>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="login-button">
|
||||
{% if needs_2fa %}Verify{% else %}Login{% endif %}
|
||||
</button>
|
||||
<p id="errorMessage" class="error-message">{{ error }}</p>
|
||||
<div class="reset-instructions">
|
||||
<p>Forgot your password? <a href="#" onclick="showResetInstructions(); return false;">Reset it</a></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Allow pressing Enter to login
|
||||
document.querySelector('input[type="password"], input[type="text"]').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
document.querySelector('button[type="submit"]').click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showResetInstructions() {
|
||||
alert('To reset your password:\n\n1. Stop the Huntarr container:\n docker stop huntarr\n\n2. Delete the credentials file:\n rm /path/to/your/config/user/credentials.json\n\n3. Restart the container:\n docker start huntarr\n\nYou will be prompted to create a new account when you access the web interface again.');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
147
templates/setup.html
Normal file
147
templates/setup.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Huntarr</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" href="/static/favicon.ico">
|
||||
<style>
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
padding: 30px;
|
||||
background-color: var(--log-background);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background-color: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="dark-theme">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>Login to Huntarr</h1>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="button" id="loginButton" class="login-button">Login</button>
|
||||
<p id="errorMessage" class="error-message">Invalid username or password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const loginButton = document.getElementById('loginButton');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
|
||||
// Allow pressing Enter to login
|
||||
passwordInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
loginButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Login button click handler
|
||||
loginButton.addEventListener('click', function() {
|
||||
const username = usernameInput.value;
|
||||
const password = passwordInput.value;
|
||||
|
||||
if (!username || !password) {
|
||||
errorMessage.textContent = 'Username and password are required';
|
||||
errorMessage.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Send login request to API
|
||||
fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Redirect to main page on successful login
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
// Show error message
|
||||
errorMessage.textContent = data.message || 'Invalid username or password';
|
||||
errorMessage.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
errorMessage.textContent = 'An error occurred. Please try again.';
|
||||
errorMessage.style.display = 'block';
|
||||
console.error('Login error:', error);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
575
templates/user.html
Normal file
575
templates/user.html
Normal file
@@ -0,0 +1,575 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Settings - Huntarr</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" href="/static/favicon.ico">
|
||||
<style>
|
||||
.user-container {
|
||||
background-color: var(--log-background);
|
||||
border: 1px solid var(--container-border);
|
||||
border-radius: 0 0 10px 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-settings {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background-color: var(--settings-bg);
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-group h3 {
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid var(--settings-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
width: 220px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.setting-item input[type="text"],
|
||||
.setting-item input[type="password"] {
|
||||
width: 300px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.setting-help {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
margin-left: 220px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.secret-key {
|
||||
font-family: monospace;
|
||||
padding: 8px;
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.verification-code {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verification-code input {
|
||||
width: 200px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
font-size: 18px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.verification-code button {
|
||||
padding: 8px 15px;
|
||||
background-color: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-instructions {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: var(--settings-bg);
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reset-instructions h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.reset-instructions pre {
|
||||
background-color: var(--input-bg);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: rgba(39, 174, 96, 0.2);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><a href="https://github.com/plexguide/huntarr" target="_blank" class="title-link">Huntarr <span class="edition">[Sonarr Edition]</span></a></h1>
|
||||
<div class="buttons">
|
||||
<button id="logsButton">Logs</button>
|
||||
<button id="settingsButton">Settings</button>
|
||||
<button id="userButton" class="active">User</button>
|
||||
</div>
|
||||
<div class="theme-toggle">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="themeToggle">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span id="themeLabel">Light Mode</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="userContainer" class="content-section">
|
||||
<div class="user-settings">
|
||||
<h2>User Account Settings</h2>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Change Username</h3>
|
||||
<div class="setting-item">
|
||||
<label for="currentUsername">Current Username:</label>
|
||||
<input type="text" id="currentUsername" placeholder="Enter current username" required>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="newUsername">New Username:</label>
|
||||
<input type="text" id="newUsername" placeholder="Enter new username" required>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="passwordForUsername">Password:</label>
|
||||
<input type="password" id="passwordForUsername" placeholder="Enter your password to confirm" required>
|
||||
</div>
|
||||
<div class="settings-buttons">
|
||||
<button id="changeUsernameButton" class="save-button">Change Username</button>
|
||||
</div>
|
||||
<div id="usernameStatus" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Change Password</h3>
|
||||
<div class="setting-item">
|
||||
<label for="currentPassword">Current Password:</label>
|
||||
<input type="password" id="currentPassword" placeholder="Enter current password" required>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="newPassword">New Password:</label>
|
||||
<input type="password" id="newPassword" placeholder="Enter new password" required>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="confirmNewPassword">Confirm New Password:</label>
|
||||
<input type="password" id="confirmNewPassword" placeholder="Confirm new password" required>
|
||||
</div>
|
||||
<div class="settings-buttons">
|
||||
<button id="changePasswordButton" class="save-button">Change Password</button>
|
||||
</div>
|
||||
<div id="passwordStatus" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Two-Factor Authentication (2FA)</h3>
|
||||
<div class="setting-item">
|
||||
<label for="enable2FA">Enable 2FA:</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="enable2FA">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<p class="setting-help">Enable two-factor authentication for additional security</p>
|
||||
</div>
|
||||
|
||||
<div id="setup2FAContainer" class="qr-code-container">
|
||||
<p>Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)</p>
|
||||
<div class="qr-code" id="qrCode">
|
||||
<!-- QR code will be inserted here -->
|
||||
</div>
|
||||
<p>Or enter this code manually in your app:</p>
|
||||
<div class="secret-key" id="secretKey">ABCDEFGHIJKLMNOP</div>
|
||||
|
||||
<div class="verification-code">
|
||||
<input type="text" id="verificationCode" placeholder="Enter 6-digit code" maxlength="6">
|
||||
<button id="verifyCodeButton">Verify & Enable</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="remove2FAContainer" style="display: none;">
|
||||
<p>Two-factor authentication is currently enabled. To disable it, enter your password:</p>
|
||||
<div class="setting-item">
|
||||
<label for="passwordFor2FA">Password:</label>
|
||||
<input type="password" id="passwordFor2FA" placeholder="Enter your password to confirm">
|
||||
</div>
|
||||
<div class="settings-buttons">
|
||||
<button id="disable2FAButton" class="reset-button">Disable 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="2faStatus" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="reset-instructions">
|
||||
<h3>Forgot Password?</h3>
|
||||
<p>If you've forgotten your password, you'll need to reset it by removing the credentials file:</p>
|
||||
<ol>
|
||||
<li>Stop the Huntarr container:
|
||||
<pre>docker stop huntarr</pre>
|
||||
</li>
|
||||
<li>Delete the credentials file from your configuration volume:
|
||||
<pre>rm /path/to/your/config/user/credentials.json</pre>
|
||||
<p>For typical Docker setups, this would be something like:</p>
|
||||
<pre>rm /mnt/user/appdata/huntarr/user/credentials.json</pre>
|
||||
</li>
|
||||
<li>Restart the Huntarr container:
|
||||
<pre>docker start huntarr</pre>
|
||||
</li>
|
||||
<li>You'll be prompted to create a new account when accessing the web interface again.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>⭐ Tool Great? <a href="https://donate.plex.one" target="_blank">Donate @ donate.plex.one</a> for Daughter's College Fund! Also, please <a href="https://github.com/plexguide/huntarr" target="_blank">star the project on GitHub</a> if you find it useful!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/main.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const logsButton = document.getElementById('logsButton');
|
||||
const settingsButton = document.getElementById('settingsButton');
|
||||
const userButton = document.getElementById('userButton');
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const themeLabel = document.getElementById('themeLabel');
|
||||
const enable2FACheckbox = document.getElementById('enable2FA');
|
||||
const setup2FAContainer = document.getElementById('setup2FAContainer');
|
||||
const remove2FAContainer = document.getElementById('remove2FAContainer');
|
||||
const changeUsernameButton = document.getElementById('changeUsernameButton');
|
||||
const changePasswordButton = document.getElementById('changePasswordButton');
|
||||
const verifyCodeButton = document.getElementById('verifyCodeButton');
|
||||
const disable2FAButton = document.getElementById('disable2FAButton');
|
||||
|
||||
// Load theme
|
||||
fetch('/api/settings/theme')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const isDarkMode = data.dark_mode || false;
|
||||
if (isDarkMode) {
|
||||
document.body.classList.add('dark-theme');
|
||||
themeToggle.checked = true;
|
||||
themeLabel.textContent = 'Dark Mode';
|
||||
} else {
|
||||
document.body.classList.remove('dark-theme');
|
||||
themeToggle.checked = false;
|
||||
themeLabel.textContent = 'Light Mode';
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error loading theme:', error));
|
||||
|
||||
// Theme toggle
|
||||
themeToggle.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
document.body.classList.add('dark-theme');
|
||||
themeLabel.textContent = 'Dark Mode';
|
||||
} else {
|
||||
document.body.classList.remove('dark-theme');
|
||||
themeLabel.textContent = 'Light Mode';
|
||||
}
|
||||
|
||||
fetch('/api/settings/theme', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ dark_mode: this.checked })
|
||||
})
|
||||
.catch(error => console.error('Error saving theme:', error));
|
||||
});
|
||||
|
||||
// Navigation
|
||||
logsButton.addEventListener('click', function() {
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
settingsButton.addEventListener('click', function() {
|
||||
window.location.href = '/settings';
|
||||
});
|
||||
|
||||
// Check 2FA status
|
||||
fetch('/api/user/2fa-status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.enabled) {
|
||||
enable2FACheckbox.checked = true;
|
||||
setup2FAContainer.style.display = 'none';
|
||||
remove2FAContainer.style.display = 'block';
|
||||
} else {
|
||||
enable2FACheckbox.checked = false;
|
||||
setup2FAContainer.style.display = 'none';
|
||||
remove2FAContainer.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error checking 2FA status:', error));
|
||||
|
||||
// 2FA toggle
|
||||
enable2FACheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
// Generate new 2FA secret
|
||||
fetch('/api/user/generate-2fa')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('qrCode').innerHTML = `<img src="${data.qr_code_url}" alt="QR Code">`;
|
||||
document.getElementById('secretKey').textContent = data.secret;
|
||||
setup2FAContainer.style.display = 'block';
|
||||
remove2FAContainer.style.display = 'none';
|
||||
} else {
|
||||
showStatus('2faStatus', data.message, false);
|
||||
enable2FACheckbox.checked = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error generating 2FA:', error);
|
||||
showStatus('2faStatus', 'Failed to generate 2FA secret', false);
|
||||
enable2FACheckbox.checked = false;
|
||||
});
|
||||
} else {
|
||||
setup2FAContainer.style.display = 'none';
|
||||
remove2FAContainer.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Verify 2FA code
|
||||
verifyCodeButton.addEventListener('click', function() {
|
||||
const code = document.getElementById('verificationCode').value;
|
||||
if (!code) {
|
||||
showStatus('2faStatus', 'Please enter the verification code', false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/user/verify-2fa', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code: code })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showStatus('2faStatus', 'Two-factor authentication enabled successfully!', true);
|
||||
setup2FAContainer.style.display = 'none';
|
||||
remove2FAContainer.style.display = 'block';
|
||||
} else {
|
||||
showStatus('2faStatus', data.message || 'Invalid verification code', false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error verifying 2FA code:', error);
|
||||
showStatus('2faStatus', 'Failed to verify code', false);
|
||||
});
|
||||
});
|
||||
|
||||
// Disable 2FA
|
||||
disable2FAButton.addEventListener('click', function() {
|
||||
const password = document.getElementById('passwordFor2FA').value;
|
||||
if (!password) {
|
||||
showStatus('2faStatus', 'Please enter your password', false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/user/disable-2fa', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ password: password })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showStatus('2faStatus', 'Two-factor authentication disabled successfully!', true);
|
||||
enable2FACheckbox.checked = false;
|
||||
setup2FAContainer.style.display = 'none';
|
||||
remove2FAContainer.style.display = 'none';
|
||||
document.getElementById('passwordFor2FA').value = '';
|
||||
} else {
|
||||
showStatus('2faStatus', data.message || 'Invalid password', false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error disabling 2FA:', error);
|
||||
showStatus('2faStatus', 'Failed to disable 2FA', false);
|
||||
});
|
||||
});
|
||||
|
||||
// Change username
|
||||
changeUsernameButton.addEventListener('click', function() {
|
||||
const currentUsername = document.getElementById('currentUsername').value;
|
||||
const newUsername = document.getElementById('newUsername').value;
|
||||
const password = document.getElementById('passwordForUsername').value;
|
||||
|
||||
if (!currentUsername || !newUsername || !password) {
|
||||
showStatus('usernameStatus', 'All fields are required', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUsername === newUsername) {
|
||||
showStatus('usernameStatus', 'New username must be different from current username', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm('Changing your username will log you out. Continue?')) {
|
||||
fetch('/api/user/change-username', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_username: currentUsername,
|
||||
new_username: newUsername,
|
||||
password: password
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showStatus('usernameStatus', 'Username changed successfully! You will be logged out...', true);
|
||||
setTimeout(() => {
|
||||
window.location.href = '/logout';
|
||||
}, 2000);
|
||||
} else {
|
||||
showStatus('usernameStatus', data.message || 'Failed to change username', false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error changing username:', error);
|
||||
showStatus('usernameStatus', 'Failed to change username', false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
changePasswordButton.addEventListener('click', function() {
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmNewPassword = document.getElementById('confirmNewPassword').value;
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmNewPassword) {
|
||||
showStatus('passwordStatus', 'All fields are required', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
showStatus('passwordStatus', 'New passwords do not match', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showStatus('passwordStatus', 'New password must be different from current password', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm('Changing your password will log you out. Continue?')) {
|
||||
fetch('/api/user/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showStatus('passwordStatus', 'Password changed successfully! You will be logged out...', true);
|
||||
setTimeout(() => {
|
||||
window.location.href = '/logout';
|
||||
}, 2000);
|
||||
} else {
|
||||
showStatus('passwordStatus', data.message || 'Failed to change password', false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error changing password:', error);
|
||||
showStatus('passwordStatus', 'Failed to change password', false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to show status messages
|
||||
function showStatus(elementId, message, isSuccess) {
|
||||
const statusElement = document.getElementById(elementId);
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = 'status-message ' + (isSuccess ? 'status-success' : 'status-error');
|
||||
statusElement.style.display = 'block';
|
||||
|
||||
// Hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
statusElement.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Utility functions for Huntarr-Sonarr
|
||||
"""
|
||||
|
||||
from utils.logger import logger, debug_log
|
||||
|
||||
__all__ = ['logger', 'debug_log']
|
||||
Reference in New Issue
Block a user