inital 4 push

This commit is contained in:
Admin9705
2025-04-09 21:20:33 -04:00
parent 3f50ad504c
commit 55a1c14221
27 changed files with 2119 additions and 469 deletions

View File

@@ -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)

View File

@@ -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
View File

@@ -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
View 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
View 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
View 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
View 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()

View File

@@ -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}")

View File

@@ -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
View 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
View 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
View 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

View File

@@ -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")

View File

@@ -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:

View File

@@ -0,0 +1,7 @@
"""
Utility functions for Huntarr
"""
from primary.utils.logger import logger, debug_log
__all__ = ['logger', 'debug_log']

View File

@@ -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[:]:

View File

@@ -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)

View File

@@ -1,2 +0,0 @@
requests>=2.25.0
flask>=2.0.0

View File

@@ -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)

View File

@@ -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

View File

@@ -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();
}
});

View File

@@ -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
View 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
View 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
View 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>

View File

@@ -1,7 +0,0 @@
"""
Utility functions for Huntarr-Sonarr
"""
from utils.logger import logger, debug_log
__all__ = ['logger', 'debug_log']