diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index f2ec4a2e..a131aede 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -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) diff --git a/Dockerfile b/Dockerfile index c710246f..adc9fd29 100644 --- a/Dockerfile +++ b/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 . +EXPOSE 9705 + +# 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"] \ No newline at end of file diff --git a/README.md b/README.md index 23540a58..56ef2dd0 100644 --- a/README.md +++ b/README.md @@ -14,25 +14,25 @@ -**NOTE**: Working to Intergrate Apps into UI and Drop Extra Variables. +**NOTE**: Working to Intergrate Other Applications! If making changes in the UI do not appear to take effect after saving, type: `docker restart hunter` * Sonarr [Good] * Radarr [Not Incorporated Yet] * Lidarr [Not Incorporated Yet] -* Readarr [ Not Incorporated Yet] +* Readarr [Not Incorporated Yet] +## WARNING -**NOTE**: This utilizes Sonarr API Version - `5`. Legacy name of this program: Sonarr Hunter. +This uses a new repo `huntarr\huntarr` and does not utilize the env variable and requires the UI. Please read the documentation. To utilize legacy Huntarr-Sonarr, please view this [Backup Page](https://null.com) **Change Log:** -Visit: https://github.com/plexguide/Huntarr-Sonarr/releases/ +Visit: https://github.com/plexguide/Huntarr/releases/ ## Table of Contents - [Overview](#overview) - [Related Projects](#related-projects) - [Features](#features) - [How It Works](#how-it-works) -- [Configuration Options](#configuration-options) - [Web Interface](#web-interface) - [Persistent Storage](#persistent-storage) - [Installation Methods](#installation-methods) @@ -46,7 +46,9 @@ Visit: https://github.com/plexguide/Huntarr-Sonarr/releases/ ## Overview -This script continually searches your Sonarr library for shows with missing episodes and episodes that need quality upgrades. It automatically triggers searches for both missing episodes and episodes below your quality cutoff. It's designed to run continuously while being gentle on your indexers, helping you gradually complete your TV show collection with the best available quality. +This application continually searches your media libraries for missing content and items that need quality upgrades. It automatically triggers searches for both missing items and those below your quality cutoff. It's designed to run continuously while being gentle on your indexers, helping you gradually complete your media collection with the best available quality. + +For detailed documentation, please visit our [Wiki](https://github.com/plexguide/Huntarr/wiki). ## Related Projects @@ -65,17 +67,17 @@ My 12-year-old daughter is passionate about singing, dancing, and exploring STEM ## Features - 🔄 **Continuous Operation**: Runs indefinitely until manually stopped -- 🎯 **Dual Targeting System**: Targets both missing episodes and quality upgrades -- 🎲 **Separate Random Controls**: Separate toggles for random missing shows and random upgrades +- 🎯 **Dual Targeting System**: Targets both missing items and quality upgrades +- 🎲 **Separate Random Controls**: Separate toggles for random missing content and random upgrades - ⏱️ **Throttled Searches**: Includes configurable delays to prevent overloading indexers -- 📊 **Status Reporting**: Provides clear feedback about what it's doing and which shows it's searching for +- 📊 **Status Reporting**: Provides clear feedback about what it's doing and which items it's searching for - 🛡️ **Error Handling**: Gracefully handles connection issues and API failures -- 🔁 **State Tracking**: Remembers which shows and episodes have been processed to avoid duplicate searches +- 🔁 **State Tracking**: Remembers which items have been processed to avoid duplicate searches - ⚙️ **Configurable Reset Timer**: Automatically resets search history after a configurable period - 📦 **Modular Design**: Modern codebase with separated concerns for easier maintenance - 🌐 **Web Interface**: Real-time log viewer with day/night mode and settings management -- 🔮 **Future Episode Skipping**: Skip processing episodes with future air dates -- 💾 **Reduced Disk Activity**: Option to skip series refresh before processing +- 🔮 **Future Item Skipping**: Skip processing items with future release dates +- 💾 **Reduced Disk Activity**: Option to skip metadata refresh before processing - 💿 **Persistent Configuration**: All settings are saved to disk and persist across container restarts - 📝 **Stateful Operation**: Processed state is now permanently saved between restarts - ⚙️ **Advanced Settings**: Control API timeout, command wait parameters, and more @@ -85,20 +87,20 @@ My 12-year-old daughter is passionate about singing, dancing, and exploring STEM ## How It Works -1. **Initialization**: Connects to your Sonarr instance and analyzes your library -2. **Missing Episodes**: - - Identifies shows with missing episodes - - Randomly or sequentially selects shows to process (configurable) +1. **Initialization**: Connects to your *Arr instance and analyzes your library +2. **Missing Content**: + - Identifies items with missing episodes/movies/etc. + - Randomly or sequentially selects items to process (configurable) - Refreshes metadata (optional) and triggers searches - - Skips episodes with future air dates (configurable) + - Skips items with future release dates (configurable) 3. **Quality Upgrades**: - - Finds episodes that don't meet your quality cutoff settings + - Finds items that don't meet your quality cutoff settings - Processes them in configurable batches - Uses smart pagination to handle large libraries - Can operate in random or sequential mode (configurable) - - Skips episodes with future air dates (configurable) + - Skips items with future release dates (configurable) 4. **State Management**: - - Tracks which shows and episodes have been processed + - Tracks which items have been processed - Stores this information persistently in the `/config` volume - Automatically resets this tracking after a configurable time period 5. **Repeat Cycle**: Waits for a configurable period before starting the next cycle @@ -107,7 +109,7 @@ My 12-year-old daughter is passionate about singing, dancing, and exploring STEM -

Missing Episodes Demo

+

Missing Content Demo

@@ -122,108 +124,9 @@ My 12-year-old daughter is passionate about singing, dancing, and exploring STEM -## Configuration Options - -The following environment variables can be configured: - -| Variable | Description | Default | -|-------------------------------|-----------------------------------------------------------------------|---------------| -| `API_KEY` | Your Sonarr API key | Required | -| `API_URL` | URL to your Sonarr instance | Required | -| `API_TIMEOUT` | Timeout in seconds for API requests to Sonarr | 60 | -| `MONITORED_ONLY` | Only process monitored shows/episodes | true | -| `HUNT_MISSING_SHOWS` | Maximum missing shows to process per cycle | 1 | -| `HUNT_UPGRADE_EPISODES` | Maximum upgrade episodes to process per cycle | 5 | -| `SLEEP_DURATION` | Seconds to wait after completing a cycle (900 = 15 minutes) | 900 | -| `RANDOM_MISSING` | Select missing shows randomly instead of sequentially | true | -| `RANDOM_UPGRADES` | Select upgrade episodes randomly instead of sequentially | true | -| `STATE_RESET_INTERVAL_HOURS` | Hours which the processed state files reset (168=1 week, 0=never reset) | 168 | -| `DEBUG_MODE` | Enable detailed debug logging (`true` or `false`) | false | -| `ENABLE_WEB_UI` | Enable or disable the web interface (`true` or `false`) | true | -| `SKIP_FUTURE_EPISODES` | Skip processing episodes with future air dates (`true` or `false`) | true | -| `SKIP_SERIES_REFRESH` | Skip refreshing series metadata before processing (`true` or `false`) | false | - -### Advanced Options (Optional) - -| Variable | Description | Default | -|-------------------------------|-----------------------------------------------------------------------|---------------| -| `COMMAND_WAIT_DELAY` | Delay in seconds between checking for command status | 1 | -| `COMMAND_WAIT_ATTEMPTS` | Number of attempts to check for command completion before giving up | 600 | -| `MINIMUM_DOWNLOAD_QUEUE_SIZE` | Minimum number of items in the download queue before starting a hunt | -1 | - -### Detailed Configuration Explanation - -- **API_TIMEOUT** - - Sets the maximum number of seconds to wait for Sonarr API responses before timing out. - - This is particularly important when working with large libraries or when checking for many quality upgrades. - - If you experience timeout errors (especially during the "Checking for Quality Upgrades" phase), increase this value. - - For libraries with thousands of episodes needing quality upgrades, values of 90-120 seconds may be necessary. - - Default is 60 seconds, which works well for most medium-sized libraries. - -- **HUNT_MISSING_SHOWS** - - Sets the maximum number of missing shows to process in each cycle. - - Once this limit is reached, the script stops processing further missing shows until the next cycle. - - Set to `0` to disable missing show processing completely. - -- **HUNT_UPGRADE_EPISODES** - - Sets the maximum number of upgrade episodes to process in each cycle. - - When this limit is reached, the upgrade portion of the cycle stops. - - Set to `0` to disable quality upgrade processing completely. - -- **RANDOM_MISSING** - - When `true`, selects missing shows randomly, which helps distribute searches across your library. - - When `false`, processes missing shows sequentially, which can be more predictable and methodical. - -- **RANDOM_UPGRADES** - - When `true`, selects episodes for quality upgrades randomly from different pages. - - When `false`, processes episodes sequentially beginning from page 1. - -- **STATE_RESET_INTERVAL_HOURS** - - Controls how often the script "forgets" which items it has already processed. - - The script records the IDs of missing shows and upgrade episodes that have been processed. - - When the age of these records exceeds the number of hours set by this variable, the records are cleared automatically. - - This reset allows the script to re-check items that were previously processed. - - Setting this to `0` will disable the reset functionality entirely - processed items will be remembered indefinitely. - - Default is 168 hours (one week) - meaning the script will start fresh weekly. - -- **DEBUG_MODE** - - When set to `true`, the script will output detailed debugging information about API responses and internal operations. - - Useful for troubleshooting issues but can make logs verbose. - -- **ENABLE_WEB_UI** - - When set to `true`, the web interface will be enabled on port 8988. - - When set to `false`, the web interface will not start, saving resources. - - Default is `true` for convenient monitoring. - -- **SKIP_FUTURE_EPISODES** - - When set to `true`, the script will skip processing episodes with future air dates. - - This helps avoid unnecessary searches for content that isn't available yet. - - Works for both missing episodes and quality upgrade processing. - - Default is `true` to optimize search efficiency. - -- **SKIP_SERIES_REFRESH** - - When set to `true`, the script will skip refreshing series metadata before searching. - - This can significantly reduce disk activity on your Sonarr server. - - Default is `false` to maintain compatibility with previous behavior. - - Set to `true` if you notice excessive disk activity during Huntarr cycles. - -- **COMMAND_WAIT_DELAY** - - Certain operations like refreshing and searching happen asynchronously. - - This is the delay in seconds between checking the status of these operations for completion. - - By checking for these to complete before proceeding we can ensure we do not overload the command queue. - - Operations like refreshing update show metadata so this ensures those actions are fully completed before additional operations are performed. - -- **COMMAND_WAIT_ATTEMPTS** - - The number of attempts to wait for an operation to complete before giving up. If a command times out the operation will be considered failed. - -- **MINIMUM_DOWNLOAD_QUEUE_SIZE** - - The minimum number of items in the download queue before a new hunt is initiated. For example if set to `5` then a new hunt will only start when there are 5 or less items marked as `downloading` in the queue. - - This helps prevent overwhelming the queue with too many download requests at once and avoids creating a massive backlog of downloads. - - Set to `-1` to disable this check. - ## Web Interface -Huntarr-Sonarr includes a real-time log viewer and settings management web interface that allows you to monitor and configure its operation directly from your browser. +Huntarr includes a real-time log viewer and settings management web interface that allows you to monitor and configure its operation directly from your browser. @@ -246,17 +149,17 @@ Huntarr-Sonarr includes a real-time log viewer and settings management web inter ### How to Access -The web interface is available on port 8988. Simply navigate to: +The web interface is available on port 9705. Simply navigate to: ``` -http://YOUR_SERVER_IP:8988 +http://YOUR_SERVER_IP:9705 ``` The URL will be displayed in the logs when Huntarr starts, using the same hostname you configured for your API_URL. ### Web UI Settings -The web interface allows you to configure all of Huntarr's settings without having to restart the container: +The web interface allows you to configure all of Huntarr's settings:
@@ -267,66 +170,26 @@ The web interface allows you to configure all of Huntarr's settings without havi
-- **Hunt Settings** - - **Hunt Missing Shows**: Maximum number of missing shows to process per cycle - - **Hunt Upgrade Episodes**: Maximum number of episodes to upgrade per cycle - -- **Timing Settings** - - **Sleep Duration**: Time to wait between cycles (in seconds) - - **State Reset Interval**: Hours after which processed items will be forgotten - -- **Processing Options** - - **Monitored Only**: Only process monitored shows and episodes - - **Random Missing Shows**: Select missing shows randomly instead of sequentially - - **Random Upgrades**: Select upgrade episodes randomly instead of sequentially - - **Skip Future Episodes**: Skip processing episodes with future air dates - - **Skip Series Refresh**: Skip refreshing series metadata before processing - -- **Advanced Settings** - - **API Timeout**: Timeout in seconds for API requests to Sonarr - - **Debug Mode**: Enable detailed debug logging - - **Command Wait Delay**: Delay between checking command status - - **Command Wait Attempts**: Number of attempts before giving up - - **Minimum Queue Size**: Minimum download queue size threshold - -### Port Configuration Explained - -When running with Docker, you need to map the container's internal port to a port on your host system. The format is `HOST_PORT:CONTAINER_PORT`. - -For example: -- `8988:8988` means "map port 8988 from the host to port 8988 in the container" - -If you want to use a different port on your host (e.g., 9000), you would use: -- `9000:8988` means "map port 9000 from the host to port 8988 in the container" - -You would then access the web interface at `http://YOUR_SERVER_IP:9000` - -### Enabling/Disabling the Web UI - -The web interface can be enabled or disabled using the `ENABLE_WEB_UI` environment variable: - -- `ENABLE_WEB_UI=true` - Enable the web interface (default) -- `ENABLE_WEB_UI=false` - Disable the web interface - -If you disable the web interface, you don't need to expose the port in your Docker configuration. +All settings are now configured entirely through the web UI after initial setup. ## Persistent Storage -Huntarr-Sonarr now stores all its configuration and state information in persistent storage, ensuring your settings and processed state are maintained across container restarts and updates. +Huntarr stores all its configuration and state information in persistent storage, ensuring your settings and processed state are maintained across container restarts and updates. ### Storage Locations The following directories are used for persistent storage: - `/config/settings/` - Contains configuration settings (huntarr.json) -- `/config/stateful/` - Contains the state tracking files for processed shows and episodes +- `/config/stateful/` - Contains the state tracking files for processed items +- `/config/user/` - Contains user authentication information ### Data Persistence All data in these directories is maintained across container restarts. This means: 1. Your settings configured via the web UI will be preserved -2. The list of shows and episodes that have already been processed will be maintained +2. The list of items that have already been processed will be maintained 3. After a container update or restart, Huntarr will continue from where it left off ### Volume Mapping @@ -334,7 +197,7 @@ All data in these directories is maintained across container restarts. This mean To ensure data persistence, make sure you map the `/config` directory to a persistent volume on your host system: ```bash --v /mnt/user/appdata/huntarr-sonarr:/config +-v /mnt/user/appdata/huntarr:/config ``` This mapping is included in all of the installation examples below. @@ -345,36 +208,19 @@ This mapping is included in all of the installation examples below. ### Docker Run -The simplest way to run Huntarr is via Docker: +The simplest way to run Huntarr is via Docker (all configuration is done via the web UI): ```bash -docker run -d --name huntarr-sonarr \ +docker run -d --name huntarr \ --restart always \ - -p 8988:8988 \ - -v /mnt/user/appdata/huntarr-sonarr:/config \ - -e API_KEY="your-api-key" \ - -e API_URL="http://your-sonarr-address:8989" \ - -e API_TIMEOUT="60" \ - -e MONITORED_ONLY="true" \ - -e HUNT_MISSING_SHOWS="1" \ - -e HUNT_UPGRADE_EPISODES="0" \ - -e SLEEP_DURATION="900" \ - -e STATE_RESET_INTERVAL_HOURS="168" \ - -e DEBUG_MODE="false" \ - -e ENABLE_WEB_UI="true" \ - -e SKIP_FUTURE_EPISODES="true" \ - -e SKIP_SERIES_REFRESH="false" \ - -e COMMAND_WAIT_DELAY="1" \ - -e COMMAND_WAIT_ATTEMPTS="600" \ - -e MINIMUM_DOWNLOAD_QUEUE_SIZE="-1" \ - -e RANDOM_MISSING="true" \ - -e RANDOM_UPGRADES="true" \ - huntarr/4sonarr:latest + -p 9705:9705 \ + -v /mnt/user/appdata/huntarr:/config \ + huntarr/huntarr:latest ``` -To check on the status of the program, you can use the web interface at http://YOUR_SERVER_IP:8988 or check the logs with: +To check on the status of the program, you can use the web interface at http://YOUR_SERVER_IP:9705 or check the logs with: ```bash -docker logs huntarr-sonarr +docker logs huntarr ``` ### Docker Compose @@ -384,38 +230,20 @@ For those who prefer Docker Compose, add this to your `docker-compose.yml` file: ```yaml version: "3.8" services: - huntarr-sonarr: - image: huntarr/4sonarr:latest - container_name: huntarr-sonarr + huntarr: + image: huntarr/huntarr:latest + container_name: huntarr restart: always ports: - - "8988:8988" + - "9705:9705" volumes: - - /mnt/user/appdata/huntarr-sonarr:/config - environment: - API_KEY: "your-api-key" - API_URL: "http://your-sonarr-address:8989" - 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" - ENABLE_WEB_UI: "true" - 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" + - /mnt/user/appdata/huntarr:/config ``` Then run: ```bash -docker-compose up -d huntarr-sonarr +docker-compose up -d huntarr ``` ### Unraid Users @@ -423,64 +251,30 @@ docker-compose up -d huntarr-sonarr Run this from Command Line in Unraid: ```bash -docker run -d --name huntarr-sonarr \ +docker run -d --name huntarr \ --restart always \ - -p 8988:8988 \ - -v /mnt/user/appdata/huntarr-sonarr:/config \ - -e API_KEY="your-api-key" \ - -e API_URL="http://your-sonarr-address:8989" \ - -e API_TIMEOUT="60" \ - -e MONITORED_ONLY="true" \ - -e HUNT_MISSING_SHOWS="1" \ - -e HUNT_UPGRADE_EPISODES="0" \ - -e SLEEP_DURATION="900" \ - -e STATE_RESET_INTERVAL_HOURS="168" \ - -e DEBUG_MODE="false" \ - -e ENABLE_WEB_UI="true" \ - -e SKIP_FUTURE_EPISODES="true" \ - -e SKIP_SERIES_REFRESH="false" \ - -e COMMAND_WAIT_DELAY="1" \ - -e COMMAND_WAIT_ATTEMPTS="600" \ - -e MINIMUM_DOWNLOAD_QUEUE_SIZE="-1" \ - -e RANDOM_MISSING="true" \ - -e RANDOM_UPGRADES="true" \ - huntarr/4sonarr:latest + -p 9705:9705 \ + -v /mnt/user/appdata/huntarr:/config \ + huntarr/huntarr:latest ``` ### SystemD Service For a more permanent installation on Linux systems using SystemD: -1. Save the script to `/usr/local/bin/huntarr.sh` +1. Save a script with the Docker run command to `/usr/local/bin/huntarr.sh` 2. Make it executable: `chmod +x /usr/local/bin/huntarr.sh` 3. Create a systemd service file at `/etc/systemd/system/huntarr.service`: ```ini [Unit] Description=Huntarr Service -After=network.target sonarr.service +After=docker.service [Service] Type=simple -User=your-username -Environment="API_KEY=your-api-key" -Environment="API_URL=http://localhost:8989" -Environment="API_TIMEOUT=60" -Environment="MONITORED_ONLY=true" -Environment="HUNT_MISSING_SHOWS=1" -Environment="HUNT_UPGRADE_EPISODES=0" -Environment="SLEEP_DURATION=900" -Environment="STATE_RESET_INTERVAL_HOURS=168" -Environment="DEBUG_MODE=false" -Environment="ENABLE_WEB_UI=true" -Environment="SKIP_FUTURE_EPISODES=true" -Environment="SKIP_SERIES_REFRESH=false" -Environment="COMMAND_WAIT_DELAY=1" -Environment="COMMAND_WAIT_ATTEMPTS=600" -Environment="MINIMUM_DOWNLOAD_QUEUE_SIZE=-1" -Environment="RANDOM_MISSING=true" -Environment="RANDOM_UPGRADES=true" -ExecStartPre=/bin/sleep 30 +User=root +ExecStartPre=/bin/sleep 10 ExecStart=/usr/local/bin/huntarr.sh Restart=on-failure RestartSec=10 @@ -498,56 +292,56 @@ sudo systemctl start huntarr ## Use Cases -- **Library Completion**: Gradually fill in missing episodes of TV shows -- **Quality Improvement**: Automatically upgrade episode quality as better versions become available -- **New Show Setup**: Automatically find episodes for newly added shows +- **Library Completion**: Gradually fill in missing content in your media library +- **Quality Improvement**: Automatically upgrade item quality as better versions become available +- **New Item Setup**: Automatically find media for newly added items - **Background Service**: Run it in the background to continuously maintain your library - **Smart Rotation**: With state tracking, ensures all content gets attention over time - **Real-time Monitoring**: Use the web interface to see what's happening at any time - **Disk Usage Optimization**: Skip refreshing metadata to reduce disk wear and tear -- **Efficient Searching**: Skip processing episodes with future air dates to save resources +- **Efficient Searching**: Skip processing items with future release dates to save resources - **Persistent Configuration**: Save your settings once and have them persist through updates - **Stateful Operation**: Maintain processing state across container restarts and updates ## Tips -- **First-Time Use**: Start with default settings to ensure it works with your setup +- **First-Time Setup**: After installation, navigate to the web interface and create your administrator account +- **API Connection**: Configure the connection to your *Arr application through the Settings page - **Web Interface**: Use the web interface to adjust settings without restarting the container -- **Adjusting Speed**: Lower the `SLEEP_DURATION` to search more frequently (be careful with indexer limits) -- **Batch Size Control**: Adjust `HUNT_MISSING_SHOWS` and `HUNT_UPGRADE_EPISODES` based on your indexer's rate limits -- **Monitored Status**: Set `MONITORED_ONLY=false` if you want to download all missing episodes regardless of monitored status -- **System Resources**: The script uses minimal resources and can run continuously on even low-powered systems -- **Port Conflicts**: If port 8988 is already in use, map to a different host port (e.g., `-p 9000:8988`) -- **Disable Web UI**: Set `ENABLE_WEB_UI=false` if you don't need the interface to save resources -- **Debugging Issues**: Enable `DEBUG_MODE=true` temporarily to see detailed logs when troubleshooting -- **Hard Drive Saving**: Enable `SKIP_SERIES_REFRESH=true` to reduce disk activity -- **Search Efficiency**: Keep `SKIP_FUTURE_EPISODES=true` to avoid searching for unavailable content +- **Adjusting Speed**: Lower the Sleep Duration to search more frequently (be careful with indexer limits) +- **Batch Size Control**: Adjust Hunt Missing and Hunt Upgrade values based on your indexer's rate limits +- **Monitored Status**: Set Monitored Only to false if you want to download all missing content regardless of monitored status +- **System Resources**: The application uses minimal resources and can run continuously on even low-powered systems +- **Port Conflicts**: If port 9705 is already in use, map to a different host port (e.g., `-p 8080:9705`) +- **Debugging Issues**: Enable Debug Mode temporarily to see detailed logs when troubleshooting +- **Hard Drive Saving**: Enable Skip Series Refresh to reduce disk activity +- **Search Efficiency**: Keep Skip Future Episodes enabled to avoid searching for unavailable content - **Persistent Storage**: Make sure to map the `/config` volume to preserve settings and state - **Dark Mode**: Toggle between light and dark themes in the web interface for comfortable viewing - **Settings Persistence**: Any settings changed in the web UI are saved immediately and permanently -- **Random vs Sequential**: Configure `RANDOM_MISSING` and `RANDOM_UPGRADES` based on your preference for processing style +- **Random vs Sequential**: Configure Random Missing and Random Upgrades based on your preference for processing style ## Troubleshooting -- **API Key Issues**: Check that your API key is correct in Sonarr settings -- **Connection Problems**: Ensure the Sonarr URL is accessible from where you're running the script -- **Command Failures**: If search commands fail, try using the Sonarr UI to verify what commands are available in your version -- **Web Interface Not Loading**: Make sure port 8988 is exposed in your Docker configuration and not blocked by a firewall -- **Logs**: Check the container logs with `docker logs huntarr-sonarr` if running in Docker, or use the web interface -- **Debug Mode**: Enable `DEBUG_MODE=true` to see detailed API responses and process flow +- **API Key Issues**: Check that your API key is correct in the Settings page +- **Connection Problems**: Ensure the API URL is accessible from where you're running the application +- **Login Issues**: If you forget your password, you will need to delete the credentials file at `/config/user/credentials.json` and restart the container +- **Web Interface Not Loading**: Make sure port 9705 is exposed in your Docker configuration and not blocked by a firewall +- **Logs**: Check the container logs with `docker logs huntarr` if running in Docker, or use the web interface +- **Debug Mode**: Enable Debug Mode in the Advanced Settings to see detailed API responses and process flow - **Settings Not Persisting**: Verify your volume mount for `/config` is configured correctly -- **State Files**: The script stores state in `/config/stateful/` - if something seems stuck, you can try deleting these files -- **Excessive Disk Activity**: If you notice high disk usage, try enabling `SKIP_SERIES_REFRESH=true` -- **Web UI URL Issues**: The web interface URL shown in logs is now derived from your API_URL setting -- **API Timeout Errors**: For large libraries, try increasing the `API_TIMEOUT` value to 120 or higher +- **State Files**: The application stores state in `/config/stateful/` - if something seems stuck, you can try deleting these files +- **Excessive Disk Activity**: If you notice high disk usage, try enabling Skip Series Refresh +- **Configuration Issues**: Settings now require a container restart to take effect - confirm the restart prompt when saving settings +- **Container Restart Required**: When making significant changes to settings, always restart the container when prompted --- -This script helps automate the tedious process of finding missing episodes and quality upgrades in your TV collection, running quietly in the background while respecting your indexers' rate limits. +This application helps automate the tedious process of finding missing content and quality upgrades in your media collection, running quietly in the background while respecting your indexers' rate limits. --- Thanks to: * [IntensiveCareCub](https://www.reddit.com/user/IntensiveCareCub/) for the Hunter to Huntarr idea! -* [ZPatten](https://github.com/zpatten) for adding the Queue Size and Delay Commands! \ No newline at end of file +* [ZPatten](https://github.com/zpatten) for adding the Queue Size and Delay Commands! diff --git a/config.py b/config.py deleted file mode 100644 index c1acec08..00000000 --- a/config.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/primary/__init__.py b/primary/__init__.py new file mode 100644 index 00000000..851281f9 --- /dev/null +++ b/primary/__init__.py @@ -0,0 +1,6 @@ +""" +Huntarr - Find Missing & Upgrade Media Items +A unified tool for Sonarr, Radarr, Lidarr, and Readarr +""" + +__version__ = "4.0.0" \ No newline at end of file diff --git a/api.py b/primary/api.py similarity index 61% rename from api.py rename to primary/api.py index a7b06181..259de4ed 100644 --- a/api.py +++ b/primary/api.py @@ -1,24 +1,37 @@ #!/usr/bin/env python3 """ -Sonarr API Helper Functions -Handles all communication with the Sonarr API +Arr API Helper Functions +Handles all communication with the Arr API """ import requests import time from typing import List, Dict, Any, Optional, Union -from utils.logger import logger, debug_log -from config import API_KEY, API_URL, API_TIMEOUT, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS +from primary.utils.logger import logger, debug_log +from primary.config import API_KEY, API_URL, API_TIMEOUT, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS, APP_TYPE # Create a session for reuse session = requests.Session() -def sonarr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Optional[Union[Dict, List]]: +def arr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Optional[Union[Dict, List]]: """ - Make a request to the Sonarr API (v3). + Make a request to the Arr API. `endpoint` should be something like 'series', 'command', 'wanted/cutoff', etc. """ - url = f"{API_URL}/api/v3/{endpoint}" + # Determine the API version based on app type + if APP_TYPE == "sonarr": + api_base = "api/v3" + elif APP_TYPE == "radarr": + api_base = "api/v3" + elif APP_TYPE == "lidarr": + api_base = "api/v1" + elif APP_TYPE == "readarr": + api_base = "api/v1" + else: + # Default to v3 for unknown app types + api_base = "api/v3" + + url = f"{API_URL}/{api_base}/{endpoint}" headers = { "X-Api-Key": API_KEY, "Content-Type": "application/json" @@ -45,7 +58,7 @@ def wait_for_command(command_id: int): while True: try: time.sleep(COMMAND_WAIT_DELAY) - response = sonarr_request(f"command/{command_id}") + response = arr_request(f"command/{command_id}") logger.debug(f"Command {command_id} Status: {response['status']}") except Exception as error: logger.error(f"Error fetching command status on attempt {attempts + 1}: {error}") @@ -64,9 +77,14 @@ def wait_for_command(command_id: int): return response['status'].lower() in ['complete', 'completed'] +# Sonarr-specific functions def get_series() -> List[Dict]: """Get all series from Sonarr.""" - series_list = sonarr_request("series") + if APP_TYPE != "sonarr": + logger.error("get_series() called but APP_TYPE is not sonarr") + return [] + + series_list = arr_request("series") if series_list: debug_log("Raw series API response sample:", series_list[:2] if len(series_list) > 2 else series_list) return series_list or [] @@ -79,11 +97,15 @@ def refresh_series(series_id: int) -> bool: "seriesId": } """ + if APP_TYPE != "sonarr": + logger.error("refresh_series() called but APP_TYPE is not sonarr") + return False + data = { "name": "RefreshSeries", "seriesId": series_id } - response = sonarr_request("command", method="POST", data=data) + response = arr_request("command", method="POST", data=data) if not response or 'id' not in response: return False return wait_for_command(response['id']) @@ -96,11 +118,15 @@ def episode_search_episodes(episode_ids: List[int]) -> bool: "episodeIds": [...] } """ + if APP_TYPE != "sonarr": + logger.error("episode_search_episodes() called but APP_TYPE is not sonarr") + return False + data = { "name": "EpisodeSearch", "episodeIds": episode_ids } - response = sonarr_request("command", method="POST", data=data) + response = arr_request("command", method="POST", data=data) if not response or 'id' not in response: return False return wait_for_command(response['id']) @@ -110,7 +136,8 @@ def get_download_queue_size() -> int: GET /api/v3/queue Returns total number of items in the queue with the status 'downloading'. """ - response = sonarr_request("queue?status=downloading") + # Endpoint is the same for all apps + response = arr_request("queue?status=downloading") if not response: return 0 @@ -127,19 +154,27 @@ def get_cutoff_unmet(page: int = 1) -> Optional[Dict]: &page=&pageSize=200 Returns JSON with a "records" array and "totalRecords". """ + if APP_TYPE != "sonarr": + logger.error("get_cutoff_unmet() called but APP_TYPE is not sonarr") + return None + endpoint = ( "wanted/cutoff?" "sortKey=airDateUtc&sortDirection=descending&includeSeriesInformation=true" f"&page={page}&pageSize=200" ) - return sonarr_request(endpoint, method="GET") + return arr_request(endpoint, method="GET") def get_cutoff_unmet_total_pages() -> int: """ To find total pages, call the endpoint with page=1&pageSize=1, read totalRecords, then compute how many pages if each pageSize=200. """ - response = sonarr_request("wanted/cutoff?page=1&pageSize=1") + if APP_TYPE != "sonarr": + logger.error("get_cutoff_unmet_total_pages() called but APP_TYPE is not sonarr") + return 0 + + response = arr_request("wanted/cutoff?page=1&pageSize=1") if not response or "totalRecords" not in response: return 0 @@ -153,15 +188,33 @@ def get_cutoff_unmet_total_pages() -> int: def get_episodes_for_series(series_id: int) -> Optional[List[Dict]]: """Get all episodes for a specific series""" - return sonarr_request(f"episode?seriesId={series_id}", method="GET") + if APP_TYPE != "sonarr": + logger.error("get_episodes_for_series() called but APP_TYPE is not sonarr") + return None + + return arr_request(f"episode?seriesId={series_id}", method="GET") def get_missing_episodes(pageSize: int = 1000) -> Optional[Dict]: """ GET /api/v3/wanted/missing?pageSize=&includeSeriesInformation=true Returns JSON with a "records" array of missing episodes and "totalRecords". """ + if APP_TYPE != "sonarr": + logger.error("get_missing_episodes() called but APP_TYPE is not sonarr") + return None + endpoint = f"wanted/missing?pageSize={pageSize}&includeSeriesInformation=true" - return sonarr_request(endpoint, method="GET") + result = arr_request(endpoint, method="GET") + + # Better debugging for missing episodes query + if result: + logger.debug(f"Found {result.get('totalRecords', 0)} total missing episodes") + if result.get('records'): + logger.debug(f"First few missing episodes: {result['records'][:2] if len(result['records']) > 2 else result['records']}") + else: + logger.warning("Missing episodes query returned no data") + + return result def get_series_with_missing_episodes() -> List[Dict]: """ @@ -169,43 +222,59 @@ def get_series_with_missing_episodes() -> List[Dict]: Returns a list of series objects with an additional 'missingEpisodes' field containing the list of missing episodes for that series. """ + if APP_TYPE != "sonarr": + logger.error("get_series_with_missing_episodes() called but APP_TYPE is not sonarr") + return [] + + # Log request attempt + logger.debug("Requesting missing episodes from Sonarr API") + missing_data = get_missing_episodes() if not missing_data or "records" not in missing_data: + logger.error("Failed to get missing episodes data or no 'records' field in response") return [] # Group missing episodes by series ID series_with_missing = {} for episode in missing_data.get("records", []): series_id = episode.get("seriesId") + if not series_id: + logger.warning(f"Found episode without seriesId: {episode}") + continue + series_title = None # Try to get series info from the episode record if "series" in episode and isinstance(episode["series"], dict): series_info = episode["series"] series_title = series_info.get("title") - - if series_id not in series_with_missing: + # Initialize the series entry if it doesn't exist - if series_title: - # We have the series info from the episode + if series_id not in series_with_missing: series_with_missing[series_id] = { "id": series_id, - "title": series_title, + "title": series_title or "Unknown Show", "monitored": series_info.get("monitored", False), - "missingEpisodes": [episode] + "missingEpisodes": [] } - else: - # We need to fetch the series info - series_info = sonarr_request(f"series/{series_id}", method="GET") + else: + # If we don't have series info, need to fetch it + if series_id not in series_with_missing: + # Get series info directly + series_info = arr_request(f"series/{series_id}", method="GET") if series_info: series_with_missing[series_id] = { "id": series_id, "title": series_info.get("title", "Unknown Show"), "monitored": series_info.get("monitored", False), - "missingEpisodes": [episode] + "missingEpisodes": [] } - else: - # Add the episode to the existing series entry + else: + logger.warning(f"Could not get series info for ID {series_id}, skipping episode") + continue + + # Add the episode to the series record + if series_id in series_with_missing: series_with_missing[series_id]["missingEpisodes"].append(episode) # Convert to list and add count for convenience @@ -214,4 +283,5 @@ def get_series_with_missing_episodes() -> List[Dict]: series_data["missingEpisodeCount"] = len(series_data["missingEpisodes"]) result.append(series_data) + logger.debug(f"Processed missing episodes data into {len(result)} series with missing episodes") return result \ No newline at end of file diff --git a/primary/auth.py b/primary/auth.py new file mode 100644 index 00000000..7ca1a9f9 --- /dev/null +++ b/primary/auth.py @@ -0,0 +1,336 @@ +#!/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() and os.path.getsize(USER_FILE) > 0 + +def create_user(username: str, password: str) -> bool: + """Create a new user""" + if not username or not password: + return False + + # Ensure user directory exists with proper permissions + USER_DIR.mkdir(parents=True, exist_ok=True) + try: + # Set appropriate permissions if not running as root + os.chmod(USER_DIR, 0o755) + except: + pass + + # 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) + # Set appropriate permissions on the file + try: + os.chmod(USER_FILE, 0o644) + except: + pass + 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/setup pages + if request.path.startswith(("/static/", "/login", "/api/login", "/setup", "/api/setup")) 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) \ No newline at end of file diff --git a/primary/config.py b/primary/config.py new file mode 100644 index 00000000..da8e8048 --- /dev/null +++ b/primary/config.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Configuration module for Huntarr +Handles all configuration settings with defaults +""" + +import os +import logging +from primary import settings_manager +from primary import keys_manager + +# Get app type +APP_TYPE = settings_manager.get_app_type() + +# API Configuration from keys_manager instead of settings_manager +API_URL, API_KEY = keys_manager.get_api_keys(APP_TYPE) + +# 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 +def determine_hunt_mode(): + """Determine the hunt mode based on current settings""" + if APP_TYPE == "sonarr": + if HUNT_MISSING_SHOWS > 0 and HUNT_UPGRADE_EPISODES > 0: + return "both" + elif HUNT_MISSING_SHOWS > 0: + return "missing" + elif HUNT_UPGRADE_EPISODES > 0: + return "upgrade" + else: + return "none" + elif APP_TYPE == "radarr": + if HUNT_MISSING_MOVIES > 0 and HUNT_UPGRADE_MOVIES > 0: + return "both" + elif HUNT_MISSING_MOVIES > 0: + return "missing" + elif HUNT_UPGRADE_MOVIES > 0: + return "upgrade" + else: + return "none" + elif APP_TYPE == "lidarr": + if HUNT_MISSING_ALBUMS > 0 and HUNT_UPGRADE_TRACKS > 0: + return "both" + elif HUNT_MISSING_ALBUMS > 0: + return "missing" + elif HUNT_UPGRADE_TRACKS > 0: + return "upgrade" + else: + return "none" + elif APP_TYPE == "readarr": + if HUNT_MISSING_BOOKS > 0 and HUNT_UPGRADE_BOOKS > 0: + return "both" + elif HUNT_MISSING_BOOKS > 0: + return "missing" + elif HUNT_UPGRADE_BOOKS > 0: + return "upgrade" + else: + return "none" + return "none" + +# Set the initial hunt mode +HUNT_MODE = determine_hunt_mode() + +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 + + # Reload APP_TYPE from settings + APP_TYPE = settings_manager.get_app_type() + + # Refresh API keys from keys_manager + API_URL, API_KEY = keys_manager.get_api_keys(APP_TYPE) + + # Force reload all settings + settings = settings_manager.get_all_settings() + + # Common settings + # 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) + + # Update hunt mode based on current settings + HUNT_MODE = determine_hunt_mode() + + # 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}") + logger.debug(f"Hunt settings: HUNT_MISSING_SHOWS={HUNT_MISSING_SHOWS}, HUNT_UPGRADE_EPISODES={HUNT_UPGRADE_EPISODES}") + +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() \ No newline at end of file diff --git a/primary/default_configs.json b/primary/default_configs.json new file mode 100644 index 00000000..6526b81f --- /dev/null +++ b/primary/default_configs.json @@ -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 + } + } \ No newline at end of file diff --git a/primary/keys_manager.py b/primary/keys_manager.py new file mode 100644 index 00000000..227157fc --- /dev/null +++ b/primary/keys_manager.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +Keys manager for Huntarr +Handles secure storage and retrieval of API keys and URLs +""" + +import os +import json +import hashlib +import base64 +import pathlib +import logging +from typing import Dict, Any, Optional, Tuple + +# Create a simple logger +logging.basicConfig(level=logging.INFO) +keys_logger = logging.getLogger("keys_manager") + +# Keys directory setup +KEYS_DIR = pathlib.Path("/config/apps") +KEYS_DIR.mkdir(parents=True, exist_ok=True) + +KEYS_FILE = KEYS_DIR / "keys.json" + +# Create an initial empty keys file if it doesn't exist +if not KEYS_FILE.exists(): + with open(KEYS_FILE, 'w') as f: + json.dump({}, f) + +def hash_api_key(api_key: str) -> str: + """Hash an API key for storage""" + # Use SHA-256 with a salt from the key itself + salt = api_key[:8] if len(api_key) >= 8 else api_key + hash_input = (api_key + salt).encode('utf-8') + return base64.b64encode(hashlib.sha256(hash_input).digest()).decode('utf-8') + +def save_api_keys(app_type: str, api_url: str, api_key: str) -> bool: + """ + Save API keys and URL for an app. + + Args: + app_type: The type of app (sonarr, radarr, etc.) + api_url: The API URL for the app + api_key: The API key + + Returns: + bool: True if successful, False otherwise + """ + try: + # Load existing keys file + with open(KEYS_FILE, 'r') as f: + keys_data = json.load(f) + + # Check if we're changing anything + if app_type in keys_data: + if keys_data[app_type].get('api_url') == api_url and keys_data[app_type].get('api_key') == api_key: + # No changes, nothing to do + return True + + # Create a new entry or update existing one + hashed_key = hash_api_key(api_key) if api_key else "" + + keys_data[app_type] = { + 'api_url': api_url, + 'api_key': api_key, + 'api_key_hash': hashed_key # Store hashed version for verification later + } + + # Save the file + with open(KEYS_FILE, 'w') as f: + json.dump(keys_data, f, indent=2) + + keys_logger.info(f"Saved API keys for {app_type}") + return True + except Exception as e: + keys_logger.error(f"Error saving API keys: {e}") + return False + +def get_api_keys(app_type: str) -> Tuple[str, str]: + """ + Get API keys and URL for an app. + + Args: + app_type: The type of app (sonarr, radarr, etc.) + + Returns: + Tuple[str, str]: (api_url, api_key) + """ + try: + # Load keys file + with open(KEYS_FILE, 'r') as f: + keys_data = json.load(f) + + # Get keys for the app + if app_type in keys_data: + return keys_data[app_type].get('api_url', ''), keys_data[app_type].get('api_key', '') + + return '', '' + except Exception as e: + keys_logger.error(f"Error getting API keys: {e}") + return '', '' + +def verify_api_key(app_type: str, api_key: str) -> bool: + """ + Verify if an API key matches the stored hash. + + Args: + app_type: The type of app (sonarr, radarr, etc.) + api_key: The API key to verify + + Returns: + bool: True if the API key is correct + """ + try: + # Load keys file + with open(KEYS_FILE, 'r') as f: + keys_data = json.load(f) + + # Check if app type exists and has a key + if app_type not in keys_data or not keys_data[app_type].get('api_key_hash'): + return False + + # Get the stored hash + stored_hash = keys_data[app_type].get('api_key_hash') + + # Hash the provided key + hashed_key = hash_api_key(api_key) + + # Compare hashes + return stored_hash == hashed_key + except Exception as e: + keys_logger.error(f"Error verifying API key: {e}") + return False + +def list_configured_apps() -> Dict[str, bool]: + """ + List all apps and whether they're configured. + + Returns: + Dict[str, bool]: Dictionary of app_type -> is_configured + """ + result = { + 'sonarr': False, + 'radarr': False, + 'lidarr': False, + 'readarr': False + } + + try: + # Load keys file + with open(KEYS_FILE, 'r') as f: + keys_data = json.load(f) + + # Check each app + for app in result.keys(): + if app in keys_data and keys_data[app].get('api_url') and keys_data[app].get('api_key'): + result[app] = True + + return result + except Exception as e: + keys_logger.error(f"Error listing configured apps: {e}") + return result \ No newline at end of file diff --git a/main.py b/primary/main.py similarity index 52% rename from main.py rename to primary/main.py index 8ec983aa..0dcae412 100644 --- a/main.py +++ b/primary/main.py @@ -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 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,21 +56,23 @@ 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() - # Log the reloaded settings for verification - logger.warning("⚠️ Settings reloaded from JSON file after restart signal ⚠️") + # Log the reloaded settings for verification - CHANGED TO INFO LEVEL + logger.info("Settings reloaded from JSON file") config.log_configuration(logger) return True @@ -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}:9705") - 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 @@ -115,31 +111,61 @@ def main_loop() -> None: # Check if we should ignore the download queue size or if we are below the minimum queue size 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 + if APP_TYPE == "sonarr": + # Get updated settings to ensure we're using the current values + from primary.config import HUNT_MISSING_SHOWS, HUNT_UPGRADE_EPISODES - # Check if restart signal received - if restart_cycle: - logger.warning("⚠️ Restarting cycle due to settings change... ⚠️") - continue + # First process missing shows if configured + if HUNT_MISSING_SHOWS > 0: + logger.info(f"Configured to look for {HUNT_MISSING_SHOWS} missing shows") + from primary.missing import process_missing_episodes + if process_missing_episodes(): + processing_done = True + else: + logger.info("No missing episodes processed - check if you have any missing episodes in Sonarr") + + # Check if restart signal received + if restart_cycle: + logger.warning("⚠️ Restarting cycle due to settings change... ⚠️") + continue + else: + logger.info("Missing shows search disabled (HUNT_MISSING_SHOWS=0)") + + # Then process quality upgrades if configured + if HUNT_UPGRADE_EPISODES > 0: + logger.info(f"Configured to look for {HUNT_UPGRADE_EPISODES} quality upgrades") + from primary.upgrade import process_cutoff_upgrades + if process_cutoff_upgrades(): + processing_done = True + else: + logger.info("No quality upgrades processed - check if you have any cutoff unmet episodes in Sonarr") - 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 + else: + logger.info("Quality upgrades search disabled (HUNT_UPGRADE_EPISODES=0)") + + 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 +176,14 @@ 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}:9705") # Sleep with progress updates for the web interface sleep_start = time.time() @@ -186,7 +210,7 @@ def main_loop() -> None: 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}") diff --git a/missing.py b/primary/missing.py similarity index 78% rename from missing.py rename to primary/missing.py index 973d5476..de4ea9cb 100644 --- a/missing.py +++ b/primary/missing.py @@ -8,22 +8,21 @@ import time import datetime from typing import List -from utils.logger import logger -from config import ( +from primary.utils.logger import logger, debug_log +from primary.config import ( HUNT_MISSING_SHOWS, MONITORED_ONLY, - RANDOM_SELECTION, RANDOM_MISSING, 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: """ @@ -43,6 +42,10 @@ def process_missing_episodes() -> bool: # Get shows that have missing episodes directly - more efficient than checking all shows shows_with_missing = get_series_with_missing_episodes() + + # Add more detailed logging about the API response + logger.debug(f"API Response for missing episodes: {shows_with_missing[:2] if shows_with_missing and len(shows_with_missing) > 2 else shows_with_missing}") + if not shows_with_missing: logger.info("No shows with missing episodes found.") return False @@ -60,31 +63,40 @@ def process_missing_episodes() -> bool: logger.info("No monitored shows with missing episodes found.") return False + # Load previously processed show IDs processed_missing_ids = load_processed_ids(PROCESSED_MISSING_FILE) + logger.debug(f"Previously processed shows: {processed_missing_ids}") + + # Filter out shows that have already been processed + unprocessed_shows = [s for s in shows_with_missing if s.get("id") not in processed_missing_ids] + + if not unprocessed_shows: + logger.info("All shows with missing episodes have already been processed in this cycle.") + return False + + logger.info(f"Found {len(unprocessed_shows)} unprocessed shows with missing episodes.") + shows_processed = 0 processing_done = False - # Use the specific RANDOM_MISSING setting - # (no longer dependent on the master RANDOM_SELECTION setting) + # Use RANDOM_MISSING setting if RANDOM_MISSING: logger.info("Using random selection for missing shows (RANDOM_MISSING=true)") - random.shuffle(shows_with_missing) + random.shuffle(unprocessed_shows) else: logger.info("Using sequential selection for missing shows (RANDOM_MISSING=false)") # Get current date for future episode filtering current_date = datetime.datetime.now().date() - for show in shows_with_missing: + for show in unprocessed_shows: if shows_processed >= HUNT_MISSING_SHOWS: + logger.info(f"Reached limit of {HUNT_MISSING_SHOWS} missing shows for this cycle.") break series_id = show.get("id") if not series_id: - continue - - # If we already processed this show ID, skip - if series_id in processed_missing_ids: + logger.warning("Found show without ID, skipping.") continue show_title = show.get("title", "Unknown Show") @@ -101,6 +113,8 @@ def process_missing_episodes() -> bool: if not monitored_missing_episodes: logger.info(f"No missing monitored episodes found for '{show_title}' — skipping.") + # Still mark as processed to avoid checking again in the next cycle + save_processed_id(PROCESSED_MISSING_FILE, series_id) continue # Skip future episodes if SKIP_FUTURE_EPISODES is enabled @@ -135,6 +149,8 @@ def process_missing_episodes() -> bool: if not monitored_missing_episodes: logger.info(f"All missing episodes for '{show_title}' are future episodes - skipping.") + # Still mark as processed to avoid checking again in the next cycle + save_processed_id(PROCESSED_MISSING_FILE, series_id) continue logger.info(f"Found {len(monitored_missing_episodes)} missing monitored episode(s) for '{show_title}'.") @@ -169,4 +185,7 @@ def process_missing_episodes() -> bool: # Truncate processed list if needed truncate_processed_list(PROCESSED_MISSING_FILE) + if shows_processed == 0: + logger.info("No missing shows processed in this cycle.") + return processing_done \ No newline at end of file diff --git a/primary/requirements.txt b/primary/requirements.txt new file mode 100644 index 00000000..1293bcb3 --- /dev/null +++ b/primary/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.25.0 +flask>=2.0.0 +pyotp>=2.6.0 +qrcode>=7.3.1 +pillow>=8.4.0 \ No newline at end of file diff --git a/primary/settings_manager.py b/primary/settings_manager.py new file mode 100644 index 00000000..8797fd12 --- /dev/null +++ b/primary/settings_manager.py @@ -0,0 +1,234 @@ +#!/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 +from primary import keys_manager + +# 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 + "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 = { + "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 ("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""" + app_type = get_app_type() + _, api_key = keys_manager.get_api_keys(app_type) + return api_key + +def get_api_url() -> str: + """Get the API URL""" + app_type = get_app_type() + api_url, _ = keys_manager.get_api_keys(app_type) + return api_url + +# Initialize settings file if it doesn't exist +if not SETTINGS_FILE.exists(): + save_settings(load_settings()) \ No newline at end of file diff --git a/primary/start.sh b/primary/start.sh new file mode 100644 index 00000000..c73d90a7 --- /dev/null +++ b/primary/start.sh @@ -0,0 +1,14 @@ +#!/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 + +# Web UI is always enabled +echo "Starting with Web UI enabled on port 9705" + +# Start both the web server and the main application +cd /app +python -m primary.web_server & +python -m primary.main \ No newline at end of file diff --git a/state.py b/primary/state.py similarity index 72% rename from state.py rename to primary/state.py index febe7e6b..a7417ea3 100644 --- a/state.py +++ b/primary/state.py @@ -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") diff --git a/upgrade.py b/primary/upgrade.py similarity index 71% rename from upgrade.py rename to primary/upgrade.py index fa30e5e5..0b614213 100644 --- a/upgrade.py +++ b/primary/upgrade.py @@ -8,28 +8,31 @@ import time import datetime import importlib -from utils.logger import logger -from config import ( +from typing import Callable +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 -def process_cutoff_upgrades() -> bool: +def process_cutoff_upgrades(restart_cycle_flag: Callable[[], bool] = lambda: False) -> bool: """ Process episodes that need quality upgrades (cutoff unmet). + Args: + restart_cycle_flag: Function that returns whether to restart the cycle + Returns: True if any processing was done, False otherwise """ @@ -43,11 +46,21 @@ def process_cutoff_upgrades() -> bool: logger.info("HUNT_UPGRADE_EPISODES is set to 0, skipping quality upgrades") return False + # Check for restart signal + if restart_cycle_flag(): + logger.info("🔄 Received restart signal before starting quality upgrades. Aborting...") + return False + total_pages = get_cutoff_unmet_total_pages() if total_pages == 0: logger.info("No episodes found that need quality upgrades.") return False + # Check for restart signal + if restart_cycle_flag(): + logger.info("🔄 Received restart signal after getting total pages. Aborting...") + return False + logger.info(f"Found {total_pages} total pages of episodes that need quality upgrades.") processed_upgrade_ids = load_processed_ids(PROCESSED_UPGRADE_FILE) episodes_processed = 0 @@ -56,8 +69,7 @@ def process_cutoff_upgrades() -> bool: # Get current date for future episode filtering current_date = datetime.datetime.now().date() - # Use the specific RANDOM_UPGRADES setting - # (no longer dependent on the master RANDOM_SELECTION setting) + # Use RANDOM_UPGRADES setting should_use_random = RANDOM_UPGRADES # Initialize page variable for both modes @@ -69,6 +81,11 @@ def process_cutoff_upgrades() -> bool: logger.info("Using sequential selection for quality upgrades (RANDOM_UPGRADES=false)") while True: + # Check for restart signal at the beginning of each page processing + if restart_cycle_flag(): + logger.info("🔄 Received restart signal at start of page loop. Aborting...") + break + # Check again to make sure we're using the current limit # This ensures if settings changed during processing, we use the new value current_limit = get_current_upgrade_limit() @@ -86,6 +103,12 @@ def process_cutoff_upgrades() -> bool: logger.info(f"Retrieving cutoff-unmet episodes (page={page} of {total_pages})...") cutoff_data = get_cutoff_unmet(page) + + # Check for restart signal after retrieving page + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal after retrieving page {page}. Aborting...") + break + if not cutoff_data or "records" not in cutoff_data: logger.error(f"ERROR: Unable to retrieve cutoff–unmet data from Sonarr on page {page}.") @@ -104,8 +127,18 @@ def process_cutoff_upgrades() -> bool: indices = list(range(total_eps)) if should_use_random: random.shuffle(indices) + + # Check for restart signal before processing episodes + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal before processing episodes on page {page}. Aborting...") + break for idx in indices: + # Check for restart signal before each episode + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal during episode processing. Aborting...") + break + # Check again for the current limit in case it was changed during processing current_limit = get_current_upgrade_limit() @@ -125,7 +158,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: @@ -144,6 +177,11 @@ def process_cutoff_upgrades() -> bool: except (ValueError, TypeError): # If date parsing fails, proceed with the episode pass + + # Check for restart signal before processing this episode + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal before processing episode {ep_title}. Aborting...") + break logger.info(f"Processing upgrade for \"{series_title}\" - S{season_num}E{ep_num} - \"{ep_title}\" (Episode ID: {episode_id})") @@ -155,12 +193,17 @@ 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: logger.info("Skipping unmonitored episode or series.") continue + + # Check for restart signal before refreshing + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal before refreshing series for {ep_title}. Aborting...") + break # Refresh the series only if SKIP_SERIES_REFRESH is not enabled if not SKIP_SERIES_REFRESH: @@ -172,6 +215,11 @@ def process_cutoff_upgrades() -> bool: logger.info(f"Refresh command completed successfully.") else: logger.info(" - Skipping series refresh (SKIP_SERIES_REFRESH=true)") + + # Check for restart signal before searching + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal before searching for {ep_title}. Aborting...") + break # Search for the episode (upgrade) logger.info(" - Searching for quality upgrade...") @@ -189,12 +237,22 @@ def process_cutoff_upgrades() -> bool: else: logger.warning(f"WARNING: Search command failed for episode ID {episode_id}.") continue + + # Check for restart signal after processing an episode + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal after processing episode {ep_title}. Aborting...") + break # Move to the next page if using sequential mode if not should_use_random: page += 1 # In random mode, we just handle one random page this iteration, # then check if we've processed enough episodes or continue to another random page + + # Check for restart signal after processing a page + if restart_cycle_flag(): + logger.info(f"🔄 Received restart signal after processing page {page}. Aborting...") + break # Log with the current limit, not the initial one current_limit = get_current_upgrade_limit() diff --git a/primary/utils/__init__.py b/primary/utils/__init__.py new file mode 100644 index 00000000..48f60dc1 --- /dev/null +++ b/primary/utils/__init__.py @@ -0,0 +1,7 @@ +""" +Utility functions for Huntarr +""" + +from primary.utils.logger import logger, debug_log + +__all__ = ['logger', 'debug_log'] \ No newline at end of file diff --git a/utils/logger.py b/primary/utils/logger.py similarity index 94% rename from utils/logger.py rename to primary/utils/logger.py index 15b42357..78484a47 100644 --- a/utils/logger.py +++ b/primary/utils/logger.py @@ -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[:]: diff --git a/primary/web_server.py b/primary/web_server.py new file mode 100644 index 00000000..9186f8d6 --- /dev/null +++ b/primary/web_server.py @@ -0,0 +1,680 @@ +#!/usr/bin/env python3 +""" +Web server for Huntarr +Provides a web interface to view logs in real-time, manage settings, and includes authentication +""" + +import os +import time +import datetime +import pathlib +import socket +import json +import signal +import sys +import qrcode +import pyotp +import base64 +import io +import requests +from flask import Flask, render_template, Response, stream_with_context, request, jsonify, send_from_directory, redirect, session, url_for +import logging +from primary.config import API_URL +from primary import settings_manager +from primary import keys_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__, 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: + # Try to find the main.py process + for proc in os.listdir('/proc'): + if not proc.isdigit(): + continue + try: + with open(f'/proc/{proc}/cmdline', 'r') as f: + cmdline = f.read().replace('\0', ' ') + if 'python' in cmdline and 'primary/main.py' in cmdline: + return int(proc) + except (IOError, ProcessLookupError): + continue + return None + except: + return None + +@app.route('/') +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('/') + # Log the access to setup page + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Accessed setup page - no user exists yet\n") + 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 + + # Log the creation attempt + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Attempting to create first user: {username}\n") + + if create_user(username, password): + # Log success + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Successfully created first user\n") + + # Create a session for the new user + session_id = create_session(username) + session[SESSION_COOKIE_NAME] = session_id + return jsonify({"success": True}) + else: + # Log failure + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - ERROR - Failed to create user - check permissions\n") + return jsonify({"success": False, "message": "Failed to create user - check directory permissions"}), 500 + +@app.route('/api/test-connection', methods=['POST']) +def test_connection(): + """Test connection to an Arr application""" + data = request.json + app_type = data.get('app') + api_url = data.get('api_url') + api_key = data.get('api_key') + + if not app_type: + return jsonify({"success": False, "message": "Missing app type parameter"}), 400 + + # If API URL and key aren't provided, get them from storage + if not api_url or not api_key: + stored_url, stored_key = keys_manager.get_api_keys(app_type) + api_url = api_url or stored_url + api_key = api_key or stored_key + + if not api_url or not api_key: + return jsonify({"success": False, "message": "Missing API URL or API key"}), 400 + + try: + # Test connection by making a simple request to the API + url = f"{api_url}/api/v3/system/status" + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + + # Log the successful connection test + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Connection test successful for {app_type}: {api_url}\n") + + return jsonify({"success": True}) + except Exception as e: + # Log the failed connection test + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - ERROR - Connection test failed for {app_type}: {api_url} - {str(e)}\n") + + return jsonify({"success": False, "message": str(e)}), 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/') +def send_static(path): + """Serve static files""" + return send_from_directory('../static', path) + +@app.route('/logs') +def stream_logs(): + """Stream logs to the client""" + app = request.args.get('app', 'sonarr') # Default to 'sonarr' if not specified + + def generate(): + # First get all existing logs + if os.path.exists(LOG_FILE): + with open(LOG_FILE, 'r') as f: + # Read the last 100 lines of the log file + lines = f.readlines()[-100:] + for line in lines: + # Filter logs by app type + if app == 'sonarr' or app in line.lower(): + yield f"data: {line}\n\n" + + # Then stream new logs as they appear + with open(LOG_FILE, 'r') as f: + # Move to the end of the file + f.seek(0, 2) + while True: + line = f.readline() + if line: + # Filter logs by app type + if app == 'sonarr' or app in line.lower(): + yield f"data: {line}\n\n" + else: + time.sleep(0.1) + + return Response(stream_with_context(generate()), + mimetype='text/event-stream') + +@app.route('/api/settings', methods=['GET']) +def get_settings(): + """Get all settings""" + # Get settings from settings_manager + settings = settings_manager.get_all_settings() + + # Add API keys from keys_manager for each app + apps = ['sonarr', 'radarr', 'lidarr', 'readarr'] + for app in apps: + api_url, api_key = keys_manager.get_api_keys(app) + if app == settings.get('app_type', 'sonarr'): + # For current app, set at root level + settings['api_url'] = api_url + settings['api_key'] = api_key + + return jsonify(settings) + +@app.route('/api/app-settings', methods=['GET']) +def get_app_settings(): + """Get settings for a specific app""" + app = request.args.get('app') + if not app: + return jsonify({"success": False, "message": "App parameter required"}), 400 + + # Get API keys for the requested app + api_url, api_key = keys_manager.get_api_keys(app) + + return jsonify({ + "success": True, + "app": app, + "api_url": api_url, + "api_key": api_key + }) + +@app.route('/api/configured-apps', methods=['GET']) +def get_configured_apps(): + """Get which apps are configured""" + return jsonify(keys_manager.list_configured_apps()) + +@app.route('/api/settings', methods=['POST']) +def update_settings(): + """Update settings and optionally restart the container to apply them immediately""" + try: + data = request.json + if not data: + return jsonify({"success": False, "message": "No data provided"}), 400 + + # Check if restart flag is set - if restart is cancelled, don't save settings + restart_container = data.pop('restart_container', False) + if not restart_container: + # User cancelled the restart, so don't save any settings + return jsonify({ + "success": True, + "message": "Operation cancelled - no changes saved", + "changes_made": False, + "cancelled": True + }) + + # Get current settings to compare + old_settings = settings_manager.get_all_settings() + old_huntarr = old_settings.get("huntarr", {}) + old_advanced = old_settings.get("advanced", {}) + old_ui = old_settings.get("ui", {}) + + # Get current API URL and key + app_type = data.get('app_type', 'sonarr') + old_api_url, old_api_key = keys_manager.get_api_keys(app_type) + + # Find changes + huntarr_changes = {} + advanced_changes = {} + ui_changes = {} + api_changes = {} + + # Track if any real changes were made + changes_made = False + + # Check API URL and key changes + new_api_url = data.get('api_url', '') + new_api_key = data.get('api_key', '') + + if old_api_url != new_api_url: + api_changes['api_url'] = {"old": old_api_url, "new": new_api_url} + changes_made = True + + if old_api_key != new_api_key: + api_changes['api_key'] = {"old": "****", "new": "****"} # Don't log actual keys + changes_made = True + + # Save API keys if changed + if api_changes: + keys_manager.save_api_keys(app_type, new_api_url, new_api_key) + + # Update huntarr settings and track changes + if "huntarr" in data: + for key, value in data["huntarr"].items(): + old_value = old_huntarr.get(key) + if old_value != value: + huntarr_changes[key] = {"old": old_value, "new": value} + changes_made = True + settings_manager.update_setting("huntarr", key, value) + + # Update UI settings and track changes + if "ui" in data: + for key, value in data["ui"].items(): + old_value = old_ui.get(key) + if old_value != value: + ui_changes[key] = {"old": old_value, "new": value} + changes_made = True + settings_manager.update_setting("ui", key, value) + + # Update advanced settings and track changes + if "advanced" in data: + for key, value in data["advanced"].items(): + old_value = old_advanced.get(key) + if old_value != value: + advanced_changes[key] = {"old": old_value, "new": value} + changes_made = True + settings_manager.update_setting("advanced", key, value) + + # Special handling for debug_mode setting + if key == "debug_mode" and old_value != value: + # Reconfigure the logger with new debug mode setting + setup_logger(value) + + # Log changes if any were made + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if changes_made: + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Settings updated by user\n") + + # Log API changes + for key, change in api_changes.items(): + if key == 'api_key': + f.write(f"{timestamp} - huntarr-web - INFO - Changed API key for {app_type}\n") + else: + f.write(f"{timestamp} - huntarr-web - INFO - Changed {key} for {app_type} from {change['old']} to {change['new']}\n") + + # Log huntarr changes + for key, change in huntarr_changes.items(): + f.write(f"{timestamp} - huntarr-web - INFO - Changed {key} from {change['old']} to {change['new']}\n") + + # Log advanced changes + for key, change in advanced_changes.items(): + f.write(f"{timestamp} - huntarr-web - INFO - Changed advanced.{key} from {change['old']} to {change['new']}\n") + + # Log UI changes + for key, change in ui_changes.items(): + f.write(f"{timestamp} - huntarr-web - INFO - Changed UI.{key} from {change['old']} to {change['new']}\n") + + f.write(f"{timestamp} - huntarr-web - INFO - Settings saved successfully\n") + + # If restart_container flag is true, restart the container + if restart_container: + f.write(f"{timestamp} - huntarr-web - INFO - Container restart requested by user\n") + # Start a background thread to restart the container after a short delay + import threading + def delayed_restart(): + time.sleep(1) # Give time for the response to be sent + f.write(f"{timestamp} - huntarr-web - INFO - Restarting container now...\n") + os.system("kill 1") # In Docker, PID 1 is the main process, killing it will restart the container + + restart_thread = threading.Thread(target=delayed_restart) + restart_thread.daemon = True + restart_thread.start() + + return jsonify({ + "success": True, + "message": "Settings saved and container is restarting...", + "changes_made": True, + "restarting": True + }) + else: + f.write(f"{timestamp} - huntarr-web - INFO - Trying to restart cycle to apply new settings\n") + # Try to signal the main process to restart the cycle + main_pid = get_main_process_pid() + if main_pid: + try: + # Send a SIGUSR1 signal which we'll handle in main.py to restart the cycle + os.kill(main_pid, signal.SIGUSR1) + return jsonify({ + "success": True, + "message": "Settings saved and cycle restarted", + "changes_made": True + }) + except: + # If signaling fails, just return success for the settings save + return jsonify({ + "success": True, + "message": "Settings saved, but cycle not restarted", + "changes_made": True + }) + else: + return jsonify({ + "success": True, + "message": "Settings saved, but main process not found", + "changes_made": True + }) + + else: + # No changes were made + return jsonify({"success": True, "message": "No changes detected", "changes_made": False}) + + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/settings/reset', methods=['POST']) +def reset_settings(): + """Reset settings to defaults and optionally restart the container""" + try: + data = request.json or {} + restart_container = data.get('restart_container', False) + + # If restart is cancelled, don't reset settings + if not restart_container: + return jsonify({ + "success": True, + "message": "Operation cancelled - settings not reset", + "changes_made": False, + "cancelled": True + }) + + # Reset settings + settings_manager.save_settings(settings_manager.DEFAULT_SETTINGS) + + # Log the reset + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Settings reset to defaults by user\n") + + # If restart_container flag is true, restart the container + if restart_container: + f.write(f"{timestamp} - huntarr-web - INFO - Container restart requested by user\n") + # Start a background thread to restart the container after a short delay + import threading + def delayed_restart(): + time.sleep(1) # Give time for the response to be sent + f.write(f"{timestamp} - huntarr-web - INFO - Restarting container now...\n") + os.system("kill 1") # In Docker, PID 1 is the main process, killing it will restart the container + + restart_thread = threading.Thread(target=delayed_restart) + restart_thread.daemon = True + restart_thread.start() + + return jsonify({ + "success": True, + "message": "Settings reset to defaults and container is restarting...", + "restarting": True + }) + else: + f.write(f"{timestamp} - huntarr-web - INFO - Trying to restart cycle to apply new settings\n") + # Try to signal the main process to restart the cycle + main_pid = get_main_process_pid() + if main_pid: + try: + # Send a SIGUSR1 signal which we'll handle in main.py to restart the cycle + os.kill(main_pid, signal.SIGUSR1) + return jsonify({"success": True, "message": "Settings reset and cycle restarted"}) + except: + # If signaling fails, just return success for the settings reset + return jsonify({"success": True, "message": "Settings reset, but cycle not restarted"}) + else: + return jsonify({"success": True, "message": "Settings reset, but main process not found"}) + + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/settings/theme', methods=['GET']) +def get_theme(): + """Get the current theme setting""" + dark_mode = settings_manager.get_setting("ui", "dark_mode", True) + return jsonify({"dark_mode": dark_mode}) + +@app.route('/api/settings/theme', methods=['POST']) +def update_theme(): + """Update the theme setting""" + try: + data = request.json + old_value = settings_manager.get_setting("ui", "dark_mode", True) + if "dark_mode" in data and old_value != data["dark_mode"]: + settings_manager.update_setting("ui", "dark_mode", data["dark_mode"]) + + # Log the theme change - simplified to remove "from X" text + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LOG_FILE, 'a') as f: + new_mode = 'Dark' if data['dark_mode'] else 'Light' + f.write(f"{timestamp} - huntarr-web - INFO - Changed theme to {new_mode} Mode\n") + + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +def get_ip_address(): + """Get the host's IP address from API_URL for display""" + try: + from urllib.parse import urlparse + + # Extract the hostname/IP from the API_URL + parsed_url = urlparse(API_URL) + hostname = parsed_url.netloc + + # Remove port if present + if ':' in hostname: + hostname = hostname.split(':')[0] + + return hostname + except Exception as e: + # Fallback to the current method if there's an issue + try: + hostname = socket.gethostname() + ip = socket.gethostbyname(hostname) + return ip + except: + return "localhost" + +if __name__ == "__main__": + # Create a basic log entry at startup + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ip_address = get_ip_address() + + with open(LOG_FILE, 'a') as f: + f.write(f"{timestamp} - huntarr-web - INFO - Web server starting on port 9705\n") + f.write(f"{timestamp} - huntarr-web - INFO - Web interface available at http://{ip_address}:9705\n") + + # Run the Flask app + app.run(host='0.0.0.0', port=9705, debug=False, threaded=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 730695f6..00000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests>=2.25.0 -flask>=2.0.0 \ No newline at end of file diff --git a/settings_manager.py b/settings_manager.py deleted file mode 100644 index 3b8812e3..00000000 --- a/settings_manager.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/start.sh b/start.sh deleted file mode 100644 index 70e8503f..00000000 --- a/start.sh +++ /dev/null @@ -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 \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 818d897d..3a1d450b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -24,8 +24,18 @@ --save-button-hover: #219955; --reset-button-bg: #e74c3c; --reset-button-hover: #c0392b; - --donation-banner-bg: #f8f9fa; - --donation-banner-border: #dee2e6; + --badge-connected: #27ae60; + --badge-not-connected: #e74c3c; + --badge-text: #fff; + --app-tab-bg: #34495e; + --app-tab-text: #ecf0f1; + --app-tab-active: #3498db; + --app-tab-hover: #2980b9; + --app-tab-border: #1a2530; + --service-not-configured: #f39c12; + --welcome-panel-bg: #34495e; + --info-box-bg: #2c3e50; + --main-content-bg: #34495e; } .dark-theme { @@ -54,10 +64,21 @@ --save-button-hover: #219955; --reset-button-bg: #e74c3c; --reset-button-hover: #c0392b; - --donation-banner-bg: #2c3e50; - --donation-banner-border: #1a2530; -} - + --badge-connected: #27ae60; + --badge-not-connected: #e74c3c; + --badge-text: #fff; + --app-tab-bg: #1a2530; + --app-tab-text: #ecf0f1; + --app-tab-active: #3498db; + --app-tab-hover: #2980b9; + --app-tab-border: #2c3e50; + --service-not-configured: #f39c12; + --welcome-panel-bg: #2c3e50; + --info-box-bg: #1a2530; + --main-content-bg: #2c3e50; +} + +/* Basic styles */ * { margin: 0; padding: 0; @@ -81,6 +102,7 @@ body { flex-direction: column; } +/* Header styling */ .header { background-color: var(--header-bg); color: var(--header-text); @@ -89,7 +111,7 @@ body { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1px; + margin-bottom: 0; flex-wrap: wrap; } @@ -102,18 +124,21 @@ body { .title-link { color: var(--header-text); text-decoration: none; + display: flex; + align-items: center; } .title-link:hover { text-decoration: underline; } -.edition { - font-weight: normal; - font-style: italic; - opacity: 0.8; +.huntarr-logo { + height: 40px; + margin-right: 10px; + vertical-align: middle; } +/* Navigation buttons */ .buttons { display: flex; gap: 10px; @@ -139,6 +164,7 @@ body { font-weight: bold; } +/* Content area */ .content-section { flex: 1; background-color: var(--log-background); @@ -150,6 +176,17 @@ body { margin-bottom: 20px; } +/* Home page specific */ +#homeContainer { + background-color: var(--main-content-bg); + color: var(--header-text); + padding: 20px; + border-left: 1px solid var(--container-border); + border-right: 1px solid var(--container-border); + border-bottom: 1px solid var(--container-border); +} + +/* Log viewer */ .log-controls { display: flex; justify-content: space-between; @@ -230,6 +267,7 @@ body { color: var(--debug-color); } +/* Footer */ .footer { text-align: center; padding: 15px 0; @@ -249,7 +287,7 @@ body { text-decoration: underline; } -/* Theme toggle styles */ +/* Theme toggle */ .theme-toggle { display: flex; align-items: center; @@ -312,7 +350,7 @@ input:checked + .slider:before { font-size: 14px; } -/* Settings page styles */ +/* Settings page */ .settings-form { padding: 20px; overflow-y: auto; @@ -347,8 +385,10 @@ input:checked + .slider:before { margin-right: 10px; } -.setting-item input[type="number"] { - width: 100px; +.setting-item input[type="number"], +.setting-item input[type="text"], +.setting-item input[type="password"] { + width: 300px; padding: 8px; border: 1px solid var(--input-border); border-radius: 5px; @@ -424,7 +464,6 @@ input:checked + .slider:before { flex-shrink: 0; } -/* Fixed width toggle button - this is the key fix */ .setting-item .toggle-switch { width: 50px; } @@ -440,7 +479,7 @@ input:checked + .slider:before { cursor: pointer; top: 0; left: 0; - width: 50px; /* Fixed width */ + width: 50px; bottom: 0; background-color: var(--switch-bg); transition: .4s; @@ -475,7 +514,6 @@ input:checked + .toggle-slider:before { opacity: 0.7; } -/* Adjust the existing save button styles to ensure they work with the disabled state */ .save-button:hover:not(.disabled-button) { background-color: var(--save-button-hover); } @@ -484,6 +522,377 @@ input:checked + .toggle-slider:before { background-color: var(--reset-button-hover); } +/* App selection tabs */ +.app-selector { + display: flex; + background-color: var(--app-tab-bg); + border-radius: 0; + margin-top: 0; + margin-bottom: 0; + overflow: hidden; +} + +.app-tab { + background-color: var(--app-tab-bg); + color: var(--app-tab-text); + border: 1px solid var(--app-tab-border); + border-bottom: none; + padding: 10px 20px; + flex: 1; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: background-color 0.3s; +} + +.app-tab:not(:last-child) { + border-right: none; +} + +.app-tab:hover { + background-color: var(--app-tab-hover); +} + +.app-tab.active { + background-color: var(--app-tab-active); + border-color: var(--app-tab-active); +} + +/* App settings sections */ +.app-settings { + display: none; +} + +.app-settings.active { + display: block; +} + +/* Connection badge */ +.connection-badge { + display: inline-block; + padding: 5px 10px; + border-radius: 5px; + font-size: 14px; + font-weight: bold; + margin-right: 10px; +} + +.connection-badge.connected { + background-color: var(--badge-connected); + color: var(--badge-text); +} + +.connection-badge.not-connected { + background-color: var(--badge-not-connected); + color: var(--badge-text); +} + +.test-connection-button { + background-color: var(--button-bg); + color: var(--button-text); + border: none; + padding: 5px 10px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + transition: background-color 0.3s; +} + +.test-connection-button:hover { + background-color: var(--button-hover); +} + +/* Service not configured message */ +.service-not-configured { + color: var(--service-not-configured); + font-weight: bold; + font-style: italic; + padding: 10px; + border: 1px dashed var(--service-not-configured); + border-radius: 5px; + margin: 10px 0; +} + +.code-block { + background-color: #000; + color: #f8f8f8; + padding: 10px; + border-radius: 5px; + font-family: monospace; + overflow-x: auto; + margin: 10px 0; +} + +/* Welcome page */ +.welcome-panel { + padding: 20px; + background-color: transparent; + color: var(--header-text); + margin-bottom: 20px; +} + +.welcome-panel h2 { + margin-bottom: 15px; + font-size: 24px; + color: var(--header-text); +} + +.welcome-panel h3 { + margin-top: 25px; + margin-bottom: 15px; + font-size: 18px; + color: var(--header-text); + border-bottom: 1px solid var(--app-tab-border); + padding-bottom: 8px; +} + +.welcome-panel p { + margin-bottom: 15px; + line-height: 1.6; +} + +.welcome-panel a { + color: var(--button-bg); + text-decoration: none; + font-weight: bold; +} + +.welcome-panel a:hover { + text-decoration: underline; +} + +.connection-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; +} + +.connection-item { + display: flex; + align-items: center; +} + +.connection-name { + width: 100px; + font-weight: bold; +} + +.info-box, .thank-you-box { + background-color: var(--info-box-bg); + border-radius: 8px; + padding: 15px; + margin-top: 25px; +} + +.info-box h3, .thank-you-box h3 { + margin-top: 0; + margin-bottom: 10px; + border-bottom: none; + padding-bottom: 0; +} + +/* Login and setup pages - UPDATED */ +.login-container { + max-width: 480px; /* Increased from 400px (20% increase) */ + margin: 100px auto; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.auth-header { + background-color: var(--header-bg); + padding: 20px; + border-radius: 10px 10px 0 0; + text-align: center; + margin-bottom: 0; +} + +.login-content { + background-color: var(--log-background); + padding: 30px; +} + +.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; +} + +.reset-instructions { + margin-top: 20px; + font-size: 14px; + text-align: center; +} + +.reset-link { + color: var(--button-bg); + text-decoration: none; + cursor: pointer; +} + +.reset-link:hover { + text-decoration: underline; +} + +/* QR code styles - UPDATED */ +.qr-code-container { + margin: 15px auto; + text-align: center; + max-width: 320px; +} + +.qr-code { + max-width: 160px; /* Reduced QR code size */ + margin: 0 auto; + background-color: white; + padding: 8px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.qr-code img { + width: 100%; + height: auto; + display: block; +} + +.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: 14px; + overflow-wrap: break-word; + word-break: break-all; +} + +.verification-code { + margin-top: 15px; + display: flex; + justify-content: center; + align-items: center; +} + +.verification-code input { + width: 180px; + 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: 16px; + margin-right: 10px; +} + +/* User settings page */ +.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); +} + +.logout-button { + background-color: var(--reset-button-bg); + color: white; + border: none; + padding: 8px 15px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + margin-top: 10px; + margin-bottom: 20px; + float: right; +} + +.logout-button:hover { + background-color: var(--reset-button-hover); +} + +.clear-float { + clear: both; +} + +.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; +} + +/* Responsive adjustments */ @media (max-width: 768px) { .header { flex-direction: column; @@ -503,4 +912,18 @@ input:checked + .toggle-slider:before { .setting-help { margin-left: 0; } + + .app-selector { + flex-wrap: wrap; + } + + .app-tab { + flex: none; + width: 50%; + } + + .login-container { + margin: 50px 20px; + max-width: none; + } } \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index 224d047b..1eba103e 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,7 +1,10 @@ document.addEventListener('DOMContentLoaded', function() { // DOM Elements + const homeButton = document.getElementById('homeButton'); const logsButton = document.getElementById('logsButton'); const settingsButton = document.getElementById('settingsButton'); + const userButton = document.getElementById('userButton'); + const homeContainer = document.getElementById('homeContainer'); const logsContainer = document.getElementById('logsContainer'); const settingsContainer = document.getElementById('settingsContainer'); const logsElement = document.getElementById('logs'); @@ -11,7 +14,44 @@ document.addEventListener('DOMContentLoaded', function() { const themeToggle = document.getElementById('themeToggle'); const themeLabel = document.getElementById('themeLabel'); - // Settings form elements - Basic settings + // App tabs + const appTabs = document.querySelectorAll('.app-tab'); + const appSettings = document.querySelectorAll('.app-settings'); + + // Connection status elements on home page + const sonarrHomeStatus = document.getElementById('sonarrHomeStatus'); + const radarrHomeStatus = document.getElementById('radarrHomeStatus'); + const lidarrHomeStatus = document.getElementById('lidarrHomeStatus'); + const readarrHomeStatus = document.getElementById('readarrHomeStatus'); + + // Current selected app + let currentApp = 'sonarr'; + + // App settings - Sonarr + const sonarrApiUrlInput = document.getElementById('sonarr_api_url'); + const sonarrApiKeyInput = document.getElementById('sonarr_api_key'); + const sonarrConnectionStatus = document.getElementById('sonarrConnectionStatus'); + const testSonarrConnectionButton = document.getElementById('testSonarrConnection'); + + // App settings - Radarr + const radarrApiUrlInput = document.getElementById('radarr_api_url'); + const radarrApiKeyInput = document.getElementById('radarr_api_key'); + const radarrConnectionStatus = document.getElementById('radarrConnectionStatus'); + const testRadarrConnectionButton = document.getElementById('testRadarrConnection'); + + // App settings - Lidarr + const lidarrApiUrlInput = document.getElementById('lidarr_api_url'); + const lidarrApiKeyInput = document.getElementById('lidarr_api_key'); + const lidarrConnectionStatus = document.getElementById('lidarrConnectionStatus'); + const testLidarrConnectionButton = document.getElementById('testLidarrConnection'); + + // App settings - Readarr + const readarrApiUrlInput = document.getElementById('readarr_api_url'); + const readarrApiKeyInput = document.getElementById('readarr_api_key'); + const readarrConnectionStatus = document.getElementById('readarrConnectionStatus'); + const testReadarrConnectionButton = document.getElementById('testReadarrConnection'); + + // Settings form elements - Basic settings (Sonarr) const huntMissingShowsInput = document.getElementById('hunt_missing_shows'); const huntUpgradeEpisodesInput = document.getElementById('hunt_upgrade_episodes'); const sleepDurationInput = document.getElementById('sleep_duration'); @@ -39,8 +79,66 @@ document.addEventListener('DOMContentLoaded', function() { // Store original settings values let originalSettings = {}; + // Track which apps are configured + const configuredApps = { + sonarr: false, + radarr: false, + lidarr: false, + readarr: false + }; + + // App selection handler + appTabs.forEach(tab => { + tab.addEventListener('click', function() { + const app = this.dataset.app; + + // If it's already the active app, do nothing + if (app === currentApp) return; + + // Update active tab + appTabs.forEach(t => t.classList.remove('active')); + this.classList.add('active'); + + // Update active settings panel if on settings page + if (settingsContainer && settingsContainer.style.display !== 'none') { + appSettings.forEach(s => s.style.display = 'none'); + document.getElementById(`${app}Settings`).style.display = 'block'; + } + + // Update current app + currentApp = app; + + // Load settings for this app + loadSettings(app); + + // For logs, we need to refresh the log stream + if (logsElement && logsContainer && logsContainer.style.display !== 'none') { + // Clear the logs first + logsElement.innerHTML = ''; + + // Update connection status based on configuration + if (statusElement) { + if (configuredApps[app]) { + statusElement.textContent = 'Connected'; + statusElement.className = 'status-connected'; + } else { + statusElement.textContent = 'Disconnected'; + statusElement.className = 'status-disconnected'; + } + } + + // Reconnect the event source only if app is configured + if (configuredApps[app]) { + connectEventSource(app); + } + } + }); + }); + // Update sleep duration display function updateSleepDurationDisplay() { + if (!sleepDurationInput || !sleepDurationHoursSpan) return; + const seconds = parseInt(sleepDurationInput.value) || 900; let displayText = ''; @@ -62,10 +160,12 @@ document.addEventListener('DOMContentLoaded', function() { sleepDurationHoursSpan.textContent = displayText; } - sleepDurationInput.addEventListener('input', function() { - updateSleepDurationDisplay(); - checkForChanges(); - }); + if (sleepDurationInput) { + sleepDurationInput.addEventListener('input', function() { + updateSleepDurationDisplay(); + checkForChanges(); + }); + } // Theme management function loadTheme() { @@ -74,8 +174,8 @@ document.addEventListener('DOMContentLoaded', function() { .then(data => { const isDarkMode = data.dark_mode || false; setTheme(isDarkMode); - themeToggle.checked = isDarkMode; - themeLabel.textContent = isDarkMode ? 'Dark Mode' : 'Light Mode'; + if (themeToggle) themeToggle.checked = isDarkMode; + if (themeLabel) themeLabel.textContent = isDarkMode ? 'Dark Mode' : 'Light Mode'; }) .catch(error => console.error('Error loading theme:', error)); } @@ -83,109 +183,384 @@ document.addEventListener('DOMContentLoaded', function() { function setTheme(isDark) { if (isDark) { document.body.classList.add('dark-theme'); - themeLabel.textContent = 'Dark Mode'; + if (themeLabel) themeLabel.textContent = 'Dark Mode'; } else { document.body.classList.remove('dark-theme'); - themeLabel.textContent = 'Light Mode'; + if (themeLabel) themeLabel.textContent = 'Light Mode'; + } + } + + if (themeToggle) { + themeToggle.addEventListener('change', function() { + const isDarkMode = this.checked; + setTheme(isDarkMode); + + fetch('/api/settings/theme', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ dark_mode: isDarkMode }) + }) + .catch(error => console.error('Error saving theme:', error)); + }); + } + + // Get user's name for welcome message + function getUserInfo() { + // This is a placeholder - in a real implementation, you'd likely have an API + // to get the current user's information + const username = document.getElementById('username'); + if (username) { + username.textContent = 'User'; // Default placeholder + } + } + + // Update connection status on the home page + function updateHomeConnectionStatus() { + // Check current configured state + fetch('/api/configured-apps') + .then(response => response.json()) + .then(data => { + // Update the configuredApps object + configuredApps.sonarr = data.sonarr || false; + configuredApps.radarr = data.radarr || false; + configuredApps.lidarr = data.lidarr || false; + configuredApps.readarr = data.readarr || false; + + // Update UI elements + // Sonarr status + if (sonarrHomeStatus) { + if (configuredApps.sonarr) { + sonarrHomeStatus.textContent = 'Configured'; + sonarrHomeStatus.className = 'connection-badge connected'; + } else { + sonarrHomeStatus.textContent = 'Not Configured'; + sonarrHomeStatus.className = 'connection-badge not-connected'; + } + } + + // Radarr status + if (radarrHomeStatus) { + if (configuredApps.radarr) { + radarrHomeStatus.textContent = 'Configured'; + radarrHomeStatus.className = 'connection-badge connected'; + } else { + radarrHomeStatus.textContent = 'Not Configured'; + radarrHomeStatus.className = 'connection-badge not-connected'; + } + } + + // Lidarr status + if (lidarrHomeStatus) { + if (configuredApps.lidarr) { + lidarrHomeStatus.textContent = 'Configured'; + lidarrHomeStatus.className = 'connection-badge connected'; + } else { + lidarrHomeStatus.textContent = 'Not Configured'; + lidarrHomeStatus.className = 'connection-badge not-connected'; + } + } + + // Readarr status + if (readarrHomeStatus) { + if (configuredApps.readarr) { + readarrHomeStatus.textContent = 'Configured'; + readarrHomeStatus.className = 'connection-badge connected'; + } else { + readarrHomeStatus.textContent = 'Not Configured'; + readarrHomeStatus.className = 'connection-badge not-connected'; + } + } + }) + .catch(error => console.error('Error checking configured apps:', error)); + } + + // Update logs connection status + function updateLogsConnectionStatus() { + if (statusElement) { + if (configuredApps[currentApp]) { + statusElement.textContent = 'Connected'; + statusElement.className = 'status-connected'; + } else { + statusElement.textContent = 'Disconnected'; + statusElement.className = 'status-disconnected'; + } } } - themeToggle.addEventListener('change', function() { - const isDarkMode = this.checked; - setTheme(isDarkMode); + // Tab switching - Toggle visibility of containers + if (homeButton && logsButton && settingsButton && homeContainer && logsContainer && settingsContainer) { + homeButton.addEventListener('click', function() { + homeContainer.style.display = 'flex'; + logsContainer.style.display = 'none'; + settingsContainer.style.display = 'none'; + homeButton.classList.add('active'); + logsButton.classList.remove('active'); + settingsButton.classList.remove('active'); + userButton.classList.remove('active'); + + // Update connection status on home page + updateHomeConnectionStatus(); + }); + + logsButton.addEventListener('click', function() { + homeContainer.style.display = 'none'; + logsContainer.style.display = 'flex'; + settingsContainer.style.display = 'none'; + homeButton.classList.remove('active'); + logsButton.classList.add('active'); + settingsButton.classList.remove('active'); + userButton.classList.remove('active'); + + // Update the connection status based on configuration + updateLogsConnectionStatus(); + + // Reconnect to logs for the current app if configured + if (logsElement && configuredApps[currentApp]) { + connectEventSource(currentApp); + } + }); - fetch('/api/settings/theme', { + settingsButton.addEventListener('click', function() { + homeContainer.style.display = 'none'; + logsContainer.style.display = 'none'; + settingsContainer.style.display = 'flex'; + homeButton.classList.remove('active'); + logsButton.classList.remove('active'); + settingsButton.classList.add('active'); + userButton.classList.remove('active'); + + // Show the settings for the current app + appSettings.forEach(s => s.style.display = 'none'); + document.getElementById(`${currentApp}Settings`).style.display = 'block'; + + // Make sure settings are loaded + loadSettings(currentApp); + }); + + userButton.addEventListener('click', function() { + window.location.href = '/user'; + }); + } + + // Log management + if (clearLogsButton) { + clearLogsButton.addEventListener('click', function() { + if (logsElement) logsElement.innerHTML = ''; + }); + } + + // Auto-scroll function + function scrollToBottom() { + if (autoScrollCheckbox && autoScrollCheckbox.checked && logsElement) { + logsElement.scrollTop = logsElement.scrollHeight; + } + } + + // Test connection functions + function testConnection(app, urlInput, keyInput, statusElement) { + const apiUrl = urlInput.value; + const apiKey = keyInput.value; + + if (!apiUrl || !apiKey) { + alert(`Please enter both API URL and API Key for ${app.charAt(0).toUpperCase() + app.slice(1)} before testing the connection.`); + return; + } + + // Test API connection + if (statusElement) { + statusElement.textContent = 'Testing...'; + statusElement.className = 'connection-badge'; + } + + fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dark_mode: isDarkMode }) + body: JSON.stringify({ + app: app, + api_url: apiUrl, + api_key: apiKey + }) }) - .catch(error => console.error('Error saving theme:', error)); - }); + .then(response => response.json()) + .then(data => { + if (data.success) { + if (statusElement) { + statusElement.textContent = 'Connected'; + statusElement.className = 'connection-badge connected'; + } + + // Update configuration status + configuredApps[app] = true; + + // Update home page status + updateHomeConnectionStatus(); + } else { + if (statusElement) { + statusElement.textContent = 'Connection Failed'; + statusElement.className = 'connection-badge not-connected'; + } + + // Update configuration status + configuredApps[app] = false; + + alert(`Connection failed: ${data.message}`); + } + }) + .catch(error => { + console.error(`Error testing ${app} connection:`, error); + if (statusElement) { + statusElement.textContent = 'Connection Error'; + statusElement.className = 'connection-badge not-connected'; + } + + // Update configuration status + configuredApps[app] = false; + + alert(`Error testing ${app} connection: ` + error.message); + }); + } - // Tab switching - logsButton.addEventListener('click', function() { - logsContainer.style.display = 'flex'; - settingsContainer.style.display = 'none'; - logsButton.classList.add('active'); - settingsButton.classList.remove('active'); - }); + // Test connection for all apps + if (testSonarrConnectionButton) { + testSonarrConnectionButton.addEventListener('click', function() { + testConnection('sonarr', sonarrApiUrlInput, sonarrApiKeyInput, sonarrConnectionStatus); + }); + } - settingsButton.addEventListener('click', function() { - logsContainer.style.display = 'none'; - settingsContainer.style.display = 'flex'; - settingsButton.classList.add('active'); - logsButton.classList.remove('active'); - loadSettings(); - }); + if (testRadarrConnectionButton) { + testRadarrConnectionButton.addEventListener('click', function() { + testConnection('radarr', radarrApiUrlInput, radarrApiKeyInput, radarrConnectionStatus); + }); + } - // Log management - clearLogsButton.addEventListener('click', function() { - logsElement.innerHTML = ''; - }); + if (testLidarrConnectionButton) { + testLidarrConnectionButton.addEventListener('click', function() { + testConnection('lidarr', lidarrApiUrlInput, lidarrApiKeyInput, lidarrConnectionStatus); + }); + } - // Auto-scroll function - function scrollToBottom() { - if (autoScrollCheckbox.checked) { - logsElement.scrollTop = logsElement.scrollHeight; - } + if (testReadarrConnectionButton) { + testReadarrConnectionButton.addEventListener('click', function() { + testConnection('readarr', readarrApiUrlInput, readarrApiKeyInput, readarrConnectionStatus); + }); } // Function to check if settings have changed from original values function checkForChanges() { - if (!originalSettings.huntarr) return; // Don't check if original settings not loaded + if (!originalSettings.huntarr) return false; // Don't check if original settings not loaded let hasChanges = false; - // Check Basic Settings - if (parseInt(huntMissingShowsInput.value) !== originalSettings.huntarr.hunt_missing_shows) hasChanges = true; - if (parseInt(huntUpgradeEpisodesInput.value) !== originalSettings.huntarr.hunt_upgrade_episodes) hasChanges = true; - if (parseInt(sleepDurationInput.value) !== originalSettings.huntarr.sleep_duration) hasChanges = true; - if (parseInt(stateResetIntervalInput.value) !== originalSettings.huntarr.state_reset_interval_hours) hasChanges = true; - if (monitoredOnlyInput.checked !== originalSettings.huntarr.monitored_only) hasChanges = true; - if (skipFutureEpisodesInput.checked !== originalSettings.huntarr.skip_future_episodes) hasChanges = true; - if (skipSeriesRefreshInput.checked !== originalSettings.huntarr.skip_series_refresh) hasChanges = true; + // API connection settings + if (currentApp === 'sonarr') { + if (sonarrApiUrlInput && sonarrApiUrlInput.value !== originalSettings.api_url) hasChanges = true; + if (sonarrApiKeyInput && sonarrApiKeyInput.value !== originalSettings.api_key) hasChanges = true; + } else if (currentApp === 'radarr') { + if (radarrApiUrlInput && radarrApiUrlInput.dataset.originalValue !== undefined && + radarrApiUrlInput.value !== radarrApiUrlInput.dataset.originalValue) hasChanges = true; + if (radarrApiKeyInput && radarrApiKeyInput.dataset.originalValue !== undefined && + radarrApiKeyInput.value !== radarrApiKeyInput.dataset.originalValue) hasChanges = true; + } else if (currentApp === 'lidarr') { + if (lidarrApiUrlInput && lidarrApiUrlInput.dataset.originalValue !== undefined && + lidarrApiUrlInput.value !== lidarrApiUrlInput.dataset.originalValue) hasChanges = true; + if (lidarrApiKeyInput && lidarrApiKeyInput.dataset.originalValue !== undefined && + lidarrApiKeyInput.value !== lidarrApiKeyInput.dataset.originalValue) hasChanges = true; + } else if (currentApp === 'readarr') { + if (readarrApiUrlInput && readarrApiUrlInput.dataset.originalValue !== undefined && + readarrApiUrlInput.value !== readarrApiUrlInput.dataset.originalValue) hasChanges = true; + if (readarrApiKeyInput && readarrApiKeyInput.dataset.originalValue !== undefined && + readarrApiKeyInput.value !== readarrApiKeyInput.dataset.originalValue) hasChanges = true; + } - // Check Advanced Settings - if (parseInt(apiTimeoutInput.value) !== originalSettings.advanced.api_timeout) hasChanges = true; - if (debugModeInput.checked !== originalSettings.advanced.debug_mode) hasChanges = true; - if (parseInt(commandWaitDelayInput.value) !== originalSettings.advanced.command_wait_delay) hasChanges = true; - if (parseInt(commandWaitAttemptsInput.value) !== originalSettings.advanced.command_wait_attempts) hasChanges = true; - if (parseInt(minimumDownloadQueueSizeInput.value) !== originalSettings.advanced.minimum_download_queue_size) hasChanges = true; - if (randomMissingInput.checked !== originalSettings.advanced.random_missing) hasChanges = true; - if (randomUpgradesInput.checked !== originalSettings.advanced.random_upgrades) hasChanges = true; + // Check Sonarr Settings + if (currentApp === 'sonarr') { + // Check Basic Settings + if (huntMissingShowsInput && parseInt(huntMissingShowsInput.value) !== originalSettings.huntarr.hunt_missing_shows) hasChanges = true; + if (huntUpgradeEpisodesInput && parseInt(huntUpgradeEpisodesInput.value) !== originalSettings.huntarr.hunt_upgrade_episodes) hasChanges = true; + if (sleepDurationInput && parseInt(sleepDurationInput.value) !== originalSettings.huntarr.sleep_duration) hasChanges = true; + if (stateResetIntervalInput && parseInt(stateResetIntervalInput.value) !== originalSettings.huntarr.state_reset_interval_hours) hasChanges = true; + if (monitoredOnlyInput && monitoredOnlyInput.checked !== originalSettings.huntarr.monitored_only) hasChanges = true; + if (skipFutureEpisodesInput && skipFutureEpisodesInput.checked !== originalSettings.huntarr.skip_future_episodes) hasChanges = true; + if (skipSeriesRefreshInput && skipSeriesRefreshInput.checked !== originalSettings.huntarr.skip_series_refresh) hasChanges = true; + + // Check Advanced Settings + if (apiTimeoutInput && parseInt(apiTimeoutInput.value) !== originalSettings.advanced.api_timeout) hasChanges = true; + if (debugModeInput && debugModeInput.checked !== originalSettings.advanced.debug_mode) hasChanges = true; + if (commandWaitDelayInput && parseInt(commandWaitDelayInput.value) !== originalSettings.advanced.command_wait_delay) hasChanges = true; + if (commandWaitAttemptsInput && parseInt(commandWaitAttemptsInput.value) !== originalSettings.advanced.command_wait_attempts) hasChanges = true; + if (minimumDownloadQueueSizeInput && parseInt(minimumDownloadQueueSizeInput.value) !== originalSettings.advanced.minimum_download_queue_size) hasChanges = true; + if (randomMissingInput && randomMissingInput.checked !== originalSettings.advanced.random_missing) hasChanges = true; + if (randomUpgradesInput && randomUpgradesInput.checked !== originalSettings.advanced.random_upgrades) hasChanges = true; + } // Enable/disable save buttons based on whether there are changes - saveSettingsButton.disabled = !hasChanges; - saveSettingsBottomButton.disabled = !hasChanges; - - // Apply visual indicator based on disabled state - if (hasChanges) { - saveSettingsButton.classList.remove('disabled-button'); - saveSettingsBottomButton.classList.remove('disabled-button'); - } else { - saveSettingsButton.classList.add('disabled-button'); - saveSettingsBottomButton.classList.add('disabled-button'); + if (saveSettingsButton && saveSettingsBottomButton) { + saveSettingsButton.disabled = !hasChanges; + saveSettingsBottomButton.disabled = !hasChanges; + + // Apply visual indicator based on disabled state + if (hasChanges) { + saveSettingsButton.classList.remove('disabled-button'); + saveSettingsBottomButton.classList.remove('disabled-button'); + } else { + saveSettingsButton.classList.add('disabled-button'); + saveSettingsBottomButton.classList.add('disabled-button'); + } } return hasChanges; } - // Add change event listeners to all form elements - [huntMissingShowsInput, huntUpgradeEpisodesInput, stateResetIntervalInput, - apiTimeoutInput, commandWaitDelayInput, commandWaitAttemptsInput, - minimumDownloadQueueSizeInput].forEach(input => { - input.addEventListener('input', checkForChanges); - }); + // Add change event listeners for Sonarr form elements + if (sonarrApiUrlInput && sonarrApiKeyInput) { + sonarrApiUrlInput.addEventListener('input', checkForChanges); + sonarrApiKeyInput.addEventListener('input', checkForChanges); + } - [monitoredOnlyInput, randomMissingInput, randomUpgradesInput, - skipFutureEpisodesInput, skipSeriesRefreshInput, debugModeInput].forEach(checkbox => { - checkbox.addEventListener('change', checkForChanges); - }); + // Add change event listeners for Radarr form elements + if (radarrApiUrlInput && radarrApiKeyInput) { + radarrApiUrlInput.addEventListener('input', checkForChanges); + radarrApiKeyInput.addEventListener('input', checkForChanges); + } + + // Add change event listeners for Lidarr form elements + if (lidarrApiUrlInput && lidarrApiKeyInput) { + lidarrApiUrlInput.addEventListener('input', checkForChanges); + lidarrApiKeyInput.addEventListener('input', checkForChanges); + } + + // Add change event listeners for Readarr form elements + if (readarrApiUrlInput && readarrApiKeyInput) { + readarrApiUrlInput.addEventListener('input', checkForChanges); + readarrApiKeyInput.addEventListener('input', checkForChanges); + } + + if (huntMissingShowsInput && huntUpgradeEpisodesInput && stateResetIntervalInput && + apiTimeoutInput && commandWaitDelayInput && commandWaitAttemptsInput && + minimumDownloadQueueSizeInput) { + + [huntMissingShowsInput, huntUpgradeEpisodesInput, stateResetIntervalInput, + apiTimeoutInput, commandWaitDelayInput, commandWaitAttemptsInput, + minimumDownloadQueueSizeInput].forEach(input => { + input.addEventListener('input', 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() { + function loadSettings(app = 'sonarr') { fetch('/api/settings') .then(response => response.json()) .then(data => { @@ -195,32 +570,217 @@ document.addEventListener('DOMContentLoaded', function() { // Store original settings for comparison originalSettings = JSON.parse(JSON.stringify(data)); - // Fill form with current settings - Basic settings - huntMissingShowsInput.value = huntarr.hunt_missing_shows !== undefined ? huntarr.hunt_missing_shows : 1; - huntUpgradeEpisodesInput.value = huntarr.hunt_upgrade_episodes !== undefined ? huntarr.hunt_upgrade_episodes : 5; - sleepDurationInput.value = huntarr.sleep_duration || 900; - updateSleepDurationDisplay(); - stateResetIntervalInput.value = huntarr.state_reset_interval_hours || 168; - monitoredOnlyInput.checked = huntarr.monitored_only !== false; - skipFutureEpisodesInput.checked = huntarr.skip_future_episodes !== false; - skipSeriesRefreshInput.checked = huntarr.skip_series_refresh === true; + // Connection settings for the current app + if (app === 'sonarr' && sonarrApiUrlInput && sonarrApiKeyInput) { + sonarrApiUrlInput.value = data.api_url || ''; + sonarrApiKeyInput.value = data.api_key || ''; + + // Update configured status for sonarr + configuredApps.sonarr = !!(data.api_url && data.api_key); + + // Update connection status + if (sonarrConnectionStatus) { + if (data.api_url && data.api_key) { + sonarrConnectionStatus.textContent = 'Configured'; + sonarrConnectionStatus.className = 'connection-badge connected'; + } else { + sonarrConnectionStatus.textContent = 'Not Configured'; + sonarrConnectionStatus.className = 'connection-badge not-connected'; + } + } + + // Sonarr-specific settings + if (huntMissingShowsInput) { + huntMissingShowsInput.value = huntarr.hunt_missing_shows !== undefined ? huntarr.hunt_missing_shows : 1; + } + if (huntUpgradeEpisodesInput) { + huntUpgradeEpisodesInput.value = huntarr.hunt_upgrade_episodes !== undefined ? huntarr.hunt_upgrade_episodes : 5; + } + if (sleepDurationInput) { + sleepDurationInput.value = huntarr.sleep_duration || 900; + updateSleepDurationDisplay(); + } + if (stateResetIntervalInput) { + stateResetIntervalInput.value = huntarr.state_reset_interval_hours || 168; + } + if (monitoredOnlyInput) { + monitoredOnlyInput.checked = huntarr.monitored_only !== false; + } + if (skipFutureEpisodesInput) { + skipFutureEpisodesInput.checked = huntarr.skip_future_episodes !== false; + } + if (skipSeriesRefreshInput) { + skipSeriesRefreshInput.checked = huntarr.skip_series_refresh === true; + } + + // Advanced settings + if (apiTimeoutInput) { + apiTimeoutInput.value = advanced.api_timeout || 60; + } + if (debugModeInput) { + debugModeInput.checked = advanced.debug_mode === true; + } + if (commandWaitDelayInput) { + commandWaitDelayInput.value = advanced.command_wait_delay || 1; + } + if (commandWaitAttemptsInput) { + commandWaitAttemptsInput.value = advanced.command_wait_attempts || 600; + } + if (minimumDownloadQueueSizeInput) { + minimumDownloadQueueSizeInput.value = advanced.minimum_download_queue_size || -1; + } + if (randomMissingInput) { + randomMissingInput.checked = advanced.random_missing !== false; + } + if (randomUpgradesInput) { + randomUpgradesInput.checked = advanced.random_upgrades !== false; + } + } else if (app === 'radarr' && radarrApiUrlInput && radarrApiKeyInput) { + // For Radarr (and other non-Sonarr apps), load from app-settings endpoint + fetch(`/api/app-settings?app=radarr`) + .then(response => response.json()) + .then(appData => { + if (appData.success) { + radarrApiUrlInput.value = appData.api_url || ''; + radarrApiKeyInput.value = appData.api_key || ''; + + // Store original values in data attributes for comparison + radarrApiUrlInput.dataset.originalValue = appData.api_url || ''; + radarrApiKeyInput.dataset.originalValue = appData.api_key || ''; + + // Update configured status + configuredApps.radarr = !!(appData.api_url && appData.api_key); + + // Update connection status + if (radarrConnectionStatus) { + if (appData.api_url && appData.api_key) { + radarrConnectionStatus.textContent = 'Configured'; + radarrConnectionStatus.className = 'connection-badge connected'; + } else { + radarrConnectionStatus.textContent = 'Not Configured'; + radarrConnectionStatus.className = 'connection-badge not-connected'; + } + } + } + }) + .catch(error => { + console.error('Error loading Radarr settings:', error); + + // Default values + radarrApiUrlInput.value = ''; + radarrApiKeyInput.value = ''; + radarrApiUrlInput.dataset.originalValue = ''; + radarrApiKeyInput.dataset.originalValue = ''; + configuredApps.radarr = false; + + if (radarrConnectionStatus) { + radarrConnectionStatus.textContent = 'Not Configured'; + radarrConnectionStatus.className = 'connection-badge not-connected'; + } + }); + } else if (app === 'lidarr' && lidarrApiUrlInput && lidarrApiKeyInput) { + // Load Lidarr settings + fetch(`/api/app-settings?app=lidarr`) + .then(response => response.json()) + .then(appData => { + if (appData.success) { + lidarrApiUrlInput.value = appData.api_url || ''; + lidarrApiKeyInput.value = appData.api_key || ''; + + // Store original values in data attributes for comparison + lidarrApiUrlInput.dataset.originalValue = appData.api_url || ''; + lidarrApiKeyInput.dataset.originalValue = appData.api_key || ''; + + // Update configured status + configuredApps.lidarr = !!(appData.api_url && appData.api_key); + + // Update connection status + if (lidarrConnectionStatus) { + if (appData.api_url && appData.api_key) { + lidarrConnectionStatus.textContent = 'Configured'; + lidarrConnectionStatus.className = 'connection-badge connected'; + } else { + lidarrConnectionStatus.textContent = 'Not Configured'; + lidarrConnectionStatus.className = 'connection-badge not-connected'; + } + } + } + }) + .catch(error => { + console.error('Error loading Lidarr settings:', error); + + // Default values + lidarrApiUrlInput.value = ''; + lidarrApiKeyInput.value = ''; + lidarrApiUrlInput.dataset.originalValue = ''; + lidarrApiKeyInput.dataset.originalValue = ''; + configuredApps.lidarr = false; + + if (lidarrConnectionStatus) { + lidarrConnectionStatus.textContent = 'Not Configured'; + lidarrConnectionStatus.className = 'connection-badge not-connected'; + } + }); + } else if (app === 'readarr' && readarrApiUrlInput && readarrApiKeyInput) { + // Load Readarr settings + fetch(`/api/app-settings?app=readarr`) + .then(response => response.json()) + .then(appData => { + if (appData.success) { + readarrApiUrlInput.value = appData.api_url || ''; + readarrApiKeyInput.value = appData.api_key || ''; + + // Store original values in data attributes for comparison + readarrApiUrlInput.dataset.originalValue = appData.api_url || ''; + readarrApiKeyInput.dataset.originalValue = appData.api_key || ''; + + // Update configured status + configuredApps.readarr = !!(appData.api_url && appData.api_key); + + // Update connection status + if (readarrConnectionStatus) { + if (appData.api_url && appData.api_key) { + readarrConnectionStatus.textContent = 'Configured'; + readarrConnectionStatus.className = 'connection-badge connected'; + } else { + readarrConnectionStatus.textContent = 'Not Configured'; + readarrConnectionStatus.className = 'connection-badge not-connected'; + } + } + } + }) + .catch(error => { + console.error('Error loading Readarr settings:', error); + + // Default values + readarrApiUrlInput.value = ''; + readarrApiKeyInput.value = ''; + readarrApiUrlInput.dataset.originalValue = ''; + readarrApiKeyInput.dataset.originalValue = ''; + configuredApps.readarr = false; + + if (readarrConnectionStatus) { + readarrConnectionStatus.textContent = 'Not Configured'; + readarrConnectionStatus.className = 'connection-badge not-connected'; + } + }); + } - // Fill form with current settings - Advanced settings - apiTimeoutInput.value = advanced.api_timeout || 60; - debugModeInput.checked = advanced.debug_mode === true; - commandWaitDelayInput.value = advanced.command_wait_delay || 1; - commandWaitAttemptsInput.value = advanced.command_wait_attempts || 600; - minimumDownloadQueueSizeInput.value = advanced.minimum_download_queue_size || -1; + // Update home page connection status + updateHomeConnectionStatus(); - // Handle random settings - randomMissingInput.checked = advanced.random_missing !== false; - randomUpgradesInput.checked = advanced.random_upgrades !== false; + // Update log connection status if on logs page + if (logsContainer && logsContainer.style.display !== 'none') { + updateLogsConnectionStatus(); + } // Initialize save buttons state - saveSettingsButton.disabled = true; - saveSettingsBottomButton.disabled = true; - saveSettingsButton.classList.add('disabled-button'); - saveSettingsBottomButton.classList.add('disabled-button'); + if (saveSettingsButton && saveSettingsBottomButton) { + saveSettingsButton.disabled = true; + saveSettingsBottomButton.disabled = true; + saveSettingsButton.classList.add('disabled-button'); + saveSettingsBottomButton.classList.add('disabled-button'); + } }) .catch(error => console.error('Error loading settings:', error)); } @@ -232,27 +792,53 @@ document.addEventListener('DOMContentLoaded', function() { return; } - const settings = { - huntarr: { - hunt_missing_shows: parseInt(huntMissingShowsInput.value) || 0, - hunt_upgrade_episodes: parseInt(huntUpgradeEpisodesInput.value) || 0, - sleep_duration: parseInt(sleepDurationInput.value) || 900, - state_reset_interval_hours: parseInt(stateResetIntervalInput.value) || 168, - monitored_only: monitoredOnlyInput.checked, - skip_future_episodes: skipFutureEpisodesInput.checked, - skip_series_refresh: skipSeriesRefreshInput.checked - }, - advanced: { - api_timeout: parseInt(apiTimeoutInput.value) || 60, - debug_mode: debugModeInput.checked, - command_wait_delay: parseInt(commandWaitDelayInput.value) || 1, - command_wait_attempts: parseInt(commandWaitAttemptsInput.value) || 600, - minimum_download_queue_size: parseInt(minimumDownloadQueueSizeInput.value) || -1, - random_missing: randomMissingInput.checked, - random_upgrades: randomUpgradesInput.checked - } + // Ask user if they want to restart the container for changes to take effect immediately + const restartContainer = confirm('Save settings and restart the container for changes to take effect immediately?\n\nClick OK to restart container or Cancel to not do anything and go back.'); + + // Prepare settings object based on current app + let settings = { + app_type: currentApp, + restart_container: restartContainer // Add restart flag }; + // Add API connection settings + if (currentApp === 'sonarr' && sonarrApiUrlInput && sonarrApiKeyInput) { + settings.api_url = sonarrApiUrlInput.value || ''; + settings.api_key = sonarrApiKeyInput.value || ''; + } else if (currentApp === 'radarr' && radarrApiUrlInput && radarrApiKeyInput) { + settings.api_url = radarrApiUrlInput.value || ''; + settings.api_key = radarrApiKeyInput.value || ''; + } else if (currentApp === 'lidarr' && lidarrApiUrlInput && lidarrApiKeyInput) { + settings.api_url = lidarrApiUrlInput.value || ''; + settings.api_key = lidarrApiKeyInput.value || ''; + } else if (currentApp === 'readarr' && readarrApiUrlInput && readarrApiKeyInput) { + settings.api_url = readarrApiUrlInput.value || ''; + settings.api_key = readarrApiKeyInput.value || ''; + } + + // Add other settings based on which app is active + if (currentApp === 'sonarr') { + settings.huntarr = { + hunt_missing_shows: huntMissingShowsInput ? parseInt(huntMissingShowsInput.value) || 0 : 0, + hunt_upgrade_episodes: huntUpgradeEpisodesInput ? parseInt(huntUpgradeEpisodesInput.value) || 0 : 0, + sleep_duration: sleepDurationInput ? parseInt(sleepDurationInput.value) || 900 : 900, + state_reset_interval_hours: stateResetIntervalInput ? parseInt(stateResetIntervalInput.value) || 168 : 168, + monitored_only: monitoredOnlyInput ? monitoredOnlyInput.checked : true, + skip_future_episodes: skipFutureEpisodesInput ? skipFutureEpisodesInput.checked : true, + skip_series_refresh: skipSeriesRefreshInput ? skipSeriesRefreshInput.checked : false + }; + settings.advanced = { + debug_mode: debugModeInput ? debugModeInput.checked : false, + command_wait_delay: commandWaitDelayInput ? parseInt(commandWaitDelayInput.value) || 1 : 1, + command_wait_attempts: commandWaitAttemptsInput ? parseInt(commandWaitAttemptsInput.value) || 600 : 600, + minimum_download_queue_size: minimumDownloadQueueSizeInput ? parseInt(minimumDownloadQueueSizeInput.value) || -1 : -1, + random_missing: randomMissingInput ? randomMissingInput.checked : true, + random_upgrades: randomUpgradesInput ? randomUpgradesInput.checked : true, + api_timeout: apiTimeoutInput ? parseInt(apiTimeoutInput.value) || 60 : 60 + }; + } + // Add similar blocks for other app types when they're implemented + fetch('/api/settings', { method: 'POST', headers: { @@ -263,14 +849,70 @@ document.addEventListener('DOMContentLoaded', function() { .then(response => response.json()) .then(data => { if (data.success) { + // Check if operation was cancelled + if (data.cancelled) { + alert('Operation cancelled - no changes were saved.'); + return; + } + // Update original settings after successful save - originalSettings = JSON.parse(JSON.stringify(settings)); + if (currentApp === 'sonarr') { + originalSettings.api_url = settings.api_url; + originalSettings.api_key = settings.api_key; + + // Update the rest of originalSettings + if (settings.huntarr) originalSettings.huntarr = {...settings.huntarr}; + if (settings.advanced) originalSettings.advanced = {...settings.advanced}; + } else if (currentApp === 'radarr') { + // Store the original values in data attributes for comparison + if (radarrApiUrlInput) radarrApiUrlInput.dataset.originalValue = settings.api_url; + if (radarrApiKeyInput) radarrApiKeyInput.dataset.originalValue = settings.api_key; + } else if (currentApp === 'lidarr') { + // Store the original values in data attributes for comparison + if (lidarrApiUrlInput) lidarrApiUrlInput.dataset.originalValue = settings.api_url; + if (lidarrApiKeyInput) lidarrApiKeyInput.dataset.originalValue = settings.api_key; + } else if (currentApp === 'readarr') { + // Store the original values in data attributes for comparison + if (readarrApiUrlInput) readarrApiUrlInput.dataset.originalValue = settings.api_url; + if (readarrApiKeyInput) readarrApiKeyInput.dataset.originalValue = settings.api_key; + } + + // Update configuration status based on API URL and API key + if (currentApp === 'sonarr') { + configuredApps.sonarr = !!(settings.api_url && settings.api_key); + } else if (currentApp === 'radarr') { + configuredApps.radarr = !!(settings.api_url && settings.api_key); + } else if (currentApp === 'lidarr') { + configuredApps.lidarr = !!(settings.api_url && settings.api_key); + } else if (currentApp === 'readarr') { + configuredApps.readarr = !!(settings.api_url && settings.api_key); + } + + // Update connection status + updateConnectionStatus(); + + // Update home page connection status + updateHomeConnectionStatus(); + + // Update logs connection status + updateLogsConnectionStatus(); // Disable save buttons - saveSettingsButton.disabled = true; - saveSettingsBottomButton.disabled = true; - saveSettingsButton.classList.add('disabled-button'); - saveSettingsBottomButton.classList.add('disabled-button'); + if (saveSettingsButton && saveSettingsBottomButton) { + saveSettingsButton.disabled = true; + saveSettingsBottomButton.disabled = true; + saveSettingsButton.classList.add('disabled-button'); + saveSettingsBottomButton.classList.add('disabled-button'); + } + + // Handle restart case differently + if (data.restarting) { + alert('Settings saved successfully. Container is now restarting. The page will reload in 5 seconds.'); + setTimeout(function() { + window.location.reload(); + }, 5000); + return; + } // Show success message if (data.changes_made) { @@ -288,17 +930,83 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // Function to update connection status + function updateConnectionStatus() { + if (currentApp === 'sonarr' && sonarrConnectionStatus) { + if (configuredApps.sonarr) { + sonarrConnectionStatus.textContent = 'Configured'; + sonarrConnectionStatus.className = 'connection-badge connected'; + } else { + sonarrConnectionStatus.textContent = 'Not Configured'; + sonarrConnectionStatus.className = 'connection-badge not-connected'; + } + } else if (currentApp === 'radarr' && radarrConnectionStatus) { + if (configuredApps.radarr) { + radarrConnectionStatus.textContent = 'Configured'; + radarrConnectionStatus.className = 'connection-badge connected'; + } else { + radarrConnectionStatus.textContent = 'Not Configured'; + radarrConnectionStatus.className = 'connection-badge not-connected'; + } + } else if (currentApp === 'lidarr' && lidarrConnectionStatus) { + if (configuredApps.lidarr) { + lidarrConnectionStatus.textContent = 'Configured'; + lidarrConnectionStatus.className = 'connection-badge connected'; + } else { + lidarrConnectionStatus.textContent = 'Not Configured'; + lidarrConnectionStatus.className = 'connection-badge not-connected'; + } + } else if (currentApp === 'readarr' && readarrConnectionStatus) { + if (configuredApps.readarr) { + readarrConnectionStatus.textContent = 'Configured'; + readarrConnectionStatus.className = 'connection-badge connected'; + } else { + readarrConnectionStatus.textContent = 'Not Configured'; + readarrConnectionStatus.className = 'connection-badge not-connected'; + } + } + } + // Function to reset settings function resetSettings() { if (confirm('Are you sure you want to reset all settings to default values?')) { + const restartContainer = confirm('Reset settings and restart the container for changes to take effect immediately?\n\nClick OK to restart container, or Cancel to just reset settings without restart.'); + fetch('/api/settings/reset', { - method: 'POST' + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + app: currentApp, + restart_container: restartContainer + }) }) .then(response => response.json()) .then(data => { if (data.success) { + // Check if operation was cancelled + if (data.cancelled) { + alert('Operation cancelled - settings were not reset.'); + return; + } + + if (data.restarting) { + alert('Settings reset to defaults. Container is now restarting. The page will reload in 5 seconds.'); + setTimeout(function() { + window.location.reload(); + }, 5000); + return; + } + alert('Settings reset to defaults and cycle restarted.'); - loadSettings(); + loadSettings(currentApp); + + // Update home page connection status + updateHomeConnectionStatus(); + + // Update logs connection status + updateLogsConnectionStatus(); } else { alert('Error resetting settings: ' + (data.message || 'Unknown error')); } @@ -311,33 +1019,46 @@ 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() { + function connectEventSource(app = 'sonarr') { + if (!logsElement) return; // Skip if not on logs page + if (!configuredApps[app]) return; // Skip if app not configured + if (eventSource) { eventSource.close(); } - eventSource = new EventSource('/logs'); + eventSource = new EventSource(`/logs?app=${app}`); eventSource.onopen = function() { - statusElement.textContent = 'Connected'; - statusElement.className = 'status-connected'; + if (statusElement) { + statusElement.textContent = 'Connected'; + statusElement.className = 'status-connected'; + } }; eventSource.onerror = function() { - statusElement.textContent = 'Disconnected'; - statusElement.className = 'status-disconnected'; + if (statusElement) { + statusElement.textContent = 'Disconnected'; + statusElement.className = 'status-disconnected'; + } - // Attempt to reconnect after 5 seconds - setTimeout(connectEventSource, 5000); + // Attempt to reconnect after 5 seconds if app is still configured + setTimeout(() => { + if (configuredApps[app]) { + connectEventSource(app); + } + }, 5000); }; eventSource.onmessage = function(event) { @@ -364,24 +1085,69 @@ 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 && 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(); + } + + // Get user info for welcome page + getUserInfo(); + + // Load settings for initial app + loadSettings(currentApp); + + // Check if we're on the settings page by URL path + const path = window.location.pathname; + + // Show proper content based on path or hash + if (path === '/settings') { + // Show settings page + if (homeContainer) homeContainer.style.display = 'none'; + if (logsContainer) logsContainer.style.display = 'none'; + if (settingsContainer) settingsContainer.style.display = 'flex'; + + if (homeButton) homeButton.classList.remove('active'); + if (logsButton) logsButton.classList.remove('active'); + if (settingsButton) settingsButton.classList.add('active'); + if (userButton) userButton.classList.remove('active'); + } else if (path === '/') { + // Default to home page + if (homeContainer) homeContainer.style.display = 'flex'; + if (logsContainer) logsContainer.style.display = 'none'; + if (settingsContainer) settingsContainer.style.display = 'none'; + + if (homeButton) homeButton.classList.add('active'); + if (logsButton) logsButton.classList.remove('active'); + if (settingsButton) settingsButton.classList.remove('active'); + if (userButton) userButton.classList.remove('active'); + + // Update connection status on home page + updateHomeConnectionStatus(); + } + + // Connect to logs if we're on the logs page and the current app is configured + if (logsElement && logsContainer && logsContainer.style.display !== 'none' && configuredApps[currentApp]) { + connectEventSource(currentApp); + } }); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 7d79bdd1..bf74c6bf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,17 +3,19 @@ - Huntarr-Sonarr + Huntarr
-

Huntarr [Sonarr Edition]

+

Huntarr

- + + +
-
+
+
+

Welcome to Huntarr, User!

+

Huntarr helps you find missing media and upgrade quality across multiple Arr applications.

+ +

Connection Status

+
+
+ Sonarr: + Not Configured +
+
+ Radarr: + Not Configured +
+
+ Lidarr: + Not Configured +
+
+ Readarr: + Not Configured +
+
+ +
+ +
+

Special Thanks

+

Thank you to ZPatten and MacaidenXIII for really getting this project kickstarted with great ideas!

+

Thank you to ninjacentral.co.za for the original inspired insight on API indexer issues which also inspired the program.

+

I thank my daughter who provides inspiration to do everything in life.

+

Thank you for using Huntarr ~ Admin9705

+
+
+
+ +