mirror of
https://github.com/plexguide/Huntarr-Sonarr.git
synced 2025-12-16 20:04:16 -06:00
Add state management migration and UI enhancements for instance settings
- Implemented state management features in the UI, allowing users to enable/disable tracking of processed media with a reset interval configuration. - Added event listeners for state management interactions, including a reset functionality and dynamic display updates based on user selections. - Introduced a migration process for state management data when instance names change, ensuring data integrity and continuity. - Enhanced logging for better tracking of state management changes and migration success or failure.
This commit is contained in:
@@ -3822,6 +3822,55 @@ const SettingsForms = {
|
||||
<p class="setting-help" style="display: none;" id="episodes-upgrade-warning-${newIndex}">⚠️ Episodes mode makes more API calls and does not support tagging. Season Packs recommended.</p>
|
||||
</div>
|
||||
` : ''}
|
||||
<!-- Instance State Management -->
|
||||
<div class="setting-item" style="border-top: 1px solid rgba(90, 109, 137, 0.2); padding-top: 15px; margin-top: 15px;">
|
||||
<label for="${appType}-state-management-mode-${newIndex}"><a href="https://plexguide.github.io/Huntarr.io/settings/settings.html#state-reset-hours" class="info-icon" title="Configure state management for this instance" target="_blank" rel="noopener"><i class="fas fa-database"></i></a>State Management:</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="${appType}-state-management-mode-${newIndex}" name="state_management_mode" style="width: 150px; padding: 8px 12px; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #1f2937; color: #d1d5db;">
|
||||
<option value="custom" selected>Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
<button type="button" id="${appType}-state-reset-btn-${newIndex}" class="btn btn-danger" style="display: inline-flex; background: linear-gradient(145deg, rgba(231, 76, 60, 0.2), rgba(192, 57, 43, 0.15)); color: rgba(231, 76, 60, 0.9); border: 1px solid rgba(231, 76, 60, 0.3); padding: 6px 12px; border-radius: 6px; font-size: 12px; align-items: center; gap: 4px; cursor: pointer; transition: all 0.2s ease;">
|
||||
<i class="fas fa-redo"></i> Reset State
|
||||
</button>
|
||||
</div>
|
||||
<p class="setting-help">Enable state management to track processed media and prevent reprocessing</p>
|
||||
</div>
|
||||
|
||||
<!-- State Management Hours (visible when enabled) -->
|
||||
<div class="setting-item" id="${appType}-custom-state-hours-${newIndex}" style="display: block; margin-left: 20px; padding: 12px; background: linear-gradient(145deg, rgba(30, 39, 56, 0.3), rgba(22, 28, 40, 0.4)); border: 1px solid rgba(90, 109, 137, 0.15); border-radius: 8px;">
|
||||
<label for="${appType}-state-management-hours-${newIndex}" style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-clock" style="color: #6366f1;"></i>
|
||||
Reset Interval:
|
||||
</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<input type="number" id="${appType}-state-management-hours-${newIndex}" name="state_management_hours" min="1" max="8760" value="168" style="width: 80px; padding: 8px 12px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #374151; color: #d1d5db;">
|
||||
<span style="color: #9ca3af; font-size: 14px;">
|
||||
hours (<span id="${appType}-state-days-display-${newIndex}">7.0</span> days)
|
||||
</span>
|
||||
</div>
|
||||
<p class="setting-help" style="font-size: 13px; color: #9ca3af; margin-top: 8px;">
|
||||
<i class="fas fa-info-circle" style="margin-right: 4px;"></i>
|
||||
State will automatically reset every <span id="${appType}-state-hours-text-${newIndex}">168</span> hours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- State Status Display -->
|
||||
<div class="setting-item" id="${appType}-state-status-${newIndex}" style="display: block; margin-left: 20px; padding: 10px; background: linear-gradient(145deg, rgba(16, 185, 129, 0.1), rgba(5, 150, 105, 0.05)); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 6px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="color: #10b981; font-weight: 600;">
|
||||
<i class="fas fa-check-circle" style="margin-right: 4px;"></i>
|
||||
Active - Tracked Items: <span id="${appType}-state-items-count-${newIndex}">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="color: #9ca3af; font-size: 12px;">Next Reset:</div>
|
||||
<div id="${appType}-state-reset-time-${newIndex}" style="color: #d1d5db; font-weight: 500;">Calculating...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="${appType}-swaparr-${newIndex}"><a href="https://plexguide.github.io/Huntarr.io/apps/swaparr.html" class="info-icon" title="Enable Swaparr stalled download monitoring for this instance" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Swaparr:</label>
|
||||
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
|
||||
@@ -3903,6 +3952,78 @@ const SettingsForms = {
|
||||
}
|
||||
}
|
||||
|
||||
// Set up state management event listeners for the new instance
|
||||
const stateManagementModeSelect = newInstance.querySelector(`#${appType}-state-management-mode-${newIndex}`);
|
||||
const customStateHours = newInstance.querySelector(`#${appType}-custom-state-hours-${newIndex}`);
|
||||
const stateStatus = newInstance.querySelector(`#${appType}-state-status-${newIndex}`);
|
||||
const stateResetBtn = newInstance.querySelector(`#${appType}-state-reset-btn-${newIndex}`);
|
||||
const stateHoursInput = newInstance.querySelector(`#${appType}-state-management-hours-${newIndex}`);
|
||||
const stateDaysDisplay = newInstance.querySelector(`#${appType}-state-days-display-${newIndex}`);
|
||||
const stateHoursText = newInstance.querySelector(`#${appType}-state-hours-text-${newIndex}`);
|
||||
|
||||
if (stateManagementModeSelect && customStateHours && stateStatus && stateResetBtn) {
|
||||
// State management mode change listener
|
||||
stateManagementModeSelect.addEventListener('change', function() {
|
||||
if (this.value === 'disabled') {
|
||||
customStateHours.style.display = 'none';
|
||||
stateStatus.style.display = 'none';
|
||||
stateResetBtn.style.display = 'none';
|
||||
} else {
|
||||
customStateHours.style.display = 'block';
|
||||
stateStatus.style.display = 'block';
|
||||
stateResetBtn.style.display = 'inline-flex';
|
||||
}
|
||||
});
|
||||
|
||||
// State reset button listener
|
||||
stateResetBtn.addEventListener('click', function() {
|
||||
const instanceNameInput = newInstance.querySelector('input[name="name"]');
|
||||
const instanceName = instanceNameInput ? instanceNameInput.value || 'Default' : 'Default';
|
||||
|
||||
if (confirm(`Are you sure you want to reset the state for ${appType.charAt(0).toUpperCase() + appType.slice(1)} instance "${instanceName}"? This will clear all tracking data and allow items to be reprocessed.`)) {
|
||||
// Call the state reset API
|
||||
const resetUrl = `./api/stateful/reset?app_type=${encodeURIComponent(appType)}&instance_name=${encodeURIComponent(instanceName)}`;
|
||||
|
||||
fetch(resetUrl, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Reload state information for this instance
|
||||
if (typeof huntarrUI !== 'undefined' && huntarrUI.loadStateManagementForInstance) {
|
||||
huntarrUI.loadStateManagementForInstance(appType, newIndex, instanceName);
|
||||
}
|
||||
alert('State reset successfully!');
|
||||
} else {
|
||||
alert('Failed to reset state: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error resetting state:', error);
|
||||
alert('Error resetting state. Please try again.');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// State hours input listener for real-time updates
|
||||
if (stateHoursInput && stateDaysDisplay && stateHoursText) {
|
||||
stateHoursInput.addEventListener('input', function() {
|
||||
const hours = parseInt(this.value) || 168;
|
||||
const days = (hours / 24).toFixed(1);
|
||||
stateDaysDisplay.textContent = days;
|
||||
stateHoursText.textContent = hours;
|
||||
});
|
||||
}
|
||||
|
||||
// Load state information for the new instance after a short delay
|
||||
setTimeout(() => {
|
||||
if (typeof huntarrUI !== 'undefined' && huntarrUI.loadStateManagementForInstance) {
|
||||
const instanceNameInput = newInstance.querySelector('input[name="name"]');
|
||||
const instanceName = instanceNameInput ? instanceNameInput.value || 'Default' : 'Default';
|
||||
huntarrUI.loadStateManagementForInstance(appType, newIndex, instanceName);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Initial status check for the new instance
|
||||
SettingsForms.checkConnectionStatus(appType, newIndex);
|
||||
|
||||
|
||||
@@ -236,6 +236,10 @@ def save_settings(app_name: str, settings_data: Dict[str, Any]) -> bool:
|
||||
if app_name == 'general':
|
||||
db.save_general_settings(settings_data)
|
||||
else:
|
||||
# For app configs, check if instance names have changed and migrate state management data
|
||||
if 'instances' in settings_data and isinstance(settings_data['instances'], list):
|
||||
_migrate_instance_state_management_if_needed(app_name, settings_data, db)
|
||||
|
||||
db.save_app_config(app_name, settings_data)
|
||||
|
||||
# Auto-save enabled - no need to log every successful save
|
||||
@@ -606,6 +610,57 @@ def initialize_database():
|
||||
|
||||
settings_logger.info("Database initialization completed successfully")
|
||||
|
||||
def _migrate_instance_state_management_if_needed(app_name: str, new_settings_data: Dict[str, Any], db) -> None:
|
||||
"""
|
||||
Check if instance names have changed and migrate state management data if needed.
|
||||
|
||||
Args:
|
||||
app_name: The app type (e.g., 'sonarr', 'radarr')
|
||||
new_settings_data: The new settings data being saved
|
||||
db: Database instance
|
||||
"""
|
||||
try:
|
||||
# Get current settings from database to compare
|
||||
current_settings = db.get_app_config(app_name)
|
||||
if not current_settings or 'instances' not in current_settings:
|
||||
# No existing instances to migrate from
|
||||
return
|
||||
|
||||
current_instances = current_settings.get('instances', [])
|
||||
new_instances = new_settings_data.get('instances', [])
|
||||
|
||||
if not isinstance(current_instances, list) or not isinstance(new_instances, list):
|
||||
return
|
||||
|
||||
# Create mappings of instances by their position/index and identify name changes
|
||||
for i, (current_instance, new_instance) in enumerate(zip(current_instances, new_instances)):
|
||||
if not isinstance(current_instance, dict) or not isinstance(new_instance, dict):
|
||||
continue
|
||||
|
||||
current_name = current_instance.get('name', f'Instance {i+1}')
|
||||
new_name = new_instance.get('name', f'Instance {i+1}')
|
||||
|
||||
# If name has changed, migrate the state management data
|
||||
if current_name != new_name and current_name and new_name:
|
||||
settings_logger.info(f"Detected instance name change for {app_name} instance {i+1}: '{current_name}' -> '{new_name}'")
|
||||
|
||||
# Attempt to migrate state management data
|
||||
migration_success = db.migrate_instance_state_management(app_name, current_name, new_name)
|
||||
|
||||
if migration_success:
|
||||
settings_logger.info(f"Successfully migrated state management data for {app_name} from '{current_name}' to '{new_name}'")
|
||||
else:
|
||||
settings_logger.warning(f"Failed to migrate state management data for {app_name} from '{current_name}' to '{new_name}' - user may need to reset state management")
|
||||
|
||||
# Handle case where instances were removed (we don't migrate in this case, just log)
|
||||
if len(current_instances) > len(new_instances):
|
||||
removed_count = len(current_instances) - len(new_instances)
|
||||
settings_logger.info(f"Detected {removed_count} removed instances for {app_name} - state management data for removed instances will remain in database")
|
||||
|
||||
except Exception as e:
|
||||
settings_logger.error(f"Error checking for instance name changes in {app_name}: {e}")
|
||||
# Don't fail the save operation if migration checking fails
|
||||
|
||||
|
||||
|
||||
# Example usage (for testing purposes, remove later)
|
||||
|
||||
@@ -969,6 +969,100 @@ class HuntarrDatabase:
|
||||
self.set_instance_lock_info(app_type, instance_name, current_time, expires_at, expiration_hours)
|
||||
logger.info(f"Initialized state management for {app_type}/{instance_name} with {expiration_hours}h expiration")
|
||||
|
||||
def migrate_instance_state_management(self, app_type: str, old_instance_name: str, new_instance_name: str) -> bool:
|
||||
"""Migrate state management data from old instance name to new instance name"""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
# Check if old instance has any state management data
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM stateful_instance_locks
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, old_instance_name))
|
||||
has_lock_data = cursor.fetchone()[0] > 0
|
||||
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM stateful_processed_ids
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, old_instance_name))
|
||||
has_processed_data = cursor.fetchone()[0] > 0
|
||||
|
||||
if not has_lock_data and not has_processed_data:
|
||||
logger.debug(f"No state management data found for {app_type}/{old_instance_name}, skipping migration")
|
||||
return True
|
||||
|
||||
# Check if new instance name already has data (avoid overwriting)
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM stateful_instance_locks
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, new_instance_name))
|
||||
new_has_lock_data = cursor.fetchone()[0] > 0
|
||||
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM stateful_processed_ids
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, new_instance_name))
|
||||
new_has_processed_data = cursor.fetchone()[0] > 0
|
||||
|
||||
if new_has_lock_data or new_has_processed_data:
|
||||
logger.warning(f"New instance name {app_type}/{new_instance_name} already has state management data, skipping migration to avoid conflicts")
|
||||
return False
|
||||
|
||||
# Migrate lock data
|
||||
if has_lock_data:
|
||||
conn.execute('''
|
||||
UPDATE stateful_instance_locks
|
||||
SET instance_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (new_instance_name, app_type, old_instance_name))
|
||||
logger.info(f"Migrated state management lock data from {app_type}/{old_instance_name} to {app_type}/{new_instance_name}")
|
||||
|
||||
# Migrate processed IDs
|
||||
if has_processed_data:
|
||||
conn.execute('''
|
||||
UPDATE stateful_processed_ids
|
||||
SET instance_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (new_instance_name, app_type, old_instance_name))
|
||||
|
||||
# Get count of migrated IDs for logging
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM stateful_processed_ids
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, new_instance_name))
|
||||
migrated_count = cursor.fetchone()[0]
|
||||
|
||||
logger.info(f"Migrated {migrated_count} processed IDs from {app_type}/{old_instance_name} to {app_type}/{new_instance_name}")
|
||||
|
||||
# Also migrate hunt history data if it exists
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM hunt_history
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, old_instance_name))
|
||||
has_history_data = cursor.fetchone()[0] > 0
|
||||
|
||||
if has_history_data:
|
||||
conn.execute('''
|
||||
UPDATE hunt_history
|
||||
SET instance_name = ?
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (new_instance_name, app_type, old_instance_name))
|
||||
|
||||
cursor = conn.execute('''
|
||||
SELECT COUNT(*) FROM hunt_history
|
||||
WHERE app_type = ? AND instance_name = ?
|
||||
''', (app_type, new_instance_name))
|
||||
migrated_history_count = cursor.fetchone()[0]
|
||||
|
||||
logger.info(f"Migrated {migrated_history_count} hunt history entries from {app_type}/{old_instance_name} to {app_type}/{new_instance_name}")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Successfully completed state management migration from {app_type}/{old_instance_name} to {app_type}/{new_instance_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error migrating state management data from {app_type}/{old_instance_name} to {app_type}/{new_instance_name}: {e}")
|
||||
return False
|
||||
|
||||
# Tally Data Management Methods
|
||||
|
||||
def get_media_stats(self, app_type: str = None) -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user