This commit is contained in:
Admin9705
2025-05-01 15:07:56 -04:00
parent 6a492b4a22
commit c2b596fc52
11 changed files with 344 additions and 197 deletions

View File

@@ -1342,3 +1342,73 @@ input:checked + .slider:before {
font-weight: 600;
background-color: rgba(var(--accent-color-rgb), 0.1);
}
/* Settings Dropdown Styles - Matching log dropdown styles */
.settings-dropdown-container {
position: relative;
margin-bottom: 15px;
}
.settings-dropdown {
position: relative;
display: inline-block;
}
.settings-dropdown-btn {
padding: 10px 20px;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 20px;
font-weight: 500;
transition: all 0.3s;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
min-width: 140px;
justify-content: space-between;
}
.settings-dropdown-btn:hover {
background-color: var(--accent-hover);
}
.settings-dropdown-content {
display: none;
position: absolute;
top: 100%;
left: 0;
background-color: var(--bg-secondary);
min-width: 180px;
z-index: 10;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
margin-top: 8px;
border: 1px solid var(--border-color);
max-height: 300px;
overflow-y: auto;
}
.settings-dropdown-content.show {
display: block;
}
.settings-option {
display: block;
padding: 10px 15px;
text-decoration: none;
color: var(--text-primary);
transition: all 0.2s;
text-align: left;
}
.settings-option:hover {
background-color: var(--bg-tertiary);
}
.settings-option.active {
color: var(--accent-color);
font-weight: 600;
background-color: rgba(var(--accent-color-rgb), 0.1);
}

View File

@@ -56,47 +56,72 @@ function setupWhisparrForm() {
whisparrStatusIndicator.textContent = 'Testing...';
}
fetch('/api/whisparr/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_url: apiUrl,
api_key: apiKey
})
})
.then(response => response.json())
.then(data => {
if (whisparrStatusIndicator) {
if (data.success) {
whisparrStatusIndicator.className = 'connection-status success';
whisparrStatusIndicator.textContent = 'Connected';
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Successfully connected to Whisparr', 'success');
// First check API version to ensure it's v3 (Eros)
checkWhisparrApiVersion(apiUrl, apiKey)
.then(isErosApi => {
if (!isErosApi) {
// Show error if not using Eros API
if (whisparrStatusIndicator) {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Legacy API Detected';
}
getWhisparrVersion(); // Fetch version after successful connection
} else {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Failed';
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Connection to Whisparr failed: ' + data.message, 'error');
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Incompatible Whisparr version detected. Please upgrade to Whisparr Eros (v3) to use this integration.', 'error');
}
testWhisparrButton.disabled = false;
return Promise.reject('Legacy API detected');
}
// If using Eros API, proceed with connection test
return fetch('/api/whisparr/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_url: apiUrl,
api_key: apiKey
})
});
})
.then(response => response.json())
.then(data => {
if (whisparrStatusIndicator) {
if (data.success) {
whisparrStatusIndicator.className = 'connection-status success';
whisparrStatusIndicator.textContent = 'Connected';
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Successfully connected to Whisparr Eros', 'success');
}
getWhisparrVersion(); // Fetch version after successful connection
} else {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Failed';
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Connection to Whisparr failed: ' + data.message, 'error');
}
}
}
}
})
.catch(error => {
if (whisparrStatusIndicator) {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Error';
}
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Error testing Whisparr connection: ' + error, 'error');
}
})
.finally(() => {
testWhisparrButton.disabled = false;
});
})
.catch(error => {
// Skip additional error notification if it's the legacy API error we already handled
if (error !== 'Legacy API detected') {
if (whisparrStatusIndicator) {
whisparrStatusIndicator.className = 'connection-status failure';
whisparrStatusIndicator.textContent = 'Error';
}
if (typeof huntarrUI !== 'undefined' && huntarrUI.showNotification) {
huntarrUI.showNotification('Error testing Whisparr connection: ' + error, 'error');
}
}
})
.finally(() => {
if (testWhisparrButton.disabled) {
testWhisparrButton.disabled = false;
}
});
});
// Get Whisparr version if connection details are present and version display exists
@@ -132,4 +157,39 @@ function escapeHtml(unsafe) {
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Function to check Whisparr API version
function checkWhisparrApiVersion(apiUrl, apiKey) {
// Use the Eros API endpoint to check version
return fetch(`${apiUrl}/api/v3/system/status`, {
method: 'GET',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json'
}
})
.then(response => {
// Check if response is OK
if (!response.ok) {
// If we get a 404, it might be a non-Eros API
if (response.status === 404) {
return false;
}
// For other status codes, throw to trigger the catch
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
// Check if the response contains version info that starts with 3 (Eros)
if (data && data.version && data.version.startsWith('3')) {
return true;
}
return false;
})
.catch(() => {
// Any error means the API is not compatible
return false;
});
}

View File

@@ -81,7 +81,13 @@ let huntarrUI = {
this.elements.currentLogApp = document.getElementById('current-log-app'); // New: dropdown current selection text
this.elements.logDropdownBtn = document.querySelector('.log-dropdown-btn'); // New: dropdown toggle button
this.elements.logDropdownContent = document.querySelector('.log-dropdown-content'); // New: dropdown content
this.elements.settingsTabs = document.querySelectorAll('.settings-tab');
// Settings dropdown elements
this.elements.settingsOptions = document.querySelectorAll('.settings-option'); // New: settings dropdown options
this.elements.currentSettingsApp = document.getElementById('current-settings-app'); // New: current settings app text
this.elements.settingsDropdownBtn = document.querySelector('.settings-dropdown-btn'); // New: settings dropdown button
this.elements.settingsDropdownContent = document.querySelector('.settings-dropdown-content'); // New: dropdown content
this.elements.appSettingsPanels = document.querySelectorAll('.app-settings-panel');
// Logs
@@ -155,9 +161,24 @@ let huntarrUI = {
});
}
// Settings tabs
this.elements.settingsTabs.forEach(tab => {
tab.addEventListener('click', (e) => this.handleSettingsTabChange(e));
// Settings dropdown toggle
if (this.elements.settingsDropdownBtn) {
this.elements.settingsDropdownBtn.addEventListener('click', (e) => {
e.preventDefault();
this.elements.settingsDropdownContent.classList.toggle('show');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.settings-dropdown') && this.elements.settingsDropdownContent.classList.contains('show')) {
this.elements.settingsDropdownContent.classList.remove('show');
}
});
}
// Settings options
this.elements.settingsOptions.forEach(option => {
option.addEventListener('click', (e) => this.handleSettingsOptionChange(e));
});
// Save settings button
@@ -474,50 +495,37 @@ let huntarrUI = {
this.connectToLogs(); // Reconnect to the new log source
},
// Settings tab switching
handleSettingsTabChange: function(e) {
// Use currentTarget to ensure we get the button, not the inner element
const targetTab = e.currentTarget;
const app = targetTab.dataset.app; // Use dataset.app as added in SettingsForms
if (!app) {
console.error("Settings tab clicked, but no data-app attribute found.");
return; // Should not happen if HTML is correct
}
e.preventDefault(); // Prevent default if it was an anchor
// Check for unsaved changes before switching tabs
if (this.settingsChanged) {
if (!confirm('You have unsaved changes on the current tab. Switch tabs anyway? Changes will be lost.')) {
return; // Stop tab switch if user cancels
}
// User confirmed, reset flag before switching
this.settingsChanged = false;
this.updateSaveResetButtonState(false);
}
// Remove active class from all tabs and panels
this.elements.settingsTabs.forEach(tab => tab.classList.remove('active'));
// Settings option handling
handleSettingsOptionChange: function(e) {
e.preventDefault(); // Prevent default anchor behavior
const app = e.target.getAttribute('data-app');
if (!app || app === this.currentSettingsApp) return; // Do nothing if same tab clicked
// Update active option
this.elements.settingsOptions.forEach(option => {
option.classList.remove('active');
});
e.target.classList.add('active');
// Update the current settings app text with proper capitalization
let displayName = app.charAt(0).toUpperCase() + app.slice(1);
this.elements.currentSettingsApp.textContent = displayName;
// Close the dropdown
this.elements.settingsDropdownContent.classList.remove('show');
// Hide all settings panels
this.elements.appSettingsPanels.forEach(panel => {
panel.classList.remove('active');
panel.style.display = 'none'; // Explicitly hide
panel.style.display = 'none';
});
// Set the target tab as active
targetTab.classList.add('active');
// Show the corresponding settings panel
const panelElement = document.getElementById(`${app}Settings`);
if (panelElement) {
panelElement.classList.add('active');
panelElement.style.display = 'block'; // Explicitly show
this.currentSettingsTab = app; // Update current tab state
// Ensure settings are populated for this tab using the stored originalSettings
this.populateSettingsForm(app, this.originalSettings[app] || {});
// Reset save button state when switching tabs (already done above if confirmed)
this.updateSaveResetButtonState(false); // Ensure it's disabled on new tab
} else {
console.error(`Settings panel not found for app: ${app}`);
// Show the selected app's settings panel
const selectedPanel = document.getElementById(app + 'Settings');
if (selectedPanel) {
selectedPanel.classList.add('active');
selectedPanel.style.display = 'block';
}
},
@@ -748,7 +756,7 @@ let huntarrUI = {
SettingsForms.updateDurationDisplay();
}
// Load stateful management info
// Load stateful info immediately, don't wait for loadAllSettings to complete
this.loadStatefulInfo();
})
.catch(error => {

View File

@@ -713,7 +713,7 @@ const SettingsForms = {
// Create a container for instances with a scrollable area for many instances
let instancesHtml = `
<div class="settings-group">
<h3>Whisparr Instances</h3>
<h3>Whisparr Instances (Eros API v3 Only)</h3>
<div class="instances-container">
`;
@@ -774,18 +774,6 @@ const SettingsForms = {
container.innerHTML = `
${instancesHtml}
<div class="settings-group">
<h3>API Version</h3>
<div class="setting-item">
<label for="whisparr_version">Whisparr Version:</label>
<select id="whisparr_version">
<option value="v3" ${settings.whisparr_version === 'v3' || !settings.whisparr_version ? 'selected' : ''}>v3 (Eros)</option>
<option value="v2" ${settings.whisparr_version === 'v2' ? 'selected' : ''}>v2 (Legacy)</option>
</select>
<p class="setting-help">Select the API version of your Whisparr installation. Default is v3 (Eros). NOTE: Only v2 works for now!</p>
</div>
</div>
<div class="settings-group">
<h3>Search Settings</h3>
<div class="setting-item">
@@ -1034,9 +1022,33 @@ const SettingsForms = {
});
}
});
} else if (!resetStrikesBtn) {
console.warn('Could not find #reset_swaparr_strikes to attach listener.');
} else {
console.warn('huntarrUI or huntarrUI.resetStatefulManagement is not available.');
}
// Add confirmation dialog for local access bypass toggle
const localAccessBypassCheckbox = container.querySelector('#local_access_bypass');
if (localAccessBypassCheckbox) {
// Store original state
const originalState = localAccessBypassCheckbox.checked;
localAccessBypassCheckbox.addEventListener('change', function(event) {
const newState = this.checked;
// Preview the UI changes immediately, but they'll be reverted if user doesn't save
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.updateUIForLocalAccessBypass === 'function') {
huntarrUI.updateUIForLocalAccessBypass(newState);
}
// Also ensure the main app knows settings have changed if the preview runs
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.markSettingsAsChanged === 'function') {
huntarrUI.markSettingsAsChanged();
}
});
}
},
// Generate General settings form
generateGeneralForm: function(container, settings = {}) {
container.innerHTML = `
@@ -1169,28 +1181,8 @@ const SettingsForms = {
} else {
console.warn('huntarrUI or huntarrUI.resetStatefulManagement is not available.');
}
// Add confirmation dialog for local access bypass toggle
const localAccessBypassCheckbox = container.querySelector('#local_access_bypass');
if (localAccessBypassCheckbox) {
// Store original state
const originalState = localAccessBypassCheckbox.checked;
localAccessBypassCheckbox.addEventListener('change', function(event) {
const newState = this.checked;
// Preview the UI changes immediately, but they'll be reverted if user doesn't save
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.updateUIForLocalAccessBypass === 'function') {
huntarrUI.updateUIForLocalAccessBypass(newState);
}
// Also ensure the main app knows settings have changed if the preview runs
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.markSettingsAsChanged === 'function') {
huntarrUI.markSettingsAsChanged();
}
});
}
},
// Update duration display - e.g., convert seconds to hours
updateDurationDisplay: function() {
// Function to update a specific sleep duration display

View File

@@ -1,13 +1,22 @@
<section id="settingsSection" class="content-section">
<div class="section-header">
<div class="app-tabs">
<button class="settings-tab active" data-app="general">General</button>
<button class="settings-tab" data-app="sonarr">Sonarr</button>
<button class="settings-tab" data-app="radarr">Radarr</button>
<button class="settings-tab" data-app="lidarr">Lidarr</button>
<button class="settings-tab" data-app="readarr">Readarr</button>
<button class="settings-tab" data-app="whisparr">Whisparr</button>
<button class="settings-tab" data-app="swaparr">Swaparr</button>
<!-- Replace settings tabs with dropdown -->
<div class="settings-dropdown-container">
<div class="settings-dropdown">
<button class="settings-dropdown-btn">
<span id="current-settings-app">General</span>
<i class="fas fa-chevron-down"></i>
</button>
<div class="settings-dropdown-content">
<a href="#" class="settings-option active" data-app="general">General</a>
<a href="#" class="settings-option" data-app="sonarr">Sonarr</a>
<a href="#" class="settings-option" data-app="radarr">Radarr</a>
<a href="#" class="settings-option" data-app="lidarr">Lidarr</a>
<a href="#" class="settings-option" data-app="readarr">Readarr</a>
<a href="#" class="settings-option" data-app="whisparr">Whisparr</a>
<a href="#" class="settings-option" data-app="swaparr">Swaparr</a>
</div>
</div>
</div>
<div class="settings-actions">

View File

@@ -24,7 +24,7 @@ def test_connection():
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
# Log the test attempt
whisparr_logger.info(f"Testing connection to Whisparr API at {api_url}")
whisparr_logger.info(f"Testing connection to Whisparr Eros API at {api_url}")
# First check if URL is properly formatted
if not (api_url.startswith('http://') or api_url.startswith('https://')):
@@ -32,7 +32,7 @@ def test_connection():
whisparr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg}), 400
# For Whisparr, use api/v3
# For Whisparr Eros, use api/v3
api_base = "api/v3"
test_url = f"{api_url.rstrip('/')}/{api_base}/system/status"
headers = {'X-Api-Key': api_key}
@@ -42,7 +42,7 @@ def test_connection():
response = requests.get(test_url, headers=headers, timeout=(10, api_timeout))
# Log HTTP status code for diagnostic purposes
whisparr_logger.debug(f"Whisparr API status code: {response.status_code}")
whisparr_logger.debug(f"Whisparr Eros API status code: {response.status_code}")
# Check HTTP status code
response.raise_for_status()
@@ -51,19 +51,23 @@ def test_connection():
try:
response_data = response.json()
# We no longer save keys here since we use instances
# keys_manager.save_api_keys("whisparr", api_url, api_key)
# Validate that this is actually Eros API v3
if 'version' in response_data and not response_data['version'].startswith('3'):
error_msg = f"Incompatible Whisparr version detected: {response_data.get('version')}. Huntarr requires Eros API v3."
whisparr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg, "is_eros": False}), 400
whisparr_logger.info(f"Successfully connected to Whisparr API version: {response_data.get('version', 'unknown')}")
whisparr_logger.info(f"Successfully connected to Whisparr Eros API version: {response_data.get('version', 'unknown')}")
# Return success with some useful information
return jsonify({
"success": True,
"message": "Successfully connected to Whisparr API",
"version": response_data.get('version', 'unknown')
"message": "Successfully connected to Whisparr Eros API",
"version": response_data.get('version', 'unknown'),
"is_eros": True
})
except ValueError:
error_msg = "Invalid JSON response from Whisparr API"
error_msg = "Invalid JSON response from Whisparr Eros API"
whisparr_logger.error(f"{error_msg}. Response content: {response.text[:200]}")
return jsonify({"success": False, "message": error_msg}), 500

View File

@@ -2,9 +2,7 @@
Whisparr app module for Huntarr
Contains functionality for missing items and quality upgrades in Whisparr
Supports both v2 (legacy) and v3 (Eros) API versions.
v2 - Original Whisparr API
v3 - Eros version of the Whisparr API
Exclusively supports the v3 Eros API.
"""
# Module exports
@@ -29,9 +27,8 @@ def get_configured_instances():
whisparr_logger.debug("No settings found for Whisparr")
return instances
# Get the API version to use (v2 or v3/Eros)
api_version = settings.get("whisparr_version", "v3")
whisparr_logger.info(f"Using Whisparr API version: {api_version}")
# Always use Eros API v3
whisparr_logger.info("Using Whisparr Eros API v3 exclusively")
# Check if instances are configured
if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]:
@@ -56,8 +53,7 @@ def get_configured_instances():
instance_data = {
"instance_name": instance.get("name", "Default"),
"api_url": api_url,
"api_key": api_key,
"api_version": api_version # Add the API version to the instance data
"api_key": api_key
}
instances.append(instance_data)
whisparr_logger.info(f"Added valid instance: {instance_data}")
@@ -83,8 +79,7 @@ def get_configured_instances():
instance_data = {
"instance_name": "Default",
"api_url": api_url,
"api_key": api_key,
"api_version": api_version # Add the API version to the instance data
"api_key": api_key
}
instances.append(instance_data)
whisparr_logger.info(f"Added valid legacy instance: {instance_data}")

View File

@@ -3,7 +3,7 @@
Whisparr-specific API functions
Handles all communication with the Whisparr API
Supports both v2 (legacy) and v3 (Eros) API versions
Exclusively uses the Eros API v3
"""
import requests
@@ -21,9 +21,9 @@ whisparr_logger = get_logger("whisparr")
# Use a session for better performance
session = requests.Session()
def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, api_version: str = "v3") -> Any:
def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any:
"""
Make a request to the Whisparr API.
Make a request to the Whisparr Eros API.
Args:
api_url: The base URL of the Whisparr API
@@ -32,7 +32,6 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met
endpoint: The API endpoint to call
method: HTTP method (GET, POST, PUT, DELETE)
data: Optional data to send with the request
api_version: API version to use ("v2" or "v3")
Returns:
The JSON response from the API, or None if the request failed
@@ -41,10 +40,9 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met
whisparr_logger.error("API URL or API key is missing. Check your settings.")
return None
# IMPORTANT: Whisparr 2.x uses v3 API endpoints even though it's labeled as v2 in our settings
# Always use v3 for API path
api_base = f"api/v3"
whisparr_logger.debug(f"Using Whisparr API base path: {api_base}")
# Always use v3 for Eros API
api_base = "api/v3"
whisparr_logger.debug(f"Using Whisparr Eros API: {api_base}")
# Full URL - ensure no double slashes
url = f"{api_url.rstrip('/')}/{api_base}/{endpoint.lstrip('/')}"
@@ -92,7 +90,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met
whisparr_logger.error(f"Unexpected error during API request: {e}")
return None
def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, api_version: str = "v3") -> int:
def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int:
"""
Get the current size of the download queue.
@@ -100,12 +98,11 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, api_ve
api_url: The base URL of the Whisparr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
api_version: API version to use ("v2" or "v3")
Returns:
The number of items in the download queue, or -1 if the request failed
"""
response = arr_request(api_url, api_key, api_timeout, "queue", api_version=api_version)
response = arr_request(api_url, api_key, api_timeout, "queue")
if response is None:
return -1
@@ -118,7 +115,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, api_ve
else:
return -1
def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, api_version: str = "v3") -> List[Dict[str, Any]]:
def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
"""
Get a list of items with missing files (not downloaded/available).
@@ -127,7 +124,6 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored items.
api_version: API version to use ("v2" or "v3")
Returns:
A list of item objects with missing files, or None if the request failed.
@@ -138,7 +134,7 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor
# Endpoint parameters - always use v3 format since we're using v3 API
endpoint = "wanted/missing?pageSize=1000&sortKey=airDateUtc&sortDirection=descending"
response = arr_request(api_url, api_key, api_timeout, endpoint, api_version=api_version)
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
return None
@@ -159,7 +155,7 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor
whisparr_logger.error(f"Error retrieving missing items: {str(e)}")
return None
def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, api_version: str = "v3") -> List[Dict[str, Any]]:
def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]:
"""
Get a list of items that don't meet their quality profile cutoff.
@@ -168,7 +164,6 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor
api_key: The API key for authentication
api_timeout: Timeout for the API request
monitored_only: If True, only return monitored items.
api_version: API version to use ("v2" or "v3")
Returns:
A list of item objects that need quality upgrades, or None if the request failed.
@@ -179,7 +174,7 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor
# Endpoint - always use v3 format
endpoint = "wanted/cutoff?pageSize=1000&sortKey=airDateUtc&sortDirection=descending"
response = arr_request(api_url, api_key, api_timeout, endpoint, api_version=api_version)
response = arr_request(api_url, api_key, api_timeout, endpoint)
if response is None:
return None
@@ -202,7 +197,7 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor
whisparr_logger.error(f"Error retrieving cutoff unmet items: {str(e)}")
return None
def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int, api_version: str = "v3") -> int:
def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int) -> int:
"""
Refresh an item in Whisparr.
@@ -211,7 +206,6 @@ def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int, api
api_key: The API key for authentication
api_timeout: Timeout for the API request
item_id: The ID of the item to refresh
api_version: API version to use ("v2" or "v3")
Returns:
The command ID if the refresh was triggered successfully, None otherwise
@@ -223,7 +217,7 @@ def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int, api
# Use series refresh instead if we can get the series ID from the episode
# First, attempt to get the episode details
episode_endpoint = f"episode/{item_id}"
episode_data = arr_request(api_url, api_key, api_timeout, episode_endpoint, api_version=api_version)
episode_data = arr_request(api_url, api_key, api_timeout, episode_endpoint)
if episode_data and "seriesId" in episode_data:
# We have the series ID, use series refresh which is more reliable
@@ -243,7 +237,7 @@ def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int, api
"episodeIds": [item_id]
}
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload, api_version=api_version)
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
if response and "id" in response:
command_id = response["id"]
@@ -257,7 +251,7 @@ def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int, api
whisparr_logger.error(f"Error refreshing item: {str(e)}")
return None
def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int], api_version: str = "v3") -> int:
def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int]) -> int:
"""
Trigger a search for one or more items.
@@ -266,7 +260,6 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int
api_key: The API key for authentication
api_timeout: Timeout for the API request
item_ids: A list of item IDs to search for
api_version: API version to use ("v2" or "v3")
Returns:
The command ID if the search command was triggered successfully, None otherwise
@@ -280,7 +273,7 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int
"episodeIds": item_ids
}
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload, api_version=api_version)
response = arr_request(api_url, api_key, api_timeout, "command", method="POST", data=payload)
if response and "id" in response:
command_id = response["id"]
@@ -294,7 +287,7 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int
whisparr_logger.error(f"Error searching for items: {str(e)}")
return None
def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int, api_version: str = "v3") -> Optional[Dict]:
def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int) -> Optional[Dict]:
"""
Get the status of a specific command.
@@ -303,7 +296,6 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id:
api_key: The API key for authentication
api_timeout: Timeout for the API request
command_id: The ID of the command to check
api_version: API version to use ("v2" or "v3")
Returns:
A dictionary containing the command status, or None if the request failed.
@@ -314,7 +306,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id:
try:
endpoint = f"command/{command_id}"
result = arr_request(api_url, api_key, api_timeout, endpoint, api_version=api_version)
result = arr_request(api_url, api_key, api_timeout, endpoint)
if result:
whisparr_logger.debug(f"Command {command_id} status: {result.get('status', 'unknown')}")
@@ -326,7 +318,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id:
whisparr_logger.error(f"Error getting command status for ID {command_id}: {e}")
return None
def check_connection(api_url: str, api_key: str, api_timeout: int, api_version: str = "v3") -> bool:
def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool:
"""
Check the connection to Whisparr API.
@@ -334,14 +326,13 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, api_version:
api_url: The base URL of the Whisparr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
api_version: API version to use ("v2" or "v3")
Returns:
True if the connection is successful, False otherwise
"""
try:
# System status is a good endpoint for verifying API connectivity
response = arr_request(api_url, api_key, api_timeout, "system/status", api_version=api_version)
response = arr_request(api_url, api_key, api_timeout, "system/status")
if response is not None:
# Get the version information if available

View File

@@ -51,9 +51,8 @@ def process_missing_items(
command_wait_attempts = app_settings.get("command_wait_attempts", 12)
state_reset_interval_hours = app_settings.get("state_reset_interval_hours", 168)
# Get the API version to use (v2 or v3)
api_version = app_settings.get("whisparr_version", "v3")
whisparr_logger.info(f"Using Whisparr API version: {api_version}")
# Log that we're using Eros API v3
whisparr_logger.info(f"Using Whisparr Eros API v3 for instance: {instance_name}")
# Skip if hunt_missing_items is set to 0
if hunt_missing_items <= 0:
@@ -67,7 +66,7 @@ def process_missing_items(
# Get missing items
whisparr_logger.info(f"Retrieving items with missing files...")
missing_items = whisparr_api.get_items_with_missing(api_url, api_key, api_timeout, monitored_only, api_version)
missing_items = whisparr_api.get_items_with_missing(api_url, api_key, api_timeout, monitored_only)
if missing_items is None: # API call failed
whisparr_logger.error("Failed to retrieve missing items from Whisparr API.")
@@ -151,7 +150,7 @@ def process_missing_items(
refresh_command_id = None
if not skip_item_refresh:
whisparr_logger.info(" - Refreshing item information...")
refresh_command_id = whisparr_api.refresh_item(api_url, api_key, api_timeout, item_id, api_version)
refresh_command_id = whisparr_api.refresh_item(api_url, api_key, api_timeout, item_id)
if refresh_command_id:
whisparr_logger.info(f"Triggered refresh command {refresh_command_id}. Waiting a few seconds...")
time.sleep(5) # Basic wait
@@ -167,7 +166,7 @@ def process_missing_items(
# Search for the item
whisparr_logger.info(" - Searching for missing item...")
search_command_id = whisparr_api.item_search(api_url, api_key, api_timeout, [item_id], api_version)
search_command_id = whisparr_api.item_search(api_url, api_key, api_timeout, [item_id])
if search_command_id:
whisparr_logger.info(f"Triggered search command {search_command_id}. Assuming success for now.")

View File

@@ -50,9 +50,8 @@ def process_cutoff_upgrades(
command_wait_attempts = app_settings.get("command_wait_attempts", 12)
state_reset_interval_hours = app_settings.get("state_reset_interval_hours", 168)
# Get the API version to use (v2 or v3)
api_version = app_settings.get("whisparr_version", "v3")
whisparr_logger.info(f"Using Whisparr API version: {api_version}")
# Log that we're using Eros API v3
whisparr_logger.info(f"Using Whisparr Eros API v3 for instance: {instance_name}")
# Skip if hunt_upgrade_items is set to 0
if hunt_upgrade_items <= 0:
@@ -66,7 +65,7 @@ def process_cutoff_upgrades(
# Get items eligible for upgrade
whisparr_logger.info(f"Retrieving items eligible for cutoff upgrade...")
upgrade_eligible_data = whisparr_api.get_cutoff_unmet_items(api_url, api_key, api_timeout, monitored_only, api_version)
upgrade_eligible_data = whisparr_api.get_cutoff_unmet_items(api_url, api_key, api_timeout, monitored_only)
if not upgrade_eligible_data:
whisparr_logger.info("No items found eligible for upgrade or error retrieving them.")
@@ -129,7 +128,7 @@ def process_cutoff_upgrades(
refresh_command_id = None
if not skip_item_refresh:
whisparr_logger.info(" - Refreshing item information...")
refresh_command_id = whisparr_api.refresh_item(api_url, api_key, api_timeout, item_id, api_version)
refresh_command_id = whisparr_api.refresh_item(api_url, api_key, api_timeout, item_id)
if refresh_command_id:
whisparr_logger.info(f"Triggered refresh command {refresh_command_id}. Waiting a few seconds...")
time.sleep(5) # Basic wait
@@ -145,7 +144,7 @@ def process_cutoff_upgrades(
# Search for the item
whisparr_logger.info(" - Searching for quality upgrade...")
search_command_id = whisparr_api.item_search(api_url, api_key, api_timeout, [item_id], api_version)
search_command_id = whisparr_api.item_search(api_url, api_key, api_timeout, [item_id])
if search_command_id:
whisparr_logger.info(f"Triggered search command {search_command_id}. Assuming success for now.")

View File

@@ -24,9 +24,9 @@ def test_connection():
if not api_url or not api_key:
return jsonify({"success": False, "message": "API URL and API Key are required"}), 400
whisparr_logger.info(f"Testing connection to Whisparr API at {api_url}")
whisparr_logger.info(f"Testing connection to Whisparr Eros API at {api_url}")
# Always use v3 API endpoint for Whisparr
# Use v3 API endpoint for Whisparr Eros
url = f"{api_url}/api/v3/system/status"
headers = {
"X-Api-Key": api_key,
@@ -40,12 +40,20 @@ def test_connection():
try:
response_data = response.json()
version = response_data.get('version', 'unknown')
whisparr_logger.info(f"Successfully connected to Whisparr API version: {version}")
# Validate that this is actually Eros API v3
if not version.startswith('3'):
error_msg = f"Whisparr version {version} detected. Huntarr requires Eros API v3."
whisparr_logger.error(error_msg)
return jsonify({"success": False, "message": error_msg, "is_eros": False}), 400
whisparr_logger.info(f"Successfully connected to Whisparr Eros API version: {version}")
return jsonify({
"success": True,
"message": "Successfully connected to Whisparr API",
"version": version
"message": "Successfully connected to Whisparr Eros API",
"version": version,
"is_eros": True
})
except ValueError:
error_msg = "Invalid JSON response from Whisparr API"
@@ -65,13 +73,13 @@ def is_configured():
@whisparr_bp.route('/get-versions', methods=['GET'])
def get_versions():
"""Get the version information from the Whisparr API"""
"""Get the version information from the Whisparr Eros API"""
api_keys = keys_manager.load_api_keys("whisparr")
api_url = api_keys.get("api_url")
api_key = api_keys.get("api_key")
if not api_url or not api_key:
return jsonify({"success": False, "message": "Whisparr API is not configured"}), 400
return jsonify({"success": False, "message": "Whisparr Eros API is not configured"}), 400
headers = {'X-Api-Key': api_key}
version_url = f"{api_url.rstrip('/')}/api/v3/system/status"
@@ -83,9 +91,21 @@ def get_versions():
version_data = response.json()
version = version_data.get("version", "Unknown")
return jsonify({"success": True, "version": version})
# Validate that it's Eros API v3
if not version.startswith('3'):
return jsonify({
"success": False,
"message": f"Incompatible Whisparr version detected: {version}. Huntarr requires Eros API v3.",
"is_eros": False
}), 400
return jsonify({
"success": True,
"version": version,
"is_eros": True
})
except requests.exceptions.RequestException as e:
error_message = f"Error fetching Whisparr version: {str(e)}"
error_message = f"Error fetching Whisparr Eros version: {str(e)}"
return jsonify({"success": False, "message": error_message}), 500
@whisparr_bp.route('/logs', methods=['GET'])