Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 40 additions & 15 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
name: Docker Build and Push

on:
push:
branches:
- main
- dev
tags:
- "*" # This will trigger on any tag push
pull_request:
branches:
- main

jobs:
build-and-push:
runs-on: ubuntu-latest
Expand All @@ -18,51 +18,76 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0

# 2) List files to verify huntarr.py is present
- name: List files in directory
run: ls -la

# 3) Set up Docker Buildx

# 3) Set up QEMU for multi-architecture builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: arm64,amd64

# 4) Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

# 4) Log in to Docker Hub
# 5) Log in to Docker Hub
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}

# 5a) Build & Push if on 'main' branch

# 6) Extract version from tag if it's a tag push
- name: Extract version from tag
if: startsWith(github.ref, 'refs/tags/')
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

# 7a) Build & Push if on 'main' branch
- name: Build and Push (main)
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/4sonarr:latest
huntarr/4sonarr:py-latest
huntarr/4sonarr:${{ github.sha }}

# 5b) Build & Push if on 'dev' branch
# 7b) Build & Push if on 'dev' branch
- name: Build and Push (dev)
if: github.ref == 'refs/heads/dev' && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/4sonarr:dev
huntarr/4sonarr:py-dev
huntarr/4sonarr:${{ github.sha }}

# 5c) Just build on pull requests
# 7c) Build & Push if it's a tag/release
- name: Build and Push (release)
if: startsWith(github.ref, 'refs/tags/') && github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
huntarr/4sonarr:${{ steps.get_version.outputs.VERSION }}
huntarr/4sonarr:latest

# 7d) Just build on pull requests
- name: Build (PR)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v3
with:
context: .
push: false
push: false
platforms: linux/amd64,linux/arm64
50 changes: 34 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Huntarr [Sonarr Edition] - Force Sonarr to Hunt Missing Shows & Upgrade Episode Qualities

<h2 align="center">Want to Help? Click the Star in the Upper-Right Corner! ⭐</h2>

<table>
Expand All @@ -9,7 +9,7 @@
</table>


**NOTE**: This utilizes Sonarr API Version - `5`.
**NOTE**: This utilizes Sonarr API Version - `5`. Legacy name of this program: Sonarr Hunter.

## Table of Contents
- [Overview](#overview)
Expand All @@ -32,8 +32,9 @@ This script continually searches your Sonarr library for shows with missing epis

## Related Projects

* [Huntarr - Radarr Edition](https://github.com/plexguide/Radarr-Hunter) - Sister version for movies
* [Huntarr - Lidarr Edition](https://github.com/plexguide/Lidarr-Hunter) - Sister version for music
* [Huntarr - Radarr Edition](https://github.com/plexguide/Radarr-Hunter) - Sister version for Movies
* [Huntarr - Lidarr Edition](https://github.com/plexguide/Lidarr-Hunter) - Sister version for Music
* [Huntarr - Readarr Edition](https://github.com/plexguide/Huntarr-Readarr) - Sister version for Books
* [Unraid Intel ARC Deployment](https://github.com/plexguide/Unraid_Intel-ARC_Deployment) - Convert videos to AV1 Format (I've saved 325TB encoding to AV1)
* Visit [PlexGuide](https://plexguide.com) for more great scripts

Expand Down Expand Up @@ -97,18 +98,21 @@ My 12-year-old daughter is passionate about singing, dancing, and exploring STEM

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 | 0 |
| `SLEEP_DURATION` | Seconds to wait after completing a cycle (900 = 15 minutes) | 900 |
| `RANDOM_SELECTION` | Use random selection (`true`) or sequential (`false`) | 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 |
| 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 | 0 |
| `SLEEP_DURATION` | Seconds to wait after completing a cycle (900 = 15 minutes) | 900 |
| `RANDOM_SELECTION` | Use random selection (`true`) or sequential (`false`) | 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 |
| `COMMAND_WAIT_DELAY` | Delay in seconds between checking for command status | 1 |
| `COMMAND_WAIT_ATTEMPTS` | Number of attempts to check for command completeion before giving up | 600 |
| `MINIMUM_DOWNLOAD_QUEUE_SIZE` | Minimum number of items in the download queue before starting a hunt | -1 |

### Detailed Configuration Explanation

Expand Down Expand Up @@ -145,6 +149,20 @@ The following environment variables can be configured:
- 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.

- **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.

---

## Installation Methods
Expand Down
47 changes: 44 additions & 3 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
"""

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
from config import API_KEY, API_URL, API_TIMEOUT, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS

# Create a session for reuse
session = requests.Session()
Expand Down Expand Up @@ -37,6 +38,31 @@ def sonarr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Opt
except requests.exceptions.RequestException as e:
logger.error(f"API request error: {e}")
return None

def wait_for_command(command_id: int):
logger.debug(f"Waiting for command {command_id} to complete...")
attempts = 0
while True:
try:
time.sleep(COMMAND_WAIT_DELAY)
response = sonarr_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}")
return False

attempts += 1

if response['status'].lower() in ['complete', 'completed'] or attempts >= COMMAND_WAIT_ATTEMPTS:
break

if response['status'].lower() not in ['complete', 'completed']:
logger.warning(f"Command {command_id} did not complete within the allowed attempts.")
return False

time.sleep(0.5)

return response['status'].lower() in ['complete', 'completed']

def get_series() -> List[Dict]:
"""Get all series from Sonarr."""
Expand All @@ -57,7 +83,8 @@ def refresh_series(series_id: int) -> Optional[Dict]:
"name": "RefreshSeries",
"seriesId": series_id
}
return sonarr_request("command", method="POST", data=data)
response = sonarr_request("command", method="POST", data=data)
return wait_for_command(response['id'])

def episode_search_episodes(episode_ids: List[int]) -> Optional[Dict]:
"""
Expand All @@ -71,7 +98,21 @@ def episode_search_episodes(episode_ids: List[int]) -> Optional[Dict]:
"name": "EpisodeSearch",
"episodeIds": episode_ids
}
return sonarr_request("command", method="POST", data=data)
response = sonarr_request("command", method="POST", data=data)
return wait_for_command(response['id'])

def get_download_queue_size() -> Optional[int]:
"""
GET /api/v3/queue
Returns total number of items in the queue with the status 'downloading'.
"""
response = sonarr_request("queue?status=downloading")
total_records = response.get("totalRecords", 0)
if not isinstance(total_records, int):
total_records = 0
logger.debug(f"Download Queue Size: {total_records}")

return total_records

def get_cutoff_unmet(page: int = 1) -> Optional[Dict]:
"""
Expand Down
23 changes: 23 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@
STATE_RESET_INTERVAL_HOURS = 168
print(f"Warning: Invalid STATE_RESET_INTERVAL_HOURS value, using default: {STATE_RESET_INTERVAL_HOURS}")

# Delay in seconds between checking the status of a command (default 1 second)
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}")

# Selection Settings
RANDOM_SELECTION = os.environ.get("RANDOM_SELECTION", "true").lower() == "true"
MONITORED_ONLY = os.environ.get("MONITORED_ONLY", "true").lower() == "true"
Expand All @@ -64,6 +85,8 @@ def log_configuration(logger):
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"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.debug(f"API_KEY={API_KEY}")
28 changes: 18 additions & 10 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import time
import sys
from utils.logger import logger
from config import HUNT_MODE, SLEEP_DURATION, log_configuration
from config import HUNT_MODE, SLEEP_DURATION, MINIMUM_DOWNLOAD_QUEUE_SIZE, log_configuration
from missing import process_missing_episodes
from upgrade import process_cutoff_upgrades
from state import check_state_reset, calculate_reset_time
from api import get_download_queue_size

def main_loop() -> None:
"""Main processing loop for Huntarr-Sonarr"""
Expand All @@ -22,16 +23,23 @@ def main_loop() -> None:

# Track if any processing was done in this cycle
processing_done = False

# 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
if HUNT_MODE in ["missing", "both"]:
if process_missing_episodes():
processing_done = True

if HUNT_MODE in ["upgrade", "both"]:
if process_cutoff_upgrades():
processing_done = True

# Process shows/episodes based on HUNT_MODE
if HUNT_MODE in ["missing", "both"]:
if process_missing_episodes():
processing_done = True

if HUNT_MODE in ["upgrade", "both"]:
if process_cutoff_upgrades():
processing_done = True

else:
logger.info(f"Download queue size ({download_queue_size}) is above the minimum threshold ({MINIMUM_DOWNLOAD_QUEUE_SIZE}). Skipped processing.")

# Calculate time until the next reset
calculate_reset_time()

Expand Down
10 changes: 4 additions & 6 deletions missing.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,18 @@ def process_missing_episodes() -> bool:
# Refresh the series
logger.info(f" - Refreshing series (ID: {series_id})...")
refresh_res = refresh_series(series_id)
if not refresh_res or "id" not in refresh_res:
if not refresh_res:
logger.warning(f"WARNING: Refresh command failed for {show_title}. Skipping.")
time.sleep(5)
continue

logger.info(f"Refresh command accepted (ID: {refresh_res['id']}). Waiting 5s...")
time.sleep(5)
logger.info(f"Refresh command completed successfully.")

# Search specifically for these missing + monitored episodes
episode_ids = [ep["id"] for ep in monitored_missing_episodes]
logger.info(f" - Searching for {len(episode_ids)} missing episodes in '{show_title}'...")
search_res = episode_search_episodes(episode_ids)
if search_res and "id" in search_res:
logger.info(f"Search command accepted (ID: {search_res['id']}).")
if search_res:
logger.info(f"Search command completed successfully.")
processing_done = True
else:
logger.warning(f"WARNING: EpisodeSearch failed for show '{show_title}' (ID: {series_id}).")
Expand Down
Loading