diff --git a/frontend/static/js/apps/lidarr.js b/frontend/static/js/apps/lidarr.js
index eb5ee837..436ed5e2 100644
--- a/frontend/static/js/apps/lidarr.js
+++ b/frontend/static/js/apps/lidarr.js
@@ -7,47 +7,71 @@
}
const lidarrModule = {
- elements: {},
+ elements: {
+ apiUrlInput: document.getElementById('lidarr_api_url'),
+ apiKeyInput: document.getElementById('lidarr_api_key'),
+ connectionTestButton: document.getElementById('test-lidarr-connection'),
+ huntMissingModeSelect: document.getElementById('hunt_missing_mode'),
+ huntMissingItemsInput: document.getElementById('hunt_missing_items'),
+ huntUpgradeItemsInput: document.getElementById('hunt_upgrade_items'),
+ sleepDurationInput: document.getElementById('lidarr_sleep_duration'),
+ sleepDurationHoursSpan: document.getElementById('lidarr_sleep_duration_hours'),
+ stateResetIntervalInput: document.getElementById('lidarr_state_reset_interval_hours'),
+ monitoredOnlyInput: document.getElementById('lidarr_monitored_only'),
+ skipFutureReleasesInput: document.getElementById('lidarr_skip_future_releases'),
+ skipArtistRefreshInput: document.getElementById('skip_artist_refresh'),
+ randomMissingInput: document.getElementById('lidarr_random_missing'),
+ randomUpgradesInput: document.getElementById('lidarr_random_upgrades'),
+ debugModeInput: document.getElementById('lidarr_debug_mode'),
+ apiTimeoutInput: document.getElementById('lidarr_api_timeout'),
+ commandWaitDelayInput: document.getElementById('lidarr_command_wait_delay'),
+ commandWaitAttemptsInput: document.getElementById('lidarr_command_wait_attempts'),
+ minimumDownloadQueueSizeInput: document.getElementById('lidarr_minimum_download_queue_size')
+ },
init: function() {
console.log('[Lidarr Module] Initializing...');
- this.cacheElements();
- this.setupEventListeners();
- // Settings are now loaded centrally by huntarrUI.loadAllSettings
- // this.loadSettings(); // REMOVED
- },
-
- cacheElements: function() {
// Cache elements specific to the Lidarr settings form
- this.elements.apiUrlInput = document.getElementById('lidarr_api_url');
- this.elements.apiKeyInput = document.getElementById('lidarr_api_key');
- this.elements.huntMissingAlbumsInput = document.getElementById('hunt_missing_albums');
- this.elements.huntUpgradeTracksInput = document.getElementById('hunt_upgrade_tracks');
- this.elements.sleepDurationInput = document.getElementById('lidarr_sleep_duration');
- this.elements.sleepDurationHoursSpan = document.getElementById('lidarr_sleep_duration_hours');
- this.elements.stateResetIntervalInput = document.getElementById('lidarr_state_reset_interval_hours');
- this.elements.monitoredOnlyInput = document.getElementById('lidarr_monitored_only');
- this.elements.skipFutureReleasesInput = document.getElementById('lidarr_skip_future_releases');
- this.elements.skipArtistRefreshInput = document.getElementById('skip_artist_refresh');
- this.elements.randomMissingInput = document.getElementById('lidarr_random_missing');
- this.elements.randomUpgradesInput = document.getElementById('lidarr_random_upgrades');
- this.elements.debugModeInput = document.getElementById('lidarr_debug_mode');
- this.elements.apiTimeoutInput = document.getElementById('lidarr_api_timeout');
- this.elements.commandWaitDelayInput = document.getElementById('lidarr_command_wait_delay');
- this.elements.commandWaitAttemptsInput = document.getElementById('lidarr_command_wait_attempts');
- this.elements.minimumDownloadQueueSizeInput = document.getElementById('lidarr_minimum_download_queue_size');
- // Add any other Lidarr-specific elements
+ this.elements = {
+ apiUrlInput: document.getElementById('lidarr_api_url'),
+ apiKeyInput: document.getElementById('lidarr_api_key'),
+ connectionTestButton: document.getElementById('test-lidarr-connection'),
+ huntMissingModeSelect: document.getElementById('hunt_missing_mode'),
+ huntMissingItemsInput: document.getElementById('hunt_missing_items'),
+ huntUpgradeItemsInput: document.getElementById('hunt_upgrade_items'),
+ // ...other element references
+ };
+
+ // Add event listeners
+ this.addEventListeners();
},
- setupEventListeners: function() {
- // Keep listeners ONLY for elements with specific UI updates beyond simple value changes
- if (this.elements.sleepDurationInput) {
- this.elements.sleepDurationInput.addEventListener('input', () => {
- this.updateSleepDurationDisplay();
- // No need to call checkForChanges here, handled by delegation
- });
+ addEventListeners() {
+ // Add connection test button click handler
+ if (this.elements.connectionTestButton) {
+ this.elements.connectionTestButton.addEventListener('click', this.testConnection.bind(this));
+ }
+
+ // Add event listener to update help text when missing mode changes
+ if (this.elements.huntMissingModeSelect) {
+ this.elements.huntMissingModeSelect.addEventListener('change', this.updateHuntMissingModeHelp.bind(this));
+ // Initial update
+ this.updateHuntMissingModeHelp();
+ }
+ },
+
+ // Update help text based on selected missing mode
+ updateHuntMissingModeHelp() {
+ const mode = this.elements.huntMissingModeSelect.value;
+ const helpText = document.querySelector('#hunt_missing_items + .setting-help');
+
+ if (helpText) {
+ if (mode === 'artist') {
+ helpText.textContent = "Number of artists with missing albums to search per cycle (0 to disable)";
+ } else if (mode === 'album') {
+ helpText.textContent = "Number of specific albums to search per cycle (0 to disable)";
+ }
}
- // Remove other input listeners previously used for checkForChanges
},
updateSleepDurationDisplay: function() {
@@ -61,26 +85,10 @@
console.warn("app.updateDurationDisplay not found, sleep duration text might not update.");
}
}
- },
-
- // REMOVED loadSettings - Handled by huntarrUI.loadAllSettings
- // loadSettings: function() { ... },
-
- // REMOVED checkForChanges - Handled by huntarrUI.handleSettingChange and updateSaveResetButtonState
- // checkForChanges: function() { ... },
-
- // REMOVED updateSaveButtonState - Handled by huntarrUI.updateSaveResetButtonState
- // updateSaveButtonState: function(hasChanges) { ... },
-
- // REMOVED getSettingsPayload - Handled by huntarrUI.collectSettingsFromForm
- // getSettingsPayload: function() { ... },
-
- // REMOVED saveSettings override - Handled by huntarrUI.saveSettings
- // const originalSaveSettings = app.saveSettings;
- // app.saveSettings = function() { ... };
+ }
};
- // Initialize Lidarr module
+ // Initialize Lidarr module when DOM content is loaded and if lidarrSettings exists
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('lidarrSettings')) {
lidarrModule.init();
diff --git a/frontend/static/js/settings_forms.js b/frontend/static/js/settings_forms.js
index 5f5484e0..bb1e3d93 100644
--- a/frontend/static/js/settings_forms.js
+++ b/frontend/static/js/settings_forms.js
@@ -281,14 +281,23 @@ const SettingsForms = {
Search Settings
-
-
-
Number of missing albums to search per cycle (0 to disable)
+
+
+
Whether to search by artist (all missing albums for artist) or individual albums
-
-
-
Number of tracks to search for quality upgrades per cycle (0 to disable)
+
+
+
Number of artists with missing albums to search per cycle (0 to disable)
+
+
+
+
+
+
Number of albums to search for quality upgrades per cycle (0 to disable)
diff --git a/src/primary/apps/blueprints.py b/src/primary/apps/blueprints.py
new file mode 100644
index 00000000..6e48a33d
--- /dev/null
+++ b/src/primary/apps/blueprints.py
@@ -0,0 +1,18 @@
+"""
+Centralized blueprint imports
+This module provides a single location to import all app blueprints
+to avoid circular import issues
+"""
+
+# Import blueprints from the renamed route files
+from src.primary.apps.sonarr_routes import sonarr_bp
+from src.primary.apps.radarr_routes import radarr_bp
+from src.primary.apps.lidarr_routes import lidarr_bp
+from src.primary.apps.readarr_routes import readarr_bp
+
+__all__ = [
+ "sonarr_bp",
+ "radarr_bp",
+ "lidarr_bp",
+ "readarr_bp"
+]
\ No newline at end of file
diff --git a/src/primary/apps/lidarr.py b/src/primary/apps/lidarr.py
index 8cf38ed8..9b35a0ce 100644
--- a/src/primary/apps/lidarr.py
+++ b/src/primary/apps/lidarr.py
@@ -1,37 +1,176 @@
-from flask import Blueprint, request, jsonify
-import datetime, os, requests
-from primary import keys_manager
+#!/usr/bin/env python3
+"""
+Lidarr Blueprint for Huntarr
+Defines Flask routes for interacting with Lidarr
+"""
+import json
+import traceback
+import requests
+from flask import Blueprint, jsonify, request
+from src.primary.utils.logger import get_logger
+from src.primary.apps.lidarr import api as lidarr_api
+from src.primary.state import reset_state_file
+import src.primary.config as config
+
+# Create a logger for this module
+lidarr_logger = get_logger("lidarr")
+
+# Create Blueprint for Lidarr routes
lidarr_bp = Blueprint('lidarr', __name__)
-LOG_FILE = "/tmp/huntarr-logs/huntarr.log"
+@lidarr_bp.route('/status', methods=['GET'])
+def status():
+ """Get Lidarr connection status and version."""
+ try:
+ # Get API settings from config
+ settings = config.get_app_settings("lidarr")
+
+ if not settings or not settings.get("api_url") or not settings.get("api_key"):
+ return jsonify({"connected": False, "message": "Lidarr is not configured"}), 200
+
+ api_url = settings["api_url"]
+ api_key = settings["api_key"]
+ api_timeout = settings.get("api_timeout", 30)
+
+ # Check connection and get system status
+ system_status = lidarr_api.get_system_status(api_url, api_key, api_timeout)
+
+ if system_status is not None:
+ version = system_status.get("version", "Unknown")
+ return jsonify({
+ "connected": True,
+ "version": version,
+ "message": f"Connected to Lidarr {version}"
+ }), 200
+ else:
+ return jsonify({
+ "connected": False,
+ "message": "Failed to connect to Lidarr"
+ }), 200
+
+ except Exception as e:
+ error_message = f"Error checking Lidarr status: {str(e)}"
+ lidarr_logger.error(error_message)
+ lidarr_logger.error(traceback.format_exc())
+ return jsonify({"connected": False, "message": error_message}), 500
@lidarr_bp.route('/test-connection', methods=['POST'])
def test_connection():
- """Test connection to a Lidarr API instance"""
- data = request.json
- api_url = data.get('api_url')
- api_key = data.get('api_key')
- if not api_url or not api_key:
- return jsonify({"success": False, "message": "Missing API URL or API key"}), 400
-
- # For Lidarr, use api/v1
- api_base = "api/v1"
- url = f"{api_url}/{api_base}/system/status"
- headers = {
- "X-Api-Key": api_key,
- "Content-Type": "application/json"
- }
+ """Test connection to Lidarr with provided API settings."""
try:
- response = requests.get(url, headers=headers, timeout=10)
- response.raise_for_status()
- keys_manager.save_api_keys("lidarr", api_url, api_key)
+ # Extract API settings from request
+ data = request.json
+ api_url = data.get("api_url", "").rstrip('/')
+ api_key = data.get("api_key", "")
+ api_timeout = int(data.get("api_timeout", 30))
+
+ if not api_url or not api_key:
+ return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
+
+ # Test connection to Lidarr
+ system_status = lidarr_api.get_system_status(api_url, api_key, api_timeout)
+
+ if system_status is not None:
+ version = system_status.get("version", "Unknown")
+ return jsonify({
+ "success": True,
+ "version": version,
+ "message": f"Successfully connected to Lidarr {version}"
+ }), 200
+ else:
+ return jsonify({
+ "success": False,
+ "message": "Failed to connect to Lidarr. Check URL and API Key."
+ }), 400
+
+ except requests.exceptions.RequestException as e:
+ error_message = f"Connection error: {str(e)}"
+ if hasattr(e, 'response'):
+ if e.response is not None:
+ error_message += f" - Status Code: {e.response.status_code}, Response: {e.response.text[:200]}"
+ lidarr_logger.error(f"Lidarr connection error: {error_message}")
+ return jsonify({"success": False, "message": error_message}), 500
+ except Exception as e: # Catch any other unexpected errors
+ lidarr_logger.error(f"An unexpected error occurred during Lidarr connection test: {str(e)}", exc_info=True)
+ return jsonify({"success": False, "message": f"An unexpected error occurred: {str(e)}"}), 500
- # Ensure the response is valid JSON
- try:
- response_data = response.json()
- except ValueError:
- return jsonify({"success": False, "message": "Invalid JSON response from Lidarr API"}), 500
+@lidarr_bp.route('/stats', methods=['GET'])
+def get_stats():
+ """Get statistics about Lidarr library."""
+ try:
+ # Get API settings from config
+ settings = config.get_app_settings("lidarr")
+
+ if not settings or not settings.get("api_url") or not settings.get("api_key"):
+ return jsonify({"error": "Lidarr is not configured"}), 400
+
+ api_url = settings["api_url"]
+ api_key = settings["api_key"]
+ api_timeout = settings.get("api_timeout", 30)
+ monitored_only = settings.get("monitored_only", True)
+
+ # Get all artists from Lidarr
+ all_artists = lidarr_api.get_artists(api_url, api_key, api_timeout)
+ if all_artists is None:
+ return jsonify({"error": "Failed to get artists from Lidarr"}), 500
+
+ # Count total artists and monitored artists
+ total_artists = len(all_artists)
+ monitored_artists = sum(1 for artist in all_artists if artist.get("monitored", False))
+
+ # Get missing albums
+ missing_albums = lidarr_api.get_missing_albums(api_url, api_key, api_timeout, monitored_only)
+ total_missing = len(missing_albums) if missing_albums is not None else 0
+
+ # Get cutoff unmet albums
+ cutoff_unmet = lidarr_api.get_cutoff_unmet_albums(api_url, api_key, api_timeout, monitored_only)
+ total_upgradable = len(cutoff_unmet) if cutoff_unmet is not None else 0
+
+ # Get download queue
+ queue_size = lidarr_api.get_download_queue_size(api_url, api_key, api_timeout)
+
+ # Return stats
+ return jsonify({
+ "total_artists": total_artists,
+ "monitored_artists": monitored_artists,
+ "missing_albums": total_missing,
+ "upgradable_albums": total_upgradable,
+ "queue_size": queue_size
+ }), 200
+
+ except Exception as e:
+ error_message = f"Error getting Lidarr stats: {str(e)}"
+ lidarr_logger.error(error_message)
+ lidarr_logger.error(traceback.format_exc())
+ return jsonify({"error": error_message}), 500
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- with open(LOG_FILE, 'a') as f:
\ No newline at end of file
+@lidarr_bp.route('/reset-state', methods=['POST'])
+def reset_state():
+ """Reset the Lidarr state files to clear processed IDs."""
+ try:
+ # JSON object with flags for which states to reset
+ data = request.json or {}
+ reset_missing = data.get('reset_missing', True)
+ reset_upgrades = data.get('reset_upgrades', True)
+
+ # Reset missing state if requested
+ if reset_missing:
+ reset_state_file("lidarr", "processed_missing")
+ lidarr_logger.info("Reset Lidarr missing albums state")
+
+ # Reset upgrades state if requested
+ if reset_upgrades:
+ reset_state_file("lidarr", "processed_upgrades")
+ lidarr_logger.info("Reset Lidarr upgrades state")
+
+ return jsonify({
+ "success": True,
+ "message": "Lidarr state reset successfully"
+ }), 200
+
+ except Exception as e:
+ error_message = f"Error resetting Lidarr state: {str(e)}"
+ lidarr_logger.error(error_message)
+ lidarr_logger.error(traceback.format_exc())
+ return jsonify({"error": error_message}), 500
\ No newline at end of file
diff --git a/src/primary/apps/lidarr/__init__.py b/src/primary/apps/lidarr/__init__.py
index 3087905a..6a53d3df 100644
--- a/src/primary/apps/lidarr/__init__.py
+++ b/src/primary/apps/lidarr/__init__.py
@@ -4,5 +4,7 @@ Contains functionality for missing albums and quality upgrades in Lidarr
"""
# Module exports
-from src.primary.apps.lidarr.missing import process_missing_albums
-from src.primary.apps.lidarr.upgrade import process_cutoff_upgrades
\ No newline at end of file
+from src.primary.apps.lidarr.missing import process_missing_content
+from src.primary.apps.lidarr.upgrade import process_cutoff_upgrades
+
+__all__ = ["process_missing_content", "process_cutoff_upgrades"]
\ No newline at end of file
diff --git a/src/primary/apps/lidarr/api.py b/src/primary/apps/lidarr/api.py
index e84234c7..54b8b2ef 100644
--- a/src/primary/apps/lidarr/api.py
+++ b/src/primary/apps/lidarr/api.py
@@ -1,171 +1,397 @@
#!/usr/bin/env python3
"""
Lidarr-specific API functions
-Handles all communication with the Lidarr API
+Handles all communication with the Lidarr API (v1)
"""
import requests
import json
+import sys
import time
import datetime
+import traceback
+import logging
from typing import List, Dict, Any, Optional, Union
from src.primary.utils.logger import get_logger
-# Get app-specific logger
-logger = get_logger("lidarr")
+# Get logger for the Lidarr app
+lidarr_logger = get_logger("lidarr")
# Use a session for better performance
session = requests.Session()
-# Default API timeout in seconds
-API_TIMEOUT = 30
-
-def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: str = "lidarr") -> Any:
+def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None) -> Any:
"""
- Make a request to the Lidarr API.
+ Make a request to the Lidarr API (v1).
Args:
- endpoint: The API endpoint to call
+ api_url: The base URL of the Lidarr API
+ api_key: The API key for authentication
+ api_timeout: Timeout for the API request
+ endpoint: The API endpoint to call (e.g., 'artist', 'command')
method: HTTP method (GET, POST, PUT, DELETE)
- data: Optional data to send with the request
- app_type: The app type (always lidarr for this module)
-
+ data: Optional data payload for POST/PUT requests (sent as JSON body)
+ params: Optional dictionary of query parameters for GET requests
+
Returns:
- The JSON response from the API, or None if the request failed
+ The parsed JSON response or True for success with no content, or None if the request failed
"""
- from src.primary import settings_manager
- api_url = settings_manager.get_setting(app_type, "api_url")
- api_key = settings_manager.get_setting(app_type, "api_key")
-
- if not api_url or not api_key:
- logger.error("API URL or API key is missing. Check your settings.")
- return None
-
- # Determine the API version
- api_base = "api/v1" # Lidarr uses v1
-
- # Full URL
- url = f"{api_url}/{api_base}/{endpoint}"
-
- # Headers
- headers = {
- "X-Api-Key": api_key,
- "Content-Type": "application/json"
- }
-
try:
- if method == "GET":
- response = session.get(url, headers=headers, timeout=API_TIMEOUT)
- elif method == "POST":
- response = session.post(url, headers=headers, json=data, timeout=API_TIMEOUT)
- elif method == "PUT":
- response = session.put(url, headers=headers, json=data, timeout=API_TIMEOUT)
- elif method == "DELETE":
- response = session.delete(url, headers=headers, timeout=API_TIMEOUT)
- else:
- logger.error(f"Unsupported HTTP method: {method}")
+ if not api_url or not api_key:
+ lidarr_logger.error("No URL or API key provided for Lidarr request")
return None
- # Check for errors
- response.raise_for_status()
+ # Construct the full URL using API v1
+ full_url = f"{api_url.rstrip('/')}/api/v1/{endpoint.lstrip('/')}"
- # Parse JSON response
- if response.text:
- return response.json()
- return {}
+ # Set up headers
+ headers = {
+ "X-Api-Key": api_key,
+ "Content-Type": "application/json"
+ }
- except requests.exceptions.RequestException as e:
- logger.error(f"API request failed: {e}")
+ lidarr_logger.debug(f"Lidarr API Request: {method} {full_url} Params: {params} Data: {data}")
+
+ try:
+ response = session.request(
+ method=method.upper(),
+ url=full_url,
+ headers=headers,
+ json=data if method.upper() in ["POST", "PUT"] else None,
+ params=params if method.upper() == "GET" else None,
+ timeout=api_timeout
+ )
+
+ lidarr_logger.debug(f"Lidarr API Response Status: {response.status_code}")
+ # Log response body only in debug mode and if small enough
+ if lidarr_logger.level == logging.DEBUG and len(response.content) < 1000:
+ lidarr_logger.debug(f"Lidarr API Response Body: {response.text}")
+ elif lidarr_logger.level == logging.DEBUG:
+ lidarr_logger.debug(f"Lidarr API Response Body (truncated): {response.text[:500]}...")
+
+ # Check for successful response
+ response.raise_for_status()
+
+ # Parse response if there is content
+ if response.content and response.headers.get('Content-Type', '').startswith('application/json'):
+ return response.json()
+ elif response.status_code in [200, 201, 202]: # Success codes that might not return JSON
+ return True
+ else: # Should have been caught by raise_for_status, but as a fallback
+ lidarr_logger.warning(f"Request successful (status {response.status_code}) but no JSON content returned from {endpoint}")
+ return True # Indicate success even without content
+
+ except requests.exceptions.RequestException as e:
+ error_msg = f"Error during {method} request to Lidarr endpoint '{endpoint}': {str(e)}"
+ if e.response is not None:
+ error_msg += f" | Status: {e.response.status_code} | Response: {e.response.text[:500]}"
+ lidarr_logger.error(error_msg)
+ return None
+ except json.JSONDecodeError:
+ lidarr_logger.error(f"Error decoding JSON response from Lidarr endpoint '{endpoint}'. Response: {response.text[:500]}")
+ return None
+
+ except Exception as e:
+ # Catch all exceptions and log them with traceback
+ error_msg = f"CRITICAL ERROR in Lidarr arr_request: {str(e)}"
+ lidarr_logger.error(error_msg)
+ lidarr_logger.error(f"Full traceback: {traceback.format_exc()}")
+ print(error_msg, file=sys.stderr)
+ print(traceback.format_exc(), file=sys.stderr)
return None
-def get_download_queue_size() -> int:
- """
- Get the current size of the download queue.
-
- Returns:
- The number of items in the download queue, or 0 if the request failed
- """
- response = arr_request("queue")
- if response and "totalRecords" in response:
- return response["totalRecords"]
- return 0
+# --- Specific API Functions ---
-def get_albums_with_missing_tracks() -> List[Dict]:
- """
- Get a list of albums with missing tracks (not downloaded/available).
-
- Returns:
- A list of album objects with missing tracks
- """
- # Get all albums with detailed information
- albums = arr_request("album")
- if not albums:
- return []
-
- # Filter for albums with missing tracks
- missing_albums = []
- for album in albums:
- # Check if album has missing tracks and is monitored
- if album.get("monitored", False) and album.get("statistics", {}).get("trackCount", 0) > album.get("statistics", {}).get("trackFileCount", 0):
- missing_albums.append(album)
-
- return missing_albums
+def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Optional[Dict]:
+ """Get Lidarr system status."""
+ return arr_request(api_url, api_key, api_timeout, "system/status")
-def get_cutoff_unmet_albums() -> List[Dict]:
- """
- Get a list of albums that don't meet their quality profile cutoff.
-
- Returns:
- A list of album objects that need quality upgrades
- """
- # The cutoffUnmet endpoint in Lidarr
- params = "cutoffUnmet=true"
- albums = arr_request(f"wanted/cutoff?{params}")
- if not albums or "records" not in albums:
- return []
-
- return albums.get("records", [])
-
-def refresh_artist(artist_id: int) -> bool:
- """
- Refresh an artist in Lidarr.
-
- Args:
- artist_id: The ID of the artist to refresh
-
- Returns:
- True if the refresh was successful, False otherwise
- """
- endpoint = f"command"
- data = {
- "name": "RefreshArtist",
- "artistId": artist_id
- }
-
- response = arr_request(endpoint, method="POST", data=data)
- if response:
- logger.debug(f"Refreshed artist ID {artist_id}")
+def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool:
+ """Check the connection to Lidarr API."""
+ status = get_system_status(api_url, api_key, api_timeout)
+ if status is not None: # API request succeeded (even if status dict is empty)
+ lidarr_logger.info("Successfully connected to Lidarr.")
return True
- return False
+ else:
+ lidarr_logger.error("Failed to connect to Lidarr (system/status check failed).")
+ return False
-def album_search(album_ids: List[int]) -> bool:
- """
- Trigger a search for one or more albums.
-
- Args:
- album_ids: A list of album IDs to search for
+def get_artists(api_url: str, api_key: str, api_timeout: int, artist_id: Optional[int] = None) -> Union[List, Dict, None]:
+ """Get artist information from Lidarr."""
+ endpoint = f"artist/{artist_id}" if artist_id else "artist"
+ return arr_request(api_url, api_key, api_timeout, endpoint)
+
+def get_albums(api_url: str, api_key: str, api_timeout: int, album_id: Optional[int] = None, artist_id: Optional[int] = None) -> Union[List, Dict, None]:
+ """Get album information from Lidarr."""
+ params = {}
+ if artist_id:
+ params['artistId'] = artist_id
- Returns:
- True if the search command was successful, False otherwise
- """
- endpoint = "command"
- data = {
+ if album_id:
+ endpoint = f"album/{album_id}"
+ else:
+ endpoint = "album"
+
+ return arr_request(api_url, api_key, api_timeout, endpoint, params=params if params else None)
+
+def get_tracks(api_url: str, api_key: str, api_timeout: int, album_id: Optional[int] = None) -> Union[List, None]:
+ """Get track information for a specific album."""
+ if not album_id:
+ lidarr_logger.warning("get_tracks requires an album_id.")
+ return None
+ params = {'albumId': album_id}
+ return arr_request(api_url, api_key, api_timeout, "track", params=params)
+
+def get_queue(api_url: str, api_key: str, api_timeout: int) -> List:
+ """Get the current queue from Lidarr (handles pagination)."""
+ # Lidarr v1 queue endpoint supports pagination, unlike Sonarr v3's simple list
+ all_records = []
+ page = 1
+ page_size = 1000 # Request large page size
+
+ while True:
+ params = {
+ "page": page,
+ "pageSize": page_size,
+ "sortKey": "timeleft", # Example sort key
+ "sortDir": "asc"
+ }
+ response = arr_request(api_url, api_key, api_timeout, "queue", params=params)
+
+ if response and isinstance(response, dict) and 'records' in response:
+ records = response.get('records', [])
+ if not records:
+ break # No more records
+ all_records.extend(records)
+
+ # Check if this was the last page
+ total_records = response.get('totalRecords', 0)
+ if len(all_records) >= total_records:
+ break
+
+ page += 1
+ else:
+ lidarr_logger.error(f"Failed to get queue page {page} or invalid response format.")
+ break # Return what we have so far
+
+ return all_records
+
+def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int:
+ """Get the current size of the Lidarr download queue."""
+ params = {"pageSize": 1} # Only need 1 record to get totalRecords
+ response = arr_request(api_url, api_key, api_timeout, "queue", params=params)
+
+ if response and isinstance(response, dict) and 'totalRecords' in response:
+ queue_size = response.get('totalRecords', 0)
+ lidarr_logger.debug(f"Lidarr download queue size: {queue_size}")
+ return queue_size
+ else:
+ lidarr_logger.error("Error getting Lidarr download queue size.")
+ return -1 # Indicate error
+
+def get_missing_albums(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
+ """Get missing albums from Lidarr, handling pagination."""
+ endpoint = "wanted/missing"
+ page = 1
+ page_size = 1000
+ all_missing_albums = []
+ total_records_reported = -1
+
+ lidarr_logger.debug(f"Starting fetch for missing albums (monitored_only={monitored_only}).")
+
+ while True:
+ params = {
+ "page": page,
+ "pageSize": page_size,
+ "includeArtist": "true" # Include artist info for filtering
+ # Removed sortKey and sortDir
+ }
+
+ lidarr_logger.debug(f"Requesting missing albums page {page} with params: {params}")
+ response = arr_request(api_url, api_key, api_timeout, endpoint, params=params)
+
+ if response and isinstance(response, dict) and 'records' in response:
+ records = response.get('records', [])
+ total_records_on_page = len(records)
+
+ if page == 1:
+ total_records_reported = response.get('totalRecords', 0)
+ lidarr_logger.debug(f"Lidarr API reports {total_records_reported} total missing albums.")
+
+ lidarr_logger.debug(f"Parsed {total_records_on_page} missing album records from Lidarr API JSON (page {page}).")
+
+ if not records:
+ lidarr_logger.debug(f"No more missing records found on page {page}. Stopping pagination.")
+ break
+
+ all_missing_albums.extend(records)
+
+ if total_records_reported >= 0 and len(all_missing_albums) >= total_records_reported:
+ lidarr_logger.debug(f"Fetched {len(all_missing_albums)} records, matching or exceeding total reported ({total_records_reported}). Assuming last page.")
+ break
+
+ if total_records_on_page < page_size:
+ lidarr_logger.debug(f"Received {total_records_on_page} records (less than page size {page_size}). Assuming last page.")
+ break
+
+ page += 1
+ # time.sleep(0.1) # Optional delay
+
+ else:
+ lidarr_logger.error(f"Failed to get missing albums page {page} or invalid response format.")
+ break # Return what we have so far
+
+ lidarr_logger.info(f"Total missing albums fetched across all pages: {len(all_missing_albums)}")
+
+ # Apply monitored filter after fetching
+ if monitored_only:
+ original_count = len(all_missing_albums)
+ # Check both album and artist monitored status
+ filtered_missing = [
+ album for album in all_missing_albums
+ if album.get('monitored', False) and album.get('artist', {}).get('monitored', False)
+ ]
+ lidarr_logger.debug(f"Filtered for monitored_only=True: {len(filtered_missing)} monitored missing albums remain (out of {original_count} total).")
+ return filtered_missing
+ else:
+ lidarr_logger.debug(f"Returning {len(all_missing_albums)} missing albums (monitored_only=False).")
+ return all_missing_albums
+
+def get_cutoff_unmet_albums(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
+ """Get cutoff unmet albums from Lidarr, handling pagination."""
+ # Note: Lidarr API returns ALBUMS for cutoff unmet, not tracks.
+ endpoint = "wanted/cutoff"
+ page = 1
+ page_size = 1000 # Adjust page size if needed, Lidarr default might be smaller
+ all_cutoff_unmet = []
+ total_records_reported = -1
+
+ lidarr_logger.debug(f"Starting fetch for cutoff unmet albums (monitored_only={monitored_only}).")
+
+ while True:
+ params = {
+ "page": page,
+ "pageSize": page_size,
+ "includeArtist": "true" # Include artist info for filtering
+ # Removed sortKey and sortDir
+ }
+
+ lidarr_logger.debug(f"Requesting cutoff unmet albums page {page} with params: {params}")
+ response = arr_request(api_url, api_key, api_timeout, endpoint, params=params)
+
+ if response and isinstance(response, dict) and 'records' in response:
+ records = response.get('records', [])
+ total_records_on_page = len(records)
+
+ if page == 1:
+ total_records_reported = response.get('totalRecords', 0)
+ lidarr_logger.debug(f"Lidarr API reports {total_records_reported} total cutoff unmet albums.")
+
+ lidarr_logger.debug(f"Parsed {total_records_on_page} cutoff unmet album records from Lidarr API JSON (page {page}).")
+
+ if not records:
+ lidarr_logger.debug(f"No more cutoff unmet records found on page {page}. Stopping pagination.")
+ break
+
+ all_cutoff_unmet.extend(records)
+
+ # Check if we have fetched all reported records
+ if total_records_reported >= 0 and len(all_cutoff_unmet) >= total_records_reported:
+ lidarr_logger.debug(f"Fetched {len(all_cutoff_unmet)} records, matching or exceeding total reported ({total_records_reported}). Assuming last page.")
+ break
+
+ # Check if the number of records received is less than the page size
+ if total_records_on_page < page_size:
+ lidarr_logger.debug(f"Received {total_records_on_page} records (less than page size {page_size}). Assuming last page.")
+ break
+
+ page += 1
+ # time.sleep(0.1) # Optional small delay between pages
+
+ else:
+ # Log the error based on the response received (handled in arr_request)
+ lidarr_logger.error(f"Error getting cutoff unmet albums from Lidarr (page {page}) or invalid response format. Stopping pagination.")
+ # Return what we have so far, or indicate complete failure? Let's return what we have.
+ break
+
+ lidarr_logger.info(f"Total cutoff unmet albums fetched across all pages: {len(all_cutoff_unmet)}")
+
+ # Apply monitored filter after fetching all pages
+ if monitored_only:
+ original_count = len(all_cutoff_unmet)
+ # Check both album and artist monitored status
+ filtered_cutoff_unmet = [
+ album for album in all_cutoff_unmet
+ if album.get('monitored', False) and album.get('artist', {}).get('monitored', False)
+ ]
+ lidarr_logger.debug(f"Filtered for monitored_only=True: {len(filtered_cutoff_unmet)} monitored cutoff unmet albums remain (out of {original_count} total).")
+ return filtered_cutoff_unmet
+ else:
+ lidarr_logger.debug(f"Returning {len(all_cutoff_unmet)} cutoff unmet albums (monitored_only=False).")
+ return all_cutoff_unmet
+
+def search_albums(api_url: str, api_key: str, api_timeout: int, album_ids: List[int]) -> Optional[Dict]:
+ """Trigger a search for specific albums in Lidarr."""
+ if not album_ids:
+ lidarr_logger.warning("No album IDs provided for search.")
+ return None
+
+ payload = {
"name": "AlbumSearch",
"albumIds": album_ids
}
+ response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
- response = arr_request(endpoint, method="POST", data=data)
- if response:
- logger.debug(f"Triggered search for album IDs: {album_ids}")
- return True
- return False
\ No newline at end of file
+ if response and isinstance(response, dict) and 'id' in response:
+ command_id = response.get('id')
+ lidarr_logger.info(f"Triggered Lidarr AlbumSearch for album IDs: {album_ids}. Command ID: {command_id}")
+ return response # Return the full command object including ID
+ else:
+ lidarr_logger.error(f"Failed to trigger Lidarr AlbumSearch for album IDs {album_ids}. Response: {response}")
+ return None
+
+def search_artist(api_url: str, api_key: str, api_timeout: int, artist_id: int) -> Optional[Dict]:
+ """Trigger a search for a specific artist in Lidarr."""
+ payload = {
+ "name": "ArtistSearch",
+ "artistId": artist_id
+ }
+ response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
+
+ if response and isinstance(response, dict) and 'id' in response:
+ command_id = response.get('id')
+ lidarr_logger.info(f"Triggered Lidarr ArtistSearch for artist ID: {artist_id}. Command ID: {command_id}")
+ return response # Return the full command object
+ else:
+ lidarr_logger.error(f"Failed to trigger Lidarr ArtistSearch for artist ID {artist_id}. Response: {response}")
+ return None
+
+def refresh_artist(api_url: str, api_key: str, api_timeout: int, artist_id: int) -> Optional[Dict]:
+ """Trigger a refresh for a specific artist in Lidarr."""
+ payload = {
+ "name": "RefreshArtist",
+ "artistId": artist_id
+ }
+ response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
+
+ if response and isinstance(response, dict) and 'id' in response:
+ command_id = response.get('id')
+ lidarr_logger.info(f"Triggered Lidarr RefreshArtist for artist ID: {artist_id}. Command ID: {command_id}")
+ return response # Return the full command object
+ else:
+ lidarr_logger.error(f"Failed to trigger Lidarr RefreshArtist for artist ID {artist_id}. Response: {response}")
+ return None
+
+def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int) -> Optional[Dict[str, Any]]:
+ """Get the status of a Lidarr command."""
+ response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}")
+ if response and isinstance(response, dict):
+ lidarr_logger.debug(f"Checked Lidarr command status for ID {command_id}: {response.get('status')}")
+ return response
+ else:
+ lidarr_logger.error(f"Error getting Lidarr command status for ID {command_id}. Response: {response}")
+ return None
+
+def get_artist_by_id(api_url: str, api_key: str, api_timeout: int, artist_id: int) -> Optional[Dict[str, Any]]:
+ """Get artist details by ID from Lidarr."""
+ return arr_request(api_url, api_key, api_timeout, f"artist/{artist_id}")
\ No newline at end of file
diff --git a/src/primary/apps/lidarr/missing.py b/src/primary/apps/lidarr/missing.py
index d7358ba8..6e238978 100644
--- a/src/primary/apps/lidarr/missing.py
+++ b/src/primary/apps/lidarr/missing.py
@@ -1,165 +1,330 @@
#!/usr/bin/env python3
"""
-Missing Albums Processing for Lidarr
-Handles searching for missing albums in Lidarr
+Lidarr missing content processing module for Huntarr
+Handles missing albums or artists based on configuration.
"""
-import random
import time
-import datetime
-import os
-import json
-from typing import List, Callable, Dict, Optional
-# Correct import path
-from src.primary.utils.logger import get_logger, debug_log
-from src.primary import settings_manager
-from src.primary.state import load_processed_ids, save_processed_id, truncate_processed_list, get_state_file_path
-from src.primary.apps.lidarr.api import get_albums_with_missing_tracks, refresh_artist, album_search
+import random
+import datetime # Import datetime
+from typing import List, Dict, Any, Set, Callable
+from src.primary.utils.logger import get_logger
+from src.primary.state import load_processed_ids, save_processed_ids, get_state_file_path
+from src.primary.apps.lidarr import api as lidarr_api
-# Get app-specific logger
-logger = get_logger("lidarr")
+# Get logger for the Lidarr app
+lidarr_logger = get_logger("lidarr")
-def process_missing_albums(restart_cycle_flag: Callable[[], bool] = lambda: False) -> bool:
+# State file for processed missing items
+PROCESSED_MISSING_FILE = get_state_file_path("lidarr", "processed_missing")
+
+def process_missing_content(
+ app_settings: Dict[str, Any],
+ stop_check: Callable[[], bool] # Function to check if stop is requested
+) -> bool:
"""
- Process albums that are missing from the library.
+ Process missing content (albums or artists) in Lidarr based on settings.
Args:
- restart_cycle_flag: Function that returns whether to restart the cycle
+ app_settings: Dictionary containing all settings for Lidarr.
+ stop_check: A function that returns True if the process should stop.
Returns:
- True if any processing was done, False otherwise
+ True if any items were processed, False otherwise.
"""
- # Get the current value directly at the start of processing
- HUNT_MISSING_ALBUMS = settings_manager.get_setting("huntarr", "hunt_missing_albums", 1)
- RANDOM_MISSING = settings_manager.get_setting("advanced", "random_missing", True)
- SKIP_ARTIST_REFRESH = settings_manager.get_setting("advanced", "skip_artist_refresh", False)
- # Fetch monitored_only setting using settings_manager
- MONITORED_ONLY = settings_manager.get_setting("lidarr", "monitored_only", True)
-
- # Get app-specific state file
- PROCESSED_MISSING_FILE = get_state_file_path("lidarr", "processed_missing")
+ lidarr_logger.info("Starting missing content processing cycle for Lidarr.")
+ processed_any = False
- logger.info("=== Checking for Missing Albums ===")
+ # Extract necessary settings
+ api_url = app_settings.get("api_url")
+ api_key = app_settings.get("api_key")
+ api_timeout = app_settings.get("api_timeout", 90) # Lidarr can be slower
+ monitored_only = app_settings.get("monitored_only", True)
+ skip_future_releases = app_settings.get("skip_future_releases", True)
+ skip_artist_refresh = app_settings.get("skip_artist_refresh", False)
+ random_missing = app_settings.get("random_missing", True) # Default random to True for Lidarr?
+ hunt_missing_items = app_settings.get("hunt_missing_items", 0)
+ # Get the missing hunt mode, default to 'artist' as requested
+ hunt_missing_mode = app_settings.get("hunt_missing_mode", "artist").lower()
+ # Get command wait settings
+ command_wait_delay = app_settings.get("command_wait_delay", 1)
+ command_wait_attempts = app_settings.get("command_wait_attempts", 600)
- # Skip if HUNT_MISSING_ALBUMS is set to 0
- if HUNT_MISSING_ALBUMS <= 0:
- logger.info("HUNT_MISSING_ALBUMS is set to 0, skipping missing albums")
+ if not api_url or not api_key:
+ lidarr_logger.error("API URL or Key not configured. Cannot process missing content.")
return False
- # Check for restart signal
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal before starting missing albums. Aborting...")
+ if hunt_missing_items <= 0:
+ lidarr_logger.info("'hunt_missing_items' setting is 0 or less. Skipping missing content processing.")
return False
-
- # Get missing albums
- logger.info("Retrieving albums with missing tracks...")
- missing_albums = get_albums_with_missing_tracks()
-
+
+ if hunt_missing_mode not in ['artist', 'album']:
+ lidarr_logger.error(f"Invalid 'hunt_missing_mode': {hunt_missing_mode}. Must be 'artist' or 'album'. Skipping.")
+ return False
+
+ lidarr_logger.info(f"Processing missing content in '{hunt_missing_mode}' mode.")
+
+ # Load already processed album IDs (even in artist mode, we mark albums)
+ processed_album_ids: Set[int] = set(load_processed_ids(PROCESSED_MISSING_FILE))
+ lidarr_logger.debug(f"Loaded {len(processed_album_ids)} processed missing album IDs for Lidarr.")
+
+ # Get missing albums from Lidarr API
+ missing_albums = lidarr_api.get_missing_albums(api_url, api_key, api_timeout, monitored_only)
+ if missing_albums is None: # API call failed
+ lidarr_logger.error("Failed to get missing albums from Lidarr API.")
+ return False
+
+ lidarr_logger.info(f"Received {len(missing_albums)} missing albums from Lidarr API (after monitored filter if applied).")
if not missing_albums:
- logger.info("No missing albums found.")
+ lidarr_logger.info("No missing albums found in Lidarr requiring processing.")
return False
-
- # Check for restart signal after retrieving albums
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal after retrieving missing albums. Aborting...")
- return False
-
- logger.info(f"Found {len(missing_albums)} albums with missing tracks.")
- processed_missing_ids = load_processed_ids(PROCESSED_MISSING_FILE)
- albums_processed = 0
- processing_done = False
-
+
+ if stop_check(): lidarr_logger.info("Stop requested during missing content processing."); return processed_any
+
# Filter out already processed albums
- unprocessed_albums = [album for album in missing_albums if album.get("id") not in processed_missing_ids]
-
- if not unprocessed_albums:
- logger.info("All missing albums have already been processed. Skipping.")
+ albums_to_consider = [album for album in missing_albums if album['id'] not in processed_album_ids]
+ lidarr_logger.info(f"Found {len(albums_to_consider)} new missing albums to consider.")
+
+ # Filter out future releases if configured
+ if skip_future_releases:
+ now = datetime.datetime.now(datetime.timezone.utc) # Use timezone-aware comparison
+ original_count = len(albums_to_consider)
+
+ filtered_albums = []
+ for album in albums_to_consider:
+ release_date_str = album.get('releaseDate')
+ if release_date_str:
+ try:
+ # Handle both YYYY-MM-DD and ISO-8601 format with Z timezone
+ if 'T' in release_date_str: # ISO-8601 format like 2014-06-10T00:00:00Z
+ # Remove the Z and parse with proper format
+ date_str = release_date_str.rstrip('Z')
+ release_date = datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S').replace(tzinfo=datetime.timezone.utc)
+ else: # Simple date format YYYY-MM-DD
+ release_date = datetime.datetime.strptime(release_date_str, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc)
+
+ if release_date < now:
+ filtered_albums.append(album)
+ # else: # Debug logging for skipped future albums
+ # lidarr_logger.debug(f"Skipping future album ID {album.get('id')} ('{album.get('title')}') with release date {release_date_str}")
+ except ValueError as e:
+ lidarr_logger.warning(f"Could not parse release date '{release_date_str}' for album ID {album.get('id')}. Error: {e}. Including it anyway.")
+ filtered_albums.append(album) # Include if date is invalid
+ else:
+ filtered_albums.append(album) # Include albums without a release date
+
+ albums_to_consider = filtered_albums
+ skipped_count = original_count - len(albums_to_consider)
+ if skipped_count > 0:
+ lidarr_logger.info(f"Skipped {skipped_count} future albums based on release date.")
+
+ if not albums_to_consider:
+ lidarr_logger.info("No missing albums left to process after filtering.")
return False
-
- logger.info(f"Found {len(unprocessed_albums)} missing albums that haven't been processed yet.")
-
- # Randomize if requested
- if RANDOM_MISSING:
- logger.info("Using random selection for missing albums (RANDOM_MISSING=true)")
- random.shuffle(unprocessed_albums)
- else:
- logger.info("Using sequential selection for missing albums (RANDOM_MISSING=false)")
- # Sort by title for consistent ordering
- unprocessed_albums.sort(key=lambda x: x.get("title", ""))
-
- # Check for restart signal before processing albums
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal before processing albums. Aborting...")
- return False
-
- # Process up to HUNT_MISSING_ALBUMS albums
- for album in unprocessed_albums:
- # Check for restart signal before each album
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal during album processing. Aborting...")
- break
+
+ processed_in_this_run = set()
+
+ # --- Artist Mode ---
+ if hunt_missing_mode == 'artist':
+ lidarr_logger.info(f"Processing in 'artist' mode. Selecting up to {hunt_missing_items} artists with missing albums.")
- # Check again for the current limit in case it was changed during processing
- current_limit = settings_manager.get_setting("huntarr", "hunt_missing_albums", 1)
+ # Group albums by artist ID
+ artists_with_missing: Dict[int, List[Dict]] = {}
+ artist_names: Dict[int, str] = {}
- if albums_processed >= current_limit:
- logger.info(f"Reached HUNT_MISSING_ALBUMS={current_limit} for this cycle.")
- break
+ for album in albums_to_consider:
+ artist_id = album.get('artistId')
+ if artist_id:
+ if artist_id not in artists_with_missing:
+ artists_with_missing[artist_id] = []
+ artist_names[artist_id] = album.get('artist', {}).get('artistName', f"Artist ID {artist_id}")
+ artists_with_missing[artist_id].append(album)
- album_id = album.get("id")
- title = album.get("title", "Unknown Title")
- artist_id = album.get("artistId")
- artist_name = "Unknown Artist"
-
- # Look for artist name in the album
- if "artist" in album and isinstance(album["artist"], dict):
- artist_name = album["artist"].get("artistName", "Unknown Artist")
-
- album_type = album.get("albumType", "Unknown Type")
- release_date = album.get("releaseDate", "Unknown Release Date")
-
- logger.info(f"Processing missing album: \"{title}\" by {artist_name} ({album_type}, {release_date}) (Album ID: {album_id})")
-
- # Refresh the artist information if SKIP_ARTIST_REFRESH is false
- if not SKIP_ARTIST_REFRESH and artist_id is not None:
- logger.info(" - Refreshing artist information...")
- refresh_res = refresh_artist(artist_id)
- if not refresh_res:
- logger.warning("WARNING: Refresh command failed. Skipping this album.")
- continue
- logger.info(f"Refresh command completed successfully.")
+ if not artists_with_missing:
+ lidarr_logger.info("No artists found with new missing albums to process.")
+ return False
+
+ # Select artists to process
+ artist_ids_to_process = list(artists_with_missing.keys())
+ if random_missing:
+ lidarr_logger.debug(f"Randomly selecting artists.")
+ random.shuffle(artist_ids_to_process)
- # Small delay after refresh to allow Lidarr to process
- time.sleep(2)
- else:
- reason = "SKIP_ARTIST_REFRESH=true" if SKIP_ARTIST_REFRESH else "artist_id is None"
- logger.info(f" - Skipping artist refresh ({reason})")
-
- # Check for restart signal before searching
- if restart_cycle_flag():
- logger.info(f"🔄 Received restart signal before searching for {title}. Aborting...")
- break
-
- # Search for the album
- logger.info(" - Searching for missing album...")
- search_res = album_search([album_id])
- if search_res:
- logger.info(f"Search command completed successfully.")
- # Mark as processed
- save_processed_id(PROCESSED_MISSING_FILE, album_id)
- albums_processed += 1
- processing_done = True
+ artists_to_search = artist_ids_to_process[:hunt_missing_items]
+ lidarr_logger.info(f"Selected {len(artists_to_search)} artists to search.")
+
+ # Process selected artists
+ for artist_id in artists_to_search:
+ if stop_check(): lidarr_logger.info("Stop requested before processing next artist."); break
- # Log with the current limit, not the initial one
- current_limit = settings_manager.get_setting("huntarr", "hunt_missing_albums", 1)
- logger.info(f"Processed {albums_processed}/{current_limit} missing albums this cycle.")
+ artist_name = artist_names.get(artist_id, f"Artist ID {artist_id}")
+ missing_albums_for_artist = artists_with_missing[artist_id]
+ missing_album_ids_for_artist = {album['id'] for album in missing_albums_for_artist}
+ lidarr_logger.info(f"Processing artist: {artist_name} (ID: {artist_id}) with {len(missing_albums_for_artist)} missing albums.")
+
+ # 1. Refresh Artist (optional)
+ refresh_command = None
+ if not skip_artist_refresh:
+ lidarr_logger.debug(f"Attempting to refresh artist ID: {artist_id}")
+ refresh_command = lidarr_api.refresh_artist(api_url, api_key, api_timeout, artist_id)
+ if refresh_command and refresh_command.get('id'):
+ if not wait_for_command(
+ api_url, api_key, api_timeout, refresh_command['id'],
+ command_wait_delay, command_wait_attempts, f"RefreshArtist {artist_id}", stop_check
+ ):
+ lidarr_logger.warning(f"RefreshArtist command (ID: {refresh_command['id']}) for artist {artist_id} did not complete successfully or timed out. Proceeding anyway.")
+ else:
+ lidarr_logger.warning(f"Failed to trigger RefreshArtist command for artist ID: {artist_id}. Proceeding without refresh.")
+ else:
+ lidarr_logger.debug(f"Skipping artist refresh for artist ID: {artist_id} as configured.")
+
+ if stop_check(): lidarr_logger.info("Stop requested after artist refresh attempt."); break
+
+ # 2. Trigger Artist Search
+ lidarr_logger.debug(f"Attempting ArtistSearch for artist ID: {artist_id}")
+ search_command = lidarr_api.search_artist(api_url, api_key, api_timeout, artist_id)
+
+ if search_command and search_command.get('id'):
+ if wait_for_command(
+ api_url, api_key, api_timeout, search_command['id'],
+ command_wait_delay, command_wait_attempts, f"ArtistSearch {artist_id}", stop_check
+ ):
+ # Mark all initially identified missing albums for this artist as processed
+ processed_in_this_run.update(missing_album_ids_for_artist)
+ processed_any = True
+ lidarr_logger.info(f"Successfully processed ArtistSearch for artist {artist_id}. Marked {len(missing_album_ids_for_artist)} related albums as processed.")
+ else:
+ lidarr_logger.warning(f"ArtistSearch command (ID: {search_command['id']}) for artist {artist_id} did not complete successfully or timed out. Albums will not be marked as processed yet.")
+ else:
+ lidarr_logger.error(f"Failed to trigger ArtistSearch command for artist ID: {artist_id}.")
+
+ # --- Album Mode ---
+ elif hunt_missing_mode == 'album':
+ lidarr_logger.info(f"Processing in 'album' mode. Selecting up to {hunt_missing_items} specific albums.")
+
+ # Select albums to process
+ albums_to_process = albums_to_consider
+ if random_missing:
+ lidarr_logger.debug(f"Randomly selecting albums.")
+ random.shuffle(albums_to_process)
+
+ albums_to_search = albums_to_process[:hunt_missing_items]
+ album_ids_to_search = [album['id'] for album in albums_to_search]
+
+ if not album_ids_to_search:
+ lidarr_logger.info("No specific albums selected to search.")
+ return False
+
+ lidarr_logger.info(f"Selected {len(album_ids_to_search)} specific albums to search: {album_ids_to_search}")
+
+ # Optional: Refresh artists for selected albums? (Could be many API calls)
+ # Let's skip artist refresh in album mode for now unless skip_artist_refresh is explicitly False.
+ if not skip_artist_refresh:
+ artist_ids_to_refresh = {album['artistId'] for album in albums_to_search if album.get('artistId')}
+ lidarr_logger.info(f"Refreshing {len(artist_ids_to_refresh)} artists related to selected albums (skip_artist_refresh=False).")
+ for artist_id in artist_ids_to_refresh:
+ if stop_check(): lidarr_logger.info("Stop requested during artist refresh in album mode."); break
+ lidarr_logger.debug(f"Attempting to refresh artist ID: {artist_id}")
+ refresh_command = lidarr_api.refresh_artist(api_url, api_key, api_timeout, artist_id)
+ if refresh_command and refresh_command.get('id'):
+ # Don't wait excessively long for each refresh here, maybe shorter timeout?
+ wait_for_command(api_url, api_key, api_timeout, refresh_command['id'], command_wait_delay, 10, f"RefreshArtist {artist_id} (Album Mode)", stop_check, log_success=False) # Don't spam logs
+ else:
+ lidarr_logger.warning(f"Failed to trigger RefreshArtist command for artist ID: {artist_id} in album mode.")
+ if stop_check(): lidarr_logger.info("Stop requested after artist refresh in album mode."); return processed_any # Exit if stopped
+
+ # Trigger Album Search for the selected batch
+ lidarr_logger.debug(f"Attempting AlbumSearch for album IDs: {album_ids_to_search}")
+ search_command = lidarr_api.search_albums(api_url, api_key, api_timeout, album_ids_to_search)
+
+ if search_command and search_command.get('id'):
+ if wait_for_command(
+ api_url, api_key, api_timeout, search_command['id'],
+ command_wait_delay, command_wait_attempts, f"AlbumSearch {len(album_ids_to_search)} albums", stop_check
+ ):
+ processed_in_this_run.update(album_ids_to_search)
+ processed_any = True
+ lidarr_logger.info(f"Successfully processed AlbumSearch for {len(album_ids_to_search)} albums.")
+ else:
+ lidarr_logger.warning(f"AlbumSearch command (ID: {search_command['id']}) did not complete successfully or timed out. Albums will not be marked as processed yet.")
else:
- logger.warning(f"WARNING: Search command failed for album ID {album_id}.")
- continue
+ lidarr_logger.error(f"Failed to trigger AlbumSearch command for album IDs: {album_ids_to_search}.")
+
+ # --- Update State ---
+ if processed_in_this_run:
+ updated_processed_ids = processed_album_ids.union(processed_in_this_run)
+ save_processed_ids(PROCESSED_MISSING_FILE, list(updated_processed_ids))
+ lidarr_logger.info(f"Saved {len(processed_in_this_run)} newly processed missing album IDs for Lidarr. Total processed: {len(updated_processed_ids)}.")
+ elif processed_any:
+ lidarr_logger.info("Attempted missing content processing, but no new items were marked as successfully processed.")
+
+ lidarr_logger.info("Finished missing content processing cycle for Lidarr.")
+ return processed_any
+
+def wait_for_command(
+ api_url: str,
+ api_key: str,
+ api_timeout: int,
+ command_id: int,
+ delay: int,
+ attempts: int,
+ command_name: str,
+ stop_check: Callable[[], bool],
+ log_success: bool = True # Option to suppress success log spam
+) -> bool:
+ """Wait for a Lidarr command to complete, checking for stop requests."""
+ lidarr_logger.debug(f"Waiting for Lidarr command '{command_name}' (ID: {command_id}) to complete...")
+ start_time = time.monotonic()
- # Log final status
- current_limit = settings_manager.get_setting("huntarr", "hunt_missing_albums", 1)
- logger.info(f"Completed processing {albums_processed} missing albums for this cycle.")
- truncate_processed_list(PROCESSED_MISSING_FILE)
-
- return processing_done
\ No newline at end of file
+ for attempt in range(attempts):
+ if stop_check():
+ lidarr_logger.info(f"Stop requested while waiting for command '{command_name}' (ID: {command_id}).")
+ return False # Indicate command did not complete successfully due to stop
+
+ status_data = lidarr_api.get_command_status(api_url, api_key, api_timeout, command_id)
+
+ if status_data and isinstance(status_data, dict):
+ status = status_data.get('status')
+ state = status_data.get('state') # Lidarr v1 uses 'state' more often? Check API
+
+ # Use 'state' if available, otherwise 'status'
+ current_state = state if state else status
+
+ if current_state == 'completed':
+ if log_success:
+ lidarr_logger.info(f"Lidarr command '{command_name}' (ID: {command_id}) completed successfully.")
+ else:
+ lidarr_logger.debug(f"Lidarr command '{command_name}' (ID: {command_id}) completed successfully.")
+ return True
+ elif current_state in ['failed', 'aborted']:
+ error_message = status_data.get('errorMessage') # Lidarr might have errorMessage
+ if not error_message:
+ # Look in body or exception if available (adapt based on actual Lidarr responses)
+ body = status_data.get('body', {})
+ error_message = body.get('message', body.get('exception', f"Command {current_state}"))
+ lidarr_logger.error(f"Lidarr command '{command_name}' (ID: {command_id}) {current_state}. Error: {error_message}")
+ return False
+ else: # queued, started, running, processing etc.
+ elapsed_time = time.monotonic() - start_time
+ lidarr_logger.debug(f"Lidarr command '{command_name}' (ID: {command_id}) state: {current_state}. Waiting {delay}s... (Attempt {attempt + 1}/{attempts}, Elapsed: {elapsed_time:.1f}s)")
+ else:
+ elapsed_time = time.monotonic() - start_time
+ lidarr_logger.warning(f"Could not get status for Lidarr command '{command_name}' (ID: {command_id}). Retrying... (Attempt {attempt + 1}/{attempts}, Elapsed: {elapsed_time:.1f}s)")
+
+ # Wait for the delay, checking stop_check frequently
+ wait_start_time = time.monotonic()
+ while time.monotonic() < wait_start_time + delay:
+ if stop_check():
+ lidarr_logger.info(f"Stop requested while waiting between checks for command '{command_name}' (ID: {command_id}).")
+ return False
+ # Sleep for 1 second or remaining time, whichever is smaller
+ sleep_interval = min(1, (wait_start_time + delay) - time.monotonic())
+ if sleep_interval > 0:
+ time.sleep(sleep_interval)
+ # Break if the delay time has passed to avoid infinite loop on clock skew
+ if time.monotonic() >= wait_start_time + delay:
+ break
+
+ elapsed_time = time.monotonic() - start_time
+ lidarr_logger.error(f"Lidarr command '{command_name}' (ID: {command_id}) timed out after {attempts} attempts ({elapsed_time:.1f}s).")
+ return False
\ No newline at end of file
diff --git a/src/primary/apps/lidarr/upgrade.py b/src/primary/apps/lidarr/upgrade.py
index ff56e263..48c67956 100644
--- a/src/primary/apps/lidarr/upgrade.py
+++ b/src/primary/apps/lidarr/upgrade.py
@@ -1,172 +1,172 @@
#!/usr/bin/env python3
"""
-Quality Upgrade Processing for Lidarr
-Handles searching for tracks/albums that need quality upgrades in Lidarr
+Lidarr cutoff upgrade processing module for Huntarr
+Handles albums that do not meet the configured quality cutoff.
"""
-import random
import time
+import random
import datetime
-import os
-import json
-from typing import List, Callable, Dict, Optional
+from typing import List, Dict, Any, Set, Callable
from src.primary.utils.logger import get_logger
-from src.primary import settings_manager
-from src.primary.state import load_processed_ids, save_processed_id, truncate_processed_list, get_state_file_path
-from src.primary.apps.lidarr.api import get_cutoff_unmet_albums, refresh_artist, album_search
+from src.primary.state import load_processed_ids, save_processed_ids, get_state_file_path
+from src.primary.apps.lidarr import api as lidarr_api
+from src.primary.apps.lidarr.missing import wait_for_command # Reuse wait function
-# Get app-specific logger
-logger = get_logger("lidarr")
+# Get logger for the Lidarr app
+lidarr_logger = get_logger("lidarr")
-def process_cutoff_upgrades(restart_cycle_flag: Callable[[], bool] = lambda: False) -> bool:
- """
- Process tracks 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
- """
- # Get the current value directly at the start of processing
- # Use settings_manager directly instead of get_current_upgrade_limit
- HUNT_UPGRADE_TRACKS = settings_manager.get_setting("lidarr", "hunt_upgrade_tracks", 0)
- RANDOM_UPGRADES = settings_manager.get_setting("lidarr", "random_upgrades", True)
- SKIP_ARTIST_REFRESH = settings_manager.get_setting("lidarr", "skip_artist_refresh", False)
- MONITORED_ONLY = settings_manager.get_setting("lidarr", "monitored_only", True)
-
- # Get app-specific state file
- PROCESSED_UPGRADE_FILE = get_state_file_path("lidarr", "processed_upgrades")
+# State file for processed upgrades (stores album IDs)
+PROCESSED_UPGRADES_FILE = get_state_file_path("lidarr", "processed_upgrades")
- logger.info("=== Checking for Quality Upgrades (Cutoff Unmet) ===")
+def process_cutoff_upgrades(
+ app_settings: Dict[str, Any],
+ stop_check: Callable[[], bool]
+) -> bool:
+ """Process cutoff unmet albums in Lidarr for quality upgrades."""
+ lidarr_logger.info("Starting cutoff upgrade processing cycle for Lidarr.")
+ processed_any = False
- # Skip if HUNT_UPGRADE_TRACKS is set to 0
- if HUNT_UPGRADE_TRACKS <= 0:
- logger.info("HUNT_UPGRADE_TRACKS is set to 0, skipping quality upgrades")
+ # Extract necessary settings
+ api_url = app_settings.get("api_url")
+ api_key = app_settings.get("api_key")
+ api_timeout = app_settings.get("api_timeout", 90) # Lidarr can be slower
+ monitored_only = app_settings.get("monitored_only", True)
+ skip_future_releases = app_settings.get("skip_future_releases", True)
+ skip_artist_refresh = app_settings.get("skip_artist_refresh", False) # Reuse setting name
+ random_upgrades = app_settings.get("random_upgrades", True) # Default random to True?
+ # Use hunt_upgrade_items as the setting name
+ hunt_upgrade_items = app_settings.get("hunt_upgrade_items", 0)
+ command_wait_delay = app_settings.get("command_wait_delay", 5)
+ command_wait_attempts = app_settings.get("command_wait_attempts", 120) # Increase wait for Lidarr?
+
+ if not api_url or not api_key:
+ lidarr_logger.error("API URL or Key not configured. Cannot process upgrades.")
return False
- # Check for restart signal
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal before starting quality upgrades. Aborting...")
+ if hunt_upgrade_items <= 0:
+ lidarr_logger.info("'hunt_upgrade_items' setting is 0 or less. Skipping upgrade processing.")
return False
-
- # Get albums needing quality upgrades
- logger.info("Retrieving albums that need quality upgrades...")
- upgrade_albums = get_cutoff_unmet_albums()
-
- if not upgrade_albums:
- logger.info("No albums found that need quality upgrades.")
+
+ # Load already processed album IDs for upgrades
+ processed_upgrade_ids: Set[int] = set(load_processed_ids(PROCESSED_UPGRADES_FILE))
+ lidarr_logger.debug(f"Loaded {len(processed_upgrade_ids)} processed upgrade album IDs for Lidarr.")
+
+ # Get cutoff unmet albums from Lidarr API
+ cutoff_unmet_albums = lidarr_api.get_cutoff_unmet_albums(api_url, api_key, api_timeout, monitored_only)
+ if cutoff_unmet_albums is None: # API call failed
+ lidarr_logger.error("Failed to get cutoff unmet albums from Lidarr API.")
+ return False
+
+ lidarr_logger.info(f"Received {len(cutoff_unmet_albums)} cutoff unmet albums from Lidarr API (after monitored filter if applied).")
+ if not cutoff_unmet_albums:
+ lidarr_logger.info("No cutoff unmet albums found in Lidarr requiring processing.")
return False
-
- # Check for restart signal after retrieving albums
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal after retrieving upgrade albums. Aborting...")
+
+ if stop_check(): lidarr_logger.info("Stop requested during upgrade processing."); return processed_any
+
+ # Filter out already processed albums for upgrades
+ albums_to_consider = [album for album in cutoff_unmet_albums if album['id'] not in processed_upgrade_ids]
+ lidarr_logger.info(f"Found {len(albums_to_consider)} new cutoff unmet albums to process for upgrades.")
+
+ # Filter out future releases if configured
+ if skip_future_releases:
+ now = datetime.datetime.now(datetime.timezone.utc) # Use timezone-aware comparison
+ original_count = len(albums_to_consider)
+
+ filtered_albums = []
+ for album in albums_to_consider:
+ release_date_str = album.get('releaseDate')
+ if release_date_str:
+ try:
+ # Handle both YYYY-MM-DD and ISO-8601 format with Z timezone
+ if 'T' in release_date_str: # ISO-8601 format like 2014-06-10T00:00:00Z
+ # Remove the Z and parse with proper format
+ date_str = release_date_str.rstrip('Z')
+ release_date = datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S').replace(tzinfo=datetime.timezone.utc)
+ else: # Simple date format YYYY-MM-DD
+ release_date = datetime.datetime.strptime(release_date_str, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc)
+
+ if release_date < now:
+ filtered_albums.append(album)
+ # else: # Debug logging for skipped future albums
+ # lidarr_logger.debug(f"Skipping future album ID {album.get('id')} ('{album.get('title')}') for upgrade with release date {release_date_str}")
+ except ValueError as e:
+ lidarr_logger.warning(f"Could not parse release date '{release_date_str}' for upgrade album ID {album.get('id')}. Error: {e}. Including it anyway.")
+ filtered_albums.append(album)
+ else:
+ filtered_albums.append(album) # Include albums without a release date
+
+ albums_to_consider = filtered_albums
+ skipped_count = original_count - len(albums_to_consider)
+ if skipped_count > 0:
+ lidarr_logger.info(f"Skipped {skipped_count} future albums based on release date for upgrades.")
+
+
+ if not albums_to_consider:
+ lidarr_logger.info("No cutoff unmet albums left to process for upgrades after filtering.")
return False
-
- logger.info(f"Found {len(upgrade_albums)} albums that need quality upgrades.")
- processed_upgrade_ids = load_processed_ids(PROCESSED_UPGRADE_FILE)
- albums_processed = 0
- processing_done = False
-
- # Filter out already processed albums
- unprocessed_albums = [album for album in upgrade_albums if album.get("id") not in processed_upgrade_ids]
-
- if not unprocessed_albums:
- logger.info("All upgrade albums have already been processed. Skipping.")
- return False
-
- logger.info(f"Found {len(unprocessed_albums)} upgrade albums that haven't been processed yet.")
-
- # Randomize if requested
- if RANDOM_UPGRADES:
- logger.info("Using random selection for quality upgrades (RANDOM_UPGRADES=true)")
- random.shuffle(unprocessed_albums)
+
+ # Select albums to search based on configuration
+ if random_upgrades:
+ lidarr_logger.debug(f"Randomly selecting up to {hunt_upgrade_items} cutoff unmet albums for upgrade search.")
+ albums_to_search = random.sample(albums_to_consider, min(len(albums_to_consider), hunt_upgrade_items))
else:
- logger.info("Using sequential selection for quality upgrades (RANDOM_UPGRADES=false)")
- # Sort by title for consistent ordering
- unprocessed_albums.sort(key=lambda x: x.get("title", ""))
-
- # Check for restart signal before processing albums
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal before processing albums. Aborting...")
+ # Sort by release date? Or artist name? Let's stick to API order for now (artist name default)
+ lidarr_logger.debug(f"Selecting the first {hunt_upgrade_items} cutoff unmet albums for upgrade search.")
+ albums_to_search = albums_to_consider[:hunt_upgrade_items]
+
+ album_ids_to_search = [album['id'] for album in albums_to_search]
+
+ if not album_ids_to_search:
+ lidarr_logger.info("No albums selected for upgrade search.")
return False
-
- # Process up to HUNT_UPGRADE_TRACKS albums
- for album in unprocessed_albums:
- # Check for restart signal before each album
- if restart_cycle_flag():
- logger.info("🔄 Received restart signal during album processing. Aborting...")
- break
-
- # Check again for the current limit in case it was changed during processing
- # Use settings_manager directly instead of get_current_upgrade_limit
- current_limit = settings_manager.get_setting("lidarr", "hunt_upgrade_tracks", 0)
-
- if albums_processed >= current_limit:
- logger.info(f"Reached HUNT_UPGRADE_TRACKS={current_limit} for this cycle.")
- break
-
- album_id = album.get("id")
- title = album.get("title", "Unknown Title")
- artist_id = album.get("artistId")
- artist_name = "Unknown Artist"
-
- # Look for artist name in the album
- if "artist" in album and isinstance(album["artist"], dict):
- artist_name = album["artist"].get("artistName", "Unknown Artist")
- elif "artist" in album and isinstance(album["artist"], str):
- artist_name = album["artist"]
-
- # Get quality information
- quality_info = ""
- if "quality" in album and album["quality"]:
- quality_name = album["quality"].get("quality", {}).get("name", "Unknown")
- quality_info = f" (Current quality: {quality_name})"
-
- logger.info(f"Processing quality upgrade for: \"{title}\" by {artist_name}{quality_info} (Album ID: {album_id})")
-
- # Refresh the artist information if SKIP_ARTIST_REFRESH is false
- if not SKIP_ARTIST_REFRESH and artist_id is not None:
- logger.info(" - Refreshing artist information...")
- refresh_res = refresh_artist(artist_id)
- if not refresh_res:
- logger.warning("WARNING: Refresh command failed. Skipping this album.")
- continue
- logger.info(f"Refresh command completed successfully.")
-
- # Small delay after refresh to allow Lidarr to process
- time.sleep(2)
+
+ lidarr_logger.info(f"Selected {len(album_ids_to_search)} cutoff unmet albums to search for upgrades: {album_ids_to_search}")
+
+ processed_in_this_run = set()
+
+ # Optional: Refresh artists for selected albums before searching?
+ if not skip_artist_refresh:
+ artist_ids_to_refresh = {album['artistId'] for album in albums_to_search if album.get('artistId')}
+ lidarr_logger.info(f"Refreshing {len(artist_ids_to_refresh)} artists related to selected upgrade albums (skip_artist_refresh=False).")
+ for artist_id in artist_ids_to_refresh:
+ if stop_check(): lidarr_logger.info("Stop requested during artist refresh for upgrades."); break
+ lidarr_logger.debug(f"Attempting to refresh artist ID: {artist_id} before upgrade search.")
+ refresh_command = lidarr_api.refresh_artist(api_url, api_key, api_timeout, artist_id)
+ if refresh_command and refresh_command.get('id'):
+ # Don't wait excessively long
+ wait_for_command(api_url, api_key, api_timeout, refresh_command['id'], command_wait_delay, 10, f"RefreshArtist {artist_id} (Upgrade)", stop_check, log_success=False)
+ else:
+ lidarr_logger.warning(f"Failed to trigger RefreshArtist command for artist ID: {artist_id} before upgrade search.")
+ if stop_check(): lidarr_logger.info("Stop requested after artist refresh for upgrades."); return processed_any # Exit if stopped
+
+
+ # Trigger Album Search for the selected batch
+ lidarr_logger.debug(f"Attempting AlbumSearch for upgrade album IDs: {album_ids_to_search}")
+ search_command = lidarr_api.search_albums(api_url, api_key, api_timeout, album_ids_to_search)
+
+ if search_command and search_command.get('id'):
+ if wait_for_command(
+ api_url, api_key, api_timeout, search_command['id'],
+ command_wait_delay, command_wait_attempts, f"AlbumSearch (Upgrade) {len(album_ids_to_search)} albums", stop_check
+ ):
+ # Mark albums as processed for upgrades if search command completed successfully
+ processed_in_this_run.update(album_ids_to_search)
+ processed_any = True
+ lidarr_logger.info(f"Successfully processed upgrade search for {len(album_ids_to_search)} albums.")
else:
- reason = "SKIP_ARTIST_REFRESH=true" if SKIP_ARTIST_REFRESH else "artist_id is None"
- logger.info(f" - Skipping artist refresh ({reason})")
-
- # Check for restart signal before searching
- if restart_cycle_flag():
- logger.info(f"🔄 Received restart signal before searching for {title}. Aborting...")
- break
-
- # Search for the album
- logger.info(" - Searching for quality upgrade...")
- search_res = album_search([album_id])
- if search_res:
- logger.info(f"Search command completed successfully.")
- # Mark as processed
- save_processed_id(PROCESSED_UPGRADE_FILE, album_id)
- albums_processed += 1
- processing_done = True
-
- # Log with the current limit, not the initial one
- # Use settings_manager directly instead of get_current_upgrade_limit
- current_limit = settings_manager.get_setting("lidarr", "hunt_upgrade_tracks", 0)
- logger.info(f"Processed {albums_processed}/{current_limit} upgrade albums this cycle.")
- else:
- logger.warning(f"WARNING: Search command failed for album ID {album_id}.")
- continue
-
- # Log final status
- # Use settings_manager directly instead of get_current_upgrade_limit
- current_limit = settings_manager.get_setting("lidarr", "hunt_upgrade_tracks", 0)
- logger.info(f"Completed processing {albums_processed} upgrade albums for this cycle.")
- truncate_processed_list(PROCESSED_UPGRADE_FILE)
-
- return processing_done
\ No newline at end of file
+ lidarr_logger.warning(f"Album upgrade search command (ID: {search_command['id']}) did not complete successfully or timed out. Albums will not be marked as processed for upgrades yet.")
+ else:
+ lidarr_logger.error(f"Failed to trigger upgrade search command (AlbumSearch) for albums {album_ids_to_search}.")
+
+ # Update the set of processed upgrade album IDs and save to state file
+ if processed_in_this_run:
+ updated_processed_ids = processed_upgrade_ids.union(processed_in_this_run)
+ save_processed_ids(PROCESSED_UPGRADES_FILE, list(updated_processed_ids))
+ lidarr_logger.info(f"Saved {len(processed_in_this_run)} newly processed upgrade album IDs for Lidarr. Total processed for upgrades: {len(updated_processed_ids)}.")
+ elif processed_any:
+ lidarr_logger.info("Attempted upgrade processing, but no new albums were marked as successfully processed.")
+
+ lidarr_logger.info("Finished cutoff upgrade processing cycle for Lidarr.")
+ return processed_any
\ No newline at end of file
diff --git a/src/primary/apps/lidarr_routes.py b/src/primary/apps/lidarr_routes.py
new file mode 100644
index 00000000..5f0793eb
--- /dev/null
+++ b/src/primary/apps/lidarr_routes.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+
+from flask import Blueprint, request, jsonify
+import datetime, os, requests
+from src.primary import keys_manager
+from src.primary.state import get_state_file_path
+
+lidarr_bp = Blueprint('lidarr', __name__)
+
+LOG_FILE = "/tmp/huntarr-logs/huntarr.log"
+
+# Make sure we're using the correct state files
+PROCESSED_MISSING_FILE = get_state_file_path("lidarr", "processed_missing")
+PROCESSED_UPGRADES_FILE = get_state_file_path("lidarr", "processed_upgrades")
+
+@lidarr_bp.route('/test-connection', methods=['POST'])
+def test_connection():
+ """Test connection to a Lidarr API instance"""
+ data = request.json
+ api_url = data.get('api_url')
+ api_key = data.get('api_key')
+
+ if not api_url or not api_key:
+ return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
+
+ headers = {'X-Api-Key': api_key}
+ test_url = f"{api_url.rstrip('/')}/api/v1/system/status"
+
+ try:
+ response = requests.get(test_url, headers=headers, timeout=10)
+ response.raise_for_status()
+
+ # Save keys if connection is successful
+ keys_manager.save_api_keys("lidarr", api_url, api_key)
+
+ # Ensure the response is valid JSON
+ try:
+ response_data = response.json()
+ except ValueError:
+ return jsonify({"success": False, "message": "Invalid JSON response from Lidarr API"}), 500
+
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - lidarr - INFO - Successfully connected to Lidarr API\n")
+
+ return jsonify({"success": True, "message": "Successfully connected to Lidarr API"})
+
+ except requests.exceptions.Timeout:
+ return jsonify({"success": False, "message": "Connection timed out"}), 504
+ except requests.exceptions.RequestException as e:
+ error_message = f"Connection failed: {str(e)}"
+ if e.response is not None:
+ try:
+ error_details = e.response.json()
+ error_message += f" - {error_details.get('message', 'No details')}"
+ except ValueError:
+ error_message += f" - Status Code: {e.response.status_code}"
+ return jsonify({"success": False, "message": error_message}), 500
+ except Exception as e:
+ return jsonify({"success": False, "message": f"An unexpected error occurred: {str(e)}"}), 500
diff --git a/src/primary/apps/radarr/__init__.py b/src/primary/apps/radarr/__init__.py
index fa141782..60879c0f 100644
--- a/src/primary/apps/radarr/__init__.py
+++ b/src/primary/apps/radarr/__init__.py
@@ -5,4 +5,6 @@ Contains functionality for missing movies and quality upgrades in Radarr
# Module exports
from src.primary.apps.radarr.missing import process_missing_movies
-from src.primary.apps.radarr.upgrade import process_cutoff_upgrades
\ No newline at end of file
+from src.primary.apps.radarr.upgrade import process_cutoff_upgrades
+
+__all__ = ["process_missing_movies", "process_cutoff_upgrades"]
\ No newline at end of file
diff --git a/src/primary/apps/radarr_routes.py b/src/primary/apps/radarr_routes.py
new file mode 100644
index 00000000..97b41c04
--- /dev/null
+++ b/src/primary/apps/radarr_routes.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+from flask import Blueprint, request, jsonify
+import datetime, os, requests
+from src.primary import keys_manager
+from src.primary.state import get_state_file_path
+
+radarr_bp = Blueprint('radarr', __name__)
+
+LOG_FILE = "/tmp/huntarr-logs/huntarr.log"
+
+# Make sure we're using the correct state files
+PROCESSED_MISSING_FILE = get_state_file_path("radarr", "processed_missing")
+PROCESSED_UPGRADES_FILE = get_state_file_path("radarr", "processed_upgrades")
+
+@radarr_bp.route('/test-connection', methods=['POST'])
+def test_connection():
+ """Test connection to a Radarr API instance"""
+ data = request.json
+ api_url = data.get('api_url')
+ api_key = data.get('api_key')
+
+ if not api_url or not api_key:
+ return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
+
+ headers = {'X-Api-Key': api_key}
+ test_url = f"{api_url.rstrip('/')}/api/v3/system/status"
+
+ try:
+ response = requests.get(test_url, headers=headers, timeout=10)
+ response.raise_for_status()
+
+ # Save keys if connection is successful
+ keys_manager.save_api_keys("radarr", api_url, api_key)
+
+ # Ensure the response is valid JSON
+ try:
+ response_data = response.json()
+ except ValueError:
+ return jsonify({"success": False, "message": "Invalid JSON response from Radarr API"}), 500
+
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - radarr - INFO - Successfully connected to Radarr API\n")
+
+ return jsonify({"success": True, "message": "Successfully connected to Radarr API"})
+
+ except requests.exceptions.Timeout:
+ return jsonify({"success": False, "message": "Connection timed out"}), 504
+ except requests.exceptions.RequestException as e:
+ error_message = f"Connection failed: {str(e)}"
+ if e.response is not None:
+ try:
+ error_details = e.response.json()
+ error_message += f" - {error_details.get('message', 'No details')}"
+ except ValueError:
+ error_message += f" - Status Code: {e.response.status_code}"
+ return jsonify({"success": False, "message": error_message}), 500
+ except Exception as e:
+ return jsonify({"success": False, "message": f"An unexpected error occurred: {str(e)}"}), 500
+
+# Function to check if Radarr is configured
+def is_configured():
+ """Check if Radarr API credentials are configured"""
+ # Implementation as needed
+ return True
diff --git a/src/primary/apps/readarr_routes.py b/src/primary/apps/readarr_routes.py
new file mode 100644
index 00000000..3ebecc23
--- /dev/null
+++ b/src/primary/apps/readarr_routes.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+
+from flask import Blueprint, request, jsonify
+import datetime, os, requests
+from src.primary import keys_manager
+
+readarr_bp = Blueprint('readarr', __name__)
+
+LOG_FILE = "/tmp/huntarr-logs/huntarr.log"
+
+@readarr_bp.route('/test-connection', methods=['POST'])
+def test_connection():
+ """Test connection to a Readarr API instance"""
+ data = request.json
+ api_url = data.get('api_url')
+ api_key = data.get('api_key')
+
+ if not api_url or not api_key:
+ return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
+
+ headers = {'X-Api-Key': api_key}
+ url = f"{api_url.rstrip('/')}/api/v1/system/status"
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ try:
+ # This is similar to: curl -H "X-Api-Key: api-key" http://ip-address/api/v1/system/status
+ response = requests.get(url, headers=headers, timeout=10)
+
+ # Check status code explicitly
+ if response.status_code == 401:
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - readarr - ERROR - Connection test failed: 401 Unauthorized - Invalid API key\n")
+ return jsonify({"success": False, "message": "Unauthorized - Invalid API key"}), 401
+
+ response.raise_for_status()
+
+ # Test if response is valid JSON
+ try:
+ response_data = response.json()
+
+ # Save the API keys only if connection test is successful
+ keys_manager.save_api_keys("readarr", api_url, api_key)
+
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - readarr - INFO - Connection test successful: {api_url}\n")
+
+ return jsonify({
+ "success": True,
+ "message": "Successfully connected to Readarr API",
+ "data": response_data
+ })
+
+ except ValueError:
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - readarr - ERROR - Invalid JSON response from Readarr API\n")
+ return jsonify({"success": False, "message": "Invalid JSON response from Readarr API"}), 500
+
+ except requests.exceptions.RequestException as e:
+ error_message = str(e)
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - readarr - ERROR - Connection test failed: {api_url} - {error_message}\n")
+
+ return jsonify({"success": False, "message": f"Connection failed: {error_message}"}), 500
diff --git a/src/primary/apps/sonarr_routes.py b/src/primary/apps/sonarr_routes.py
new file mode 100644
index 00000000..fbec5c2d
--- /dev/null
+++ b/src/primary/apps/sonarr_routes.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+from flask import Blueprint, request, jsonify
+import datetime, os, requests
+from src.primary import keys_manager
+from src.primary.state import get_state_file_path
+
+sonarr_bp = Blueprint('sonarr', __name__)
+
+LOG_FILE = "/tmp/huntarr-logs/huntarr.log"
+
+# Make sure we're using the correct state files
+PROCESSED_MISSING_FILE = get_state_file_path("sonarr", "processed_missing")
+PROCESSED_UPGRADES_FILE = get_state_file_path("sonarr", "processed_upgrades")
+
+@sonarr_bp.route('/test-connection', methods=['POST'])
+def test_connection():
+ """Test connection to a Sonarr API instance"""
+ data = request.json
+ api_url = data.get('api_url')
+ api_key = data.get('api_key')
+
+ if not api_url or not api_key:
+ return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
+
+ headers = {'X-Api-Key': api_key}
+ test_url = f"{api_url.rstrip('/')}/api/v3/system/status"
+
+ try:
+ response = requests.get(test_url, headers=headers, timeout=10)
+ response.raise_for_status()
+
+ # Save keys if connection is successful
+ keys_manager.save_api_keys("sonarr", api_url, api_key)
+
+ # Ensure the response is valid JSON
+ try:
+ response_data = response.json()
+ except ValueError:
+ return jsonify({"success": False, "message": "Invalid JSON response from Sonarr API"}), 500
+
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ with open(LOG_FILE, 'a') as f:
+ f.write(f"{timestamp} - sonarr - INFO - Successfully connected to Sonarr API\n")
+
+ return jsonify({"success": True, "message": "Successfully connected to Sonarr API"})
+
+ except requests.exceptions.Timeout:
+ return jsonify({"success": False, "message": "Connection timed out"}), 504
+ except requests.exceptions.RequestException as e:
+ error_message = f"Connection failed: {str(e)}"
+ if e.response is not None:
+ try:
+ error_details = e.response.json()
+ error_message += f" - {error_details.get('message', 'No details')}"
+ except ValueError:
+ error_message += f" - Status Code: {e.response.status_code}"
+ return jsonify({"success": False, "message": error_message}), 500
+ except Exception as e:
+ return jsonify({"success": False, "message": f"An unexpected error occurred: {str(e)}"}), 500
+
+# Function to check if Sonarr is configured
+def is_configured():
+ """Check if Sonarr API credentials are configured"""
+ # Implementation as needed
+ return True
diff --git a/src/primary/background.py b/src/primary/background.py
index b0dc1d9e..5d243811 100644
--- a/src/primary/background.py
+++ b/src/primary/background.py
@@ -74,10 +74,12 @@ def app_specific_loop(app_type: str) -> None:
elif app_type == "lidarr":
missing_module = importlib.import_module('src.primary.apps.lidarr.missing')
upgrade_module = importlib.import_module('src.primary.apps.lidarr.upgrade')
- process_missing = getattr(missing_module, 'process_missing_albums')
+ # Use process_missing_content as the function name might change based on mode
+ process_missing = getattr(missing_module, 'process_missing_content')
process_upgrades = getattr(upgrade_module, 'process_cutoff_upgrades')
- hunt_missing_setting = "hunt_missing_albums"
- hunt_upgrade_setting = "hunt_upgrade_tracks"
+ hunt_missing_setting = "hunt_missing_items"
+ # Use hunt_upgrade_items
+ hunt_upgrade_setting = "hunt_upgrade_items"
elif app_type == "readarr":
missing_module = importlib.import_module('src.primary.apps.readarr.missing')
upgrade_module = importlib.import_module('src.primary.apps.readarr.upgrade')
diff --git a/src/primary/config.py b/src/primary/config.py
index da7d4b4c..41ac32d4 100644
--- a/src/primary/config.py
+++ b/src/primary/config.py
@@ -29,9 +29,14 @@ def determine_hunt_mode(app_name: str) -> str:
elif app_name == "radarr":
hunt_missing = settings_manager.get_setting(app_name, "hunt_missing_movies", 0)
hunt_upgrade = settings_manager.get_setting(app_name, "hunt_upgrade_movies", 0)
- elif app_name == "lidarr":
- hunt_missing = settings_manager.get_setting(app_name, "hunt_missing_albums", 0)
- hunt_upgrade = settings_manager.get_setting(app_name, "hunt_upgrade_tracks", 0)
+ elif app_name.lower() == 'lidarr':
+ # Use hunt_missing_items instead of hunt_missing_albums
+ hunt_missing = settings_manager.get_setting(app_name, "hunt_missing_items", 0)
+ # Use hunt_upgrade_items instead of hunt_upgrade_albums
+ hunt_upgrade = settings_manager.get_setting(app_name, "hunt_upgrade_items", 0)
+
+ # For Lidarr, also include the hunt_missing_mode
+ hunt_missing_mode = settings_manager.get_setting(app_name, "hunt_missing_mode", "artist")
elif app_name == "readarr":
hunt_missing = settings_manager.get_setting(app_name, "hunt_missing_books", 0)
hunt_upgrade = settings_manager.get_setting(app_name, "hunt_upgrade_books", 0)
@@ -130,11 +135,15 @@ def log_configuration(app_name: str):
log.info(f"Hunt Upgrade Movies: {settings.get('hunt_upgrade_movies', 0)}")
log.info(f"Skip Future Releases: {settings.get('skip_future_releases', True)}")
log.info(f"Skip Movie Refresh: {settings.get('skip_movie_refresh', False)}")
- elif app_name == "lidarr":
- log.info(f"Hunt Missing Albums: {settings.get('hunt_missing_albums', 0)}")
- log.info(f"Hunt Upgrade Tracks: {settings.get('hunt_upgrade_tracks', 0)}")
- log.info(f"Skip Future Releases: {settings.get('skip_future_releases', True)}")
- log.info(f"Skip Artist Refresh: {settings.get('skip_artist_refresh', False)}")
+ elif app_name.lower() == 'lidarr':
+ log.info(f"Mode: {settings.get('hunt_missing_mode', 'artist')}")
+ log.info(f"Hunt Missing Items: {settings.get('hunt_missing_items', 0)}")
+ # Use hunt_upgrade_items
+ log.info(f"Hunt Upgrade Items: {settings.get('hunt_upgrade_items', 0)}")
+ log.info(f"Sleep Duration: {settings.get('sleep_duration', 900)} seconds")
+ log.info(f"State Reset Interval: {settings.get('state_reset_interval_hours', 168)} hours")
+ log.info(f"Monitored Only: {settings.get('monitored_only', True)}")
+ log.info(f"Minimum Download Queue Size: {settings.get('minimum_download_queue_size', -1)}")
elif app_name == "readarr":
log.info(f"Hunt Missing Books: {settings.get('hunt_missing_books', 0)}")
log.info(f"Hunt Upgrade Books: {settings.get('hunt_upgrade_books', 0)}")
diff --git a/src/primary/default_configs/lidarr.json b/src/primary/default_configs/lidarr.json
index 68d98041..d45e7dd6 100644
--- a/src/primary/default_configs/lidarr.json
+++ b/src/primary/default_configs/lidarr.json
@@ -1,8 +1,9 @@
{
"api_url": "",
"api_key": "",
- "hunt_missing_albums": 1,
- "hunt_upgrade_tracks": 0,
+ "hunt_missing_mode": "artist",
+ "hunt_missing_items": 1,
+ "hunt_upgrade_items": 0,
"sleep_duration": 900,
"state_reset_interval_hours": 168,
"monitored_only": true,
diff --git a/src/primary/web_server.py b/src/primary/web_server.py
index c391b8bf..ff6435b0 100644
--- a/src/primary/web_server.py
+++ b/src/primary/web_server.py
@@ -36,6 +36,9 @@ from src.primary.auth import (
# Import blueprint for common routes
from src.primary.routes.common import common_bp
+# Import blueprints for each app from the centralized blueprints module
+from src.primary.apps.blueprints import sonarr_bp, radarr_bp, lidarr_bp, readarr_bp
+
# Disable Flask default logging
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
@@ -50,6 +53,10 @@ app.secret_key = os.environ.get('SECRET_KEY', 'dev_key_for_sessions')
# Register blueprints
app.register_blueprint(common_bp)
+app.register_blueprint(sonarr_bp, url_prefix='/api/sonarr')
+app.register_blueprint(radarr_bp, url_prefix='/api/radarr')
+app.register_blueprint(lidarr_bp, url_prefix='/api/lidarr')
+app.register_blueprint(readarr_bp, url_prefix='/api/readarr')
# Register the authentication check to run before requests
app.before_request(authenticate_request)