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)