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:
Admin9705
2025-06-27 19:06:52 -04:00
parent c1deba322b
commit c1525390b7
3 changed files with 270 additions and 0 deletions

View File

@@ -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);

View File

@@ -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)

View File

@@ -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]: