Files
Huntarr/frontend/static/js/new-main.js
T
Admin9705 ccf9ae2a9a fixes
2025-05-03 19:38:25 -04:00

2473 lines
109 KiB
JavaScript

/**
* Huntarr - New UI Implementation
* Main JavaScript file for handling UI interactions and API communication
*/
let huntarrUI = {
// Current state
eventSources: {},
currentSection: 'home', // Default section
currentLogApp: 'all', // Default log app
currentHistoryApp: 'all', // Default history app
autoScroll: true,
configuredApps: {
sonarr: false,
radarr: false,
lidarr: false,
readarr: false, // Added readarr
whisparr: false // Added whisparr
},
originalSettings: {}, // Store the full original settings object
settingsChanged: false, // Flag to track unsaved settings changes
hasUnsavedChanges: false, // Global flag for unsaved changes across all apps
formChanged: {}, // Track unsaved changes per app
suppressUnsavedChangesCheck: false, // Flag to suppress unsaved changes dialog
// Logo URL
logoUrl: '/static/logo/256.png',
// Element references
elements: {},
// Initialize the application
init: function() {
// Apply any preloaded theme immediately to avoid flashing
const prefersDarkMode = localStorage.getItem('huntarr-dark-mode') === 'true';
if (prefersDarkMode) {
document.body.classList.add('dark-theme');
}
const resetButton = document.getElementById('reset-stats');
if (resetButton) {
resetButton.addEventListener('click', (e) => {
e.preventDefault();
this.resetMediaStats();
});
}
// Ensure logo is visible immediately
this.logoUrl = localStorage.getItem('huntarr-logo-url') || this.logoUrl;
this.cacheElements();
this.setupEventListeners();
this.loadUsername();
this.checkLocalAccessBypassStatus(); // Add this line
this.checkAppConnections();
this.loadMediaStats(); // Load media statistics
this.loadCurrentVersion(); // Load current version
this.loadLatestVersion(); // Load latest version from GitHub
this.loadGitHubStarCount(); // Load GitHub star count
// Preload stateful management info so it's ready when needed
this.loadStatefulInfo();
// Ensure logo is applied
if (typeof window.applyLogoToAllElements === 'function') {
window.applyLogoToAllElements();
}
// Initialize instance event handlers
this.setupInstanceEventHandlers();
// Add global event handler for unsaved changes
this.registerGlobalUnsavedChangesHandler();
// Set up the stateful reset button (call immediately and also after a delay)
this.setupStatefulResetButton();
// Also call it again after a delay in case settings are loaded dynamically
setTimeout(() => {
this.setupStatefulResetButton();
}, 1000);
},
// Cache DOM elements for better performance
cacheElements: function() {
// Navigation
this.elements.navItems = document.querySelectorAll('.nav-item');
this.elements.homeNav = document.getElementById('homeNav');
this.elements.logsNav = document.getElementById('logsNav');
this.elements.historyNav = document.getElementById('historyNav');
this.elements.settingsNav = document.getElementById('settingsNav');
this.elements.userNav = document.getElementById('userNav');
// Sections
this.elements.sections = document.querySelectorAll('.content-section');
this.elements.homeSection = document.getElementById('homeSection');
this.elements.logsSection = document.getElementById('logsSection');
this.elements.historySection = document.getElementById('historySection');
this.elements.settingsSection = document.getElementById('settingsSection');
// App tabs & Settings Tabs
this.elements.appTabs = document.querySelectorAll('.app-tab'); // For logs section
this.elements.logOptions = document.querySelectorAll('.log-option'); // New: replaced logTabs with logOptions
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
// History dropdown elements
this.elements.historyOptions = document.querySelectorAll('.history-option'); // History dropdown options
this.elements.currentHistoryApp = document.getElementById('current-history-app'); // Current history app text
this.elements.historyDropdownBtn = document.querySelector('.history-dropdown-btn'); // History dropdown button
this.elements.historyDropdownContent = document.querySelector('.history-dropdown-content'); // History dropdown content
this.elements.historyPlaceholderText = document.getElementById('history-placeholder-text'); // Placeholder text for history
// 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
this.elements.logsContainer = document.getElementById('logsContainer');
this.elements.autoScrollCheckbox = document.getElementById('autoScrollCheckbox');
this.elements.clearLogsButton = document.getElementById('clearLogsButton');
this.elements.logConnectionStatus = document.getElementById('logConnectionStatus');
// Settings
this.elements.saveSettingsButton = document.getElementById('saveSettingsButton'); // Corrected ID
// Status elements
this.elements.sonarrHomeStatus = document.getElementById('sonarrHomeStatus');
this.elements.radarrHomeStatus = document.getElementById('radarrHomeStatus');
this.elements.lidarrHomeStatus = document.getElementById('lidarrHomeStatus');
this.elements.readarrHomeStatus = document.getElementById('readarrHomeStatus'); // Added readarr
this.elements.whisparrHomeStatus = document.getElementById('whisparrHomeStatus'); // Added whisparr
// Actions
this.elements.startHuntButton = document.getElementById('startHuntButton');
this.elements.stopHuntButton = document.getElementById('stopHuntButton');
// Theme
// this.elements.themeToggle = document.getElementById('themeToggle'); // Removed theme toggle
// Logout
this.elements.logoutLink = document.getElementById('logoutLink'); // Added logout link
},
// Set up event listeners
setupEventListeners: function() {
// Navigation
this.elements.navItems.forEach(item => {
item.addEventListener('click', (e) => this.handleNavigation(e));
});
// Log auto-scroll setting
if (this.elements.autoScrollCheckbox) {
this.elements.autoScrollCheckbox.addEventListener('change', (e) => {
this.autoScroll = e.target.checked;
});
}
// Clear logs button
if (this.elements.clearLogsButton) {
this.elements.clearLogsButton.addEventListener('click', () => this.clearLogs());
}
// App tabs in logs section
this.elements.appTabs.forEach(tab => {
tab.addEventListener('click', (e) => this.handleAppTabChange(e));
});
// Log options dropdown
this.elements.logOptions.forEach(option => {
option.addEventListener('click', (e) => this.handleLogOptionChange(e));
});
// Log dropdown toggle
if (this.elements.logDropdownBtn) {
this.elements.logDropdownBtn.addEventListener('click', (e) => {
e.preventDefault();
this.elements.logDropdownContent.classList.toggle('show');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.log-dropdown') && this.elements.logDropdownContent.classList.contains('show')) {
this.elements.logDropdownContent.classList.remove('show');
}
});
}
// History dropdown toggle
if (this.elements.historyDropdownBtn) {
this.elements.historyDropdownBtn.addEventListener('click', (e) => {
e.preventDefault();
this.elements.historyDropdownContent.classList.toggle('show');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.history-dropdown') && this.elements.historyDropdownContent.classList.contains('show')) {
this.elements.historyDropdownContent.classList.remove('show');
}
});
}
// History options
this.elements.historyOptions.forEach(option => {
option.addEventListener('click', (e) => this.handleHistoryOptionChange(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
if (this.elements.saveSettingsButton) {
this.elements.saveSettingsButton.addEventListener('click', () => this.saveSettings());
}
// Start hunt button
if (this.elements.startHuntButton) {
this.elements.startHuntButton.addEventListener('click', () => this.startHunt());
}
// Stop hunt button
if (this.elements.stopHuntButton) {
this.elements.stopHuntButton.addEventListener('click', () => this.stopHunt());
}
// Logout button
if (this.elements.logoutLink) {
this.elements.logoutLink.addEventListener('click', (e) => this.logout(e));
}
// Dark mode toggle
const darkModeToggle = document.getElementById('darkModeToggle');
if (darkModeToggle) {
const prefersDarkMode = localStorage.getItem('huntarr-dark-mode') === 'true';
darkModeToggle.checked = prefersDarkMode;
darkModeToggle.addEventListener('change', function() {
const isDarkMode = this.checked;
document.body.classList.toggle('dark-theme', isDarkMode);
localStorage.setItem('huntarr-dark-mode', isDarkMode);
});
}
// Settings inputs change tracking
document.querySelectorAll('#settingsSection input, #settingsSection select').forEach(element => {
element.addEventListener('change', () => this.markSettingsAsChanged());
});
// Monitor for window beforeunload to warn about unsaved settings
window.addEventListener('beforeunload', (e) => {
if (this.settingsChanged && this.hasFormChanges(this.currentSettingsTab)) {
// Standard way to show a confirmation dialog when navigating away
e.preventDefault();
e.returnValue = ''; // Chrome requires returnValue to be set
return ''; // Legacy browsers
}
});
// Stateful management reset button
const resetStatefulBtn = document.getElementById('reset_stateful_btn');
if (resetStatefulBtn) {
resetStatefulBtn.addEventListener('click', () => this.handleStatefulReset());
}
// Stateful management hours input
const statefulHoursInput = document.getElementById('stateful_management_hours');
if (statefulHoursInput) {
statefulHoursInput.addEventListener('change', () => {
this.updateStatefulExpirationOnUI();
});
}
// Handle window hash change
window.addEventListener('hashchange', () => this.handleHashNavigation(window.location.hash)); // Ensure hash is passed
// Settings form delegation
const settingsFormContainer = document.querySelector('.settings-form');
if (settingsFormContainer) {
settingsFormContainer.addEventListener('input', (event) => {
if (event.target.closest('.app-settings-panel.active')) {
// Check if the target is an input, select, or textarea within the active panel
if (event.target.matches('input, select, textarea')) {
this.markSettingsAsChanged(); // Use the new function
}
}
});
settingsFormContainer.addEventListener('change', (event) => {
if (event.target.closest('.app-settings-panel.active')) {
// Handle changes for checkboxes and selects that use 'change' event
if (event.target.matches('input[type="checkbox"], select')) {
this.markSettingsAsChanged(); // Use the new function
}
}
});
}
// Add listener for unsaved changes prompt (External Navigation)
window.onbeforeunload = (event) => {
if (this.settingsChanged) {
// Standard way to trigger the browser's confirmation dialog
event.preventDefault();
// Chrome requires returnValue to be set
event.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
return 'You have unsaved changes. Are you sure you want to leave?'; // For older browsers
}
// If no changes, return undefined to allow navigation without prompt
return undefined;
};
// Initial setup based on hash or default to home
const initialHash = window.location.hash || '#home';
this.handleHashNavigation(initialHash);
},
// Setup logo handling to prevent flashing during navigation
setupLogoHandling: function() {
// Get the logo image
const logoImg = document.querySelector('.sidebar .logo');
if (logoImg) {
// Cache the source
this.logoSrc = logoImg.src;
// Ensure it's fully loaded
if (!logoImg.complete) {
logoImg.onload = () => {
// Once loaded, store the source
this.logoSrc = logoImg.src;
};
}
}
// Also add event listener to ensure logo is preserved during navigation
window.addEventListener('beforeunload', () => {
// Store logo src in session storage to persist across page loads
if (this.logoSrc) {
sessionStorage.setItem('huntarr-logo-src', this.logoSrc);
}
});
},
// Navigation handling
handleNavigation: function(e) {
const targetElement = e.currentTarget; // Get the clicked nav item
const href = targetElement.getAttribute('href');
const target = targetElement.getAttribute('target');
// Allow links with target="_blank" to open in a new window (return early)
if (target === '_blank') {
return; // Let the default click behavior happen
}
// For all other links, prevent default behavior and handle internally
e.preventDefault();
if (!href) return; // Exit if no href
let targetSection = null;
let isInternalLink = href.startsWith('#');
if (isInternalLink) {
targetSection = href.substring(1) || 'home'; // Get section from hash, default to 'home' if only '#'
} else {
// Handle external links (like /user) or non-hash links if needed
// For now, assume non-hash links navigate away
}
// Check for unsaved changes ONLY if navigating INTERNALLY away from settings
if (isInternalLink && this.currentSection === 'settings' && targetSection !== 'settings' && this.settingsChanged) {
// Use our new comparison function to check if there are actual changes
const hasRealChanges = this.hasFormChanges(this.currentSettingsTab);
if (hasRealChanges && !confirm('You have unsaved changes. Are you sure you want to leave? Changes will be lost.')) {
return; // Stop navigation if user cancels
}
// User confirmed or no real changes, reset flag before navigating
this.settingsChanged = false;
this.updateSaveResetButtonState(false);
}
// Add special handling for apps section - clear global app module flags
if (this.currentSection === 'apps' && targetSection !== 'apps') {
// Reset the app module flags when navigating away
if (window._appsModuleLoaded) {
window._appsSuppressChangeDetection = true;
if (window.appsModule && typeof window.appsModule.settingsChanged !== 'undefined') {
window.appsModule.settingsChanged = false;
}
// Schedule ending suppression to avoid any edge case issues
setTimeout(() => {
window._appsSuppressChangeDetection = false;
}, 1000);
}
}
// Proceed with navigation
if (isInternalLink) {
window.location.hash = href; // Change hash to trigger handleHashNavigation
} else {
// If it's an external link (like /user), just navigate normally
window.location.href = href;
}
},
handleHashNavigation: function(hash) {
const section = hash.substring(1) || 'home';
this.switchSection(section);
},
switchSection: function(section) {
// Update active section
this.elements.sections.forEach(s => {
s.classList.remove('active');
});
// Update navigation
this.elements.navItems.forEach(item => {
item.classList.remove('active');
});
// Show selected section
let newTitle = 'Home'; // Default title
const sponsorsSection = document.getElementById('sponsorsSection'); // Get sponsors section element
const sponsorsNav = document.getElementById('sponsorsNav'); // Get sponsors nav element
if (section === 'home' && this.elements.homeSection) {
this.elements.homeSection.classList.add('active');
if (this.elements.homeNav) this.elements.homeNav.classList.add('active');
newTitle = 'Home';
this.currentSection = 'home';
// Disconnect logs if switching away from logs
this.disconnectAllEventSources();
// Check app connections when returning to home page to update status
this.checkAppConnections();
// Also refresh media stats
this.loadMediaStats();
} else if (section === 'logs' && this.elements.logsSection) {
this.elements.logsSection.classList.add('active');
if (this.elements.logsNav) this.elements.logsNav.classList.add('active');
newTitle = 'Logs';
this.currentSection = 'logs';
this.connectToLogs();
} else if (section === 'history' && this.elements.historySection) {
this.elements.historySection.classList.add('active');
if (this.elements.historyNav) this.elements.historyNav.classList.add('active');
newTitle = 'History';
this.currentSection = 'history';
// Disconnect logs if switching away from logs
this.disconnectAllEventSources();
} else if (section === 'apps' && document.getElementById('appsSection')) {
document.getElementById('appsSection').classList.add('active');
if (document.getElementById('appsNav')) document.getElementById('appsNav').classList.add('active');
newTitle = 'Apps';
this.currentSection = 'apps';
// Disconnect logs if switching away from logs
this.disconnectAllEventSources();
// Load apps if the apps module exists
if (typeof appsModule !== 'undefined') {
appsModule.loadApps();
}
} else if (section === 'settings' && this.elements.settingsSection) {
this.elements.settingsSection.classList.add('active');
if (this.elements.settingsNav) this.elements.settingsNav.classList.add('active');
newTitle = 'Settings';
this.currentSection = 'settings';
// Ensure default settings tab is set if none is active
if (!this.currentSettingsTab) {
this.currentSettingsTab = 'general'; // Default to general tab
// Set the general tab as active
const generalTab = document.querySelector('.settings-tab[data-app="general"]');
if (generalTab) {
this.elements.settingsTabs.forEach(t => {
t.classList.remove('active');
});
generalTab.classList.add('active');
// Also set the general panel as visible
this.elements.appSettingsPanels.forEach(panel => {
panel.classList.remove('active');
panel.style.display = 'none';
});
const generalPanel = document.getElementById('generalSettings');
if (generalPanel) {
generalPanel.classList.add('active');
generalPanel.style.display = 'block';
}
}
}
// Load stateful info immediately, don't wait for loadAllSettings to complete
this.loadStatefulInfo();
// Load all settings after stateful info has started loading
this.loadAllSettings();
// Disconnect logs if switching away from logs
this.disconnectAllEventSources();
} else if (section === 'sponsors' && sponsorsSection) { // ADDED sponsors case
sponsorsSection.classList.add('active');
if (sponsorsNav) sponsorsNav.classList.add('active');
newTitle = 'Project Sponsors';
this.currentSection = 'sponsors';
// Set the iframe source when switching to this section
const sponsorsFrame = document.getElementById('sponsorsFrame');
if (sponsorsFrame && (!sponsorsFrame.src || sponsorsFrame.src === 'about:blank')) { // Set src only if not already set or blank
sponsorsFrame.src = 'https://github.com/sponsors/plexguide';
}
// Disconnect logs if switching away from logs
this.disconnectAllEventSources();
} else {
// Default to home if section is unknown or element missing
if (this.elements.homeSection) this.elements.homeSection.classList.add('active');
if (this.elements.homeNav) this.elements.homeNav.classList.add('active');
newTitle = 'Home';
this.currentSection = 'home';
// Disconnect logs if switching away from logs
this.disconnectAllEventSources();
}
// Update the page title
const pageTitleElement = document.getElementById('currentPageTitle');
if (pageTitleElement) {
pageTitleElement.textContent = newTitle;
} else {
console.warn("[huntarrUI] currentPageTitle element not found during section switch.");
}
},
// App tab switching
handleAppTabChange: function(e) {
const app = e.target.getAttribute('data-app');
if (!app) return;
// Update active tab
this.elements.appTabs.forEach(tab => {
tab.classList.remove('active');
});
e.target.classList.add('active');
// Switch to the selected app logs
this.currentApp = app;
this.connectToLogs();
},
// Log option dropdown handling
handleLogOptionChange: function(e) {
e.preventDefault(); // Prevent default anchor behavior
const app = e.target.getAttribute('data-app');
if (!app || app === this.currentLogApp) return; // Do nothing if same tab clicked
// Update active option
this.elements.logOptions.forEach(option => {
option.classList.remove('active');
});
e.target.classList.add('active');
// Update the current log app text with proper capitalization
let displayName = app.charAt(0).toUpperCase() + app.slice(1);
// Special handling for Whisparr V2
if (app === 'whisparr') {
displayName = 'Whisparr V2';
}
this.elements.currentLogApp.textContent = displayName;
// Close the dropdown
this.elements.logDropdownContent.classList.remove('show');
// Switch to the selected app logs
this.currentLogApp = app;
this.clearLogs(); // Clear existing logs before switching
this.connectToLogs(); // Reconnect to the new log source
},
// History option dropdown handling
handleHistoryOptionChange: function(e) {
e.preventDefault(); // Prevent default anchor behavior
const app = e.target.getAttribute('data-app');
if (!app || app === this.currentHistoryApp) return; // Do nothing if same tab clicked
// Update active option
this.elements.historyOptions.forEach(option => {
option.classList.remove('active');
});
e.target.classList.add('active');
// Update the current history app text with proper capitalization
let displayName = app.charAt(0).toUpperCase() + app.slice(1);
this.elements.currentHistoryApp.textContent = displayName;
// Close the dropdown
this.elements.historyDropdownContent.classList.remove('show');
// Switch to the selected app history
this.currentHistoryApp = app;
// this.clearHistory(); // Clear existing history before switching
// this.connectToHistory(); // Reconnect to the new history source
// Update the placeholder text based on the selected app
this.updateHistoryPlaceholder(app);
},
// Update the history placeholder text based on the selected app
updateHistoryPlaceholder: function(app) {
if (!this.elements.historyPlaceholderText) return;
let message = "";
if (app === 'all') {
message = "The History feature will be available in a future update. Stay tuned for enhancements that will allow you to view your media processing history.";
} else {
let displayName = this.capitalizeFirst(app);
message = `The ${displayName} History feature is under development and will be available in a future update. You'll be able to track your ${displayName} media processing history here.`;
}
this.elements.historyPlaceholderText.textContent = message;
},
// 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';
});
// Show the selected app's settings panel
const selectedPanel = document.getElementById(app + 'Settings');
if (selectedPanel) {
selectedPanel.classList.add('active');
selectedPanel.style.display = 'block';
}
this.currentSettingsTab = app;
console.log(`[huntarrUI] Switched settings tab to: ${this.currentSettingsTab}`); // Added logging
},
// Logs handling
connectToLogs: function() {
// Disconnect any existing event sources
this.disconnectAllEventSources();
// Connect to logs stream for the currentLogApp
this.connectEventSource(this.currentLogApp); // Pass the selected app
this.elements.logConnectionStatus.textContent = 'Connecting...';
this.elements.logConnectionStatus.className = '';
},
connectEventSource: function(appType) {
// Close any existing event source
if (this.eventSources.logs) {
this.eventSources.logs.close();
}
try {
// Append the app type to the URL
const eventSource = new EventSource(`/logs?app=${appType}`);
eventSource.onopen = () => {
this.elements.logConnectionStatus.textContent = 'Connected';
this.elements.logConnectionStatus.className = 'status-connected';
};
eventSource.onmessage = (event) => {
if (!this.elements.logsContainer) return;
try {
const logString = event.data;
// Regex to parse log lines: Optional [APP], Timestamp, Logger, Level, Message
// Example: [SONARR] 2024-01-01 12:00:00 - huntarr.sonarr - INFO - Message content
// Example: 2024-01-01 12:00:00 - huntarr - DEBUG - System message
const logRegex = /^(?:\\[(\\w+)\\]\\s)?([\\d\\-]+\\s[\\d:]+)\\s-\\s([\\w\\.]+)\\s-\\s(\\w+)\\s-\\s(.*)$/;
const match = logString.match(logRegex);
// First determine the app type for this log message
let logAppType = 'system'; // Default to system
if (match && match[1]) {
// If we have a match with app tag like [SONARR], use that
logAppType = match[1].toLowerCase();
} else if (match && match[3]) {
// Otherwise try to determine from the logger name (e.g., huntarr.sonarr)
const loggerParts = match[3].split('.');
if (loggerParts.length > 1) {
const possibleApp = loggerParts[1].toLowerCase();
if (['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'swaparr'].includes(possibleApp)) {
logAppType = possibleApp;
}
}
}
// Special case for system logs that may contain app-specific patterns
if (logAppType === 'system') {
// App-specific patterns that may appear in system logs
const patterns = {
'sonarr': ['episode', 'series', 'tv show', 'sonarr'],
'radarr': ['movie', 'film', 'radarr'],
'lidarr': ['album', 'artist', 'track', 'music', 'lidarr'],
'readarr': ['book', 'author', 'readarr'],
'whisparr': ['scene', 'adult', 'whisparr'],
'swaparr': ['added strike', 'max strikes reached', 'would have removed', 'strikes, removing download', 'processing stalled downloads', 'swaparr']
};
// Check each app's patterns
for (const [app, appPatterns] of Object.entries(patterns)) {
if (appPatterns.some(pattern => logString.toLowerCase().includes(pattern))) {
logAppType = app;
break;
}
}
}
// Determine if this log should be displayed based on the selected app tab
const shouldDisplay =
this.currentLogApp === 'all' ||
this.currentLogApp === logAppType;
if (!shouldDisplay) return;
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
if (match) {
const [, appName, timestamp, loggerName, level, message] = match;
logEntry.innerHTML = `
<span class="log-timestamp" title="${timestamp}">${timestamp.split(' ')[1]}</span>
${appName ? `<span class="log-app" title="Source: ${appName}">[${appName}]</span>` : ''}
<span class="log-level log-level-${level.toLowerCase()}" title="Level: ${level}">${level}</span>
<span class="log-logger" title="Logger: ${loggerName}">(${loggerName.replace('huntarr.', '')})</span>
<span class="log-message">${message}</span>
`;
logEntry.classList.add(`log-${level.toLowerCase()}`);
} else {
// Fallback for lines that don't match the expected format
logEntry.innerHTML = `<span class="log-message">${logString}</span>`;
// Basic level detection for fallback
if (logString.includes('ERROR')) logEntry.classList.add('log-error');
else if (logString.includes('WARN') || logString.includes('WARNING')) logEntry.classList.add('log-warning');
else if (logString.includes('DEBUG')) logEntry.classList.add('log-debug');
else logEntry.classList.add('log-info');
}
// Add to logs container
this.elements.logsContainer.appendChild(logEntry);
// Special event dispatching for Swaparr logs
if (logAppType === 'swaparr' && this.currentLogApp === 'swaparr') {
// Dispatch a custom event for swaparr.js to process
const swaparrEvent = new CustomEvent('swaparrLogReceived', {
detail: {
logData: match && match[5] ? match[5] : logString
}
});
document.dispatchEvent(swaparrEvent);
}
// Auto-scroll to bottom if enabled
if (this.autoScroll) {
this.elements.logsContainer.scrollTop = this.elements.logsContainer.scrollHeight;
}
} catch (error) {
console.error('[huntarrUI] Error processing log message:', error, 'Data:', event.data);
}
};
eventSource.onerror = (err) => {
console.error(`[huntarrUI] EventSource error for app ${this.currentLogApp}:`, err);
if (this.elements.logConnectionStatus) {
this.elements.logConnectionStatus.textContent = 'Error/Disconnected';
this.elements.logConnectionStatus.className = 'status-error'; // Use a specific error class
}
// Close the potentially broken connection
if (this.eventSources.logs) {
this.eventSources.logs.close();
console.log(`[huntarrUI] Closed potentially broken log EventSource for ${this.currentLogApp}.`);
}
// Attempt to reconnect after a delay, but only if still on the logs page
if (this.currentSection === 'logs') {
console.log(`[huntarrUI] Attempting to reconnect log stream for ${this.currentLogApp} in 5 seconds...`);
setTimeout(() => {
// Double-check if still on logs page before reconnecting
if (this.currentSection === 'logs') {
console.log(`[huntarrUI] Reconnecting log stream for ${this.currentLogApp}.`);
this.connectToLogs(); // Re-initiate connection
} else {
console.log(`[huntarrUI] Log reconnect cancelled; user navigated away from logs section.`);
}
}, 5000); // 5-second delay
}
}; // Added missing semicolon
this.eventSources.logs = eventSource; // Store the reference
} catch (e) {
console.error(`[huntarrUI] Failed to create EventSource for app ${appType}:`, e);
if (this.elements.logConnectionStatus) {
this.elements.logConnectionStatus.textContent = 'Failed to connect';
this.elements.logConnectionStatus.className = 'status-error';
}
}
},
disconnectAllEventSources: function() {
Object.keys(this.eventSources).forEach(key => {
const source = this.eventSources[key];
if (source) {
try {
if (source.readyState !== EventSource.CLOSED) {
source.close();
console.log(`[huntarrUI] Closed event source for ${key}.`);
} else {
console.log(`[huntarrUI] Event source for ${key} was already closed.`);
}
} catch (e) {
console.error(`[huntarrUI] Error closing event source for ${key}:`, e);
}
}
// Clear the reference
delete this.eventSources[key]; // Use delete
});
// Reset status indicator if logs aren't the active section
if (this.currentSection !== 'logs' && this.elements.logConnectionStatus) {
this.elements.logConnectionStatus.textContent = 'Disconnected';
this.elements.logConnectionStatus.className = 'status-disconnected';
}
},
clearLogs: function() {
if (this.elements.logsContainer) {
this.elements.logsContainer.innerHTML = '';
}
},
// Settings handling
loadAllSettings: function() {
// Disable save button until changes are made
this.updateSaveResetButtonState(false);
this.settingsChanged = false;
// Get all settings to populate forms
HuntarrUtils.fetchWithTimeout('/api/settings')
.then(response => response.json())
.then(data => {
console.log('Loaded settings:', data);
// Store original settings for comparison
this.originalSettings = data;
// Populate each app's settings form
if (data.sonarr) this.populateSettingsForm('sonarr', data.sonarr);
if (data.radarr) this.populateSettingsForm('radarr', data.radarr);
if (data.lidarr) this.populateSettingsForm('lidarr', data.lidarr);
if (data.readarr) this.populateSettingsForm('readarr', data.readarr);
if (data.whisparr) this.populateSettingsForm('whisparr', data.whisparr);
if (data.swaparr) this.populateSettingsForm('swaparr', data.swaparr);
if (data.general) this.populateSettingsForm('general', data.general);
// Update duration displays (like sleep durations)
if (typeof SettingsForms !== 'undefined' &&
typeof SettingsForms.updateDurationDisplay === 'function') {
SettingsForms.updateDurationDisplay();
}
// Load stateful info immediately, don't wait for loadAllSettings to complete
this.loadStatefulInfo();
})
.catch(error => {
console.error('Error loading settings:', error);
this.showNotification('Error loading settings. Please try again.', 'error');
});
},
populateSettingsForm: function(app, appSettings) {
// Cache the form for this app
const form = document.getElementById(`${app}Settings`);
if (!form) return;
// Check if SettingsForms is loaded to generate the form
if (typeof SettingsForms !== 'undefined') {
const formFunction = SettingsForms[`generate${app.charAt(0).toUpperCase()}${app.slice(1)}Form`];
if (typeof formFunction === 'function') {
formFunction(form, appSettings); // This function already calls setupInstanceManagement internally
// Update duration displays for this app
if (typeof SettingsForms.updateDurationDisplay === 'function') {
try {
SettingsForms.updateDurationDisplay();
} catch (e) {
console.error(`[huntarrUI] Error updating duration display:`, e);
}
}
} else {
console.error(`[huntarrUI] Form generator function not found for app: ${app}`);
}
} else {
console.error('[huntarrUI] SettingsForms is not defined');
return;
}
},
// Called when any setting input changes in the active tab
markSettingsAsChanged() {
if (!this.settingsChanged) {
console.log("[huntarrUI] Settings marked as changed.");
this.settingsChanged = true;
this.updateSaveResetButtonState(true); // Enable buttons
}
},
saveSettings: function() {
const app = this.currentSettingsTab;
console.log(`[huntarrUI] saveSettings called for app: ${app}`);
// Clear the unsaved changes flag BEFORE sending the request
// This prevents the "unsaved changes" dialog from appearing
this.settingsChanged = false;
this.updateSaveResetButtonState(false);
// Use getFormSettings for all apps, as it handles different structures
let settings = this.getFormSettings(app);
if (!settings) {
console.error(`[huntarrUI] Failed to collect settings for app: ${app}`);
this.showNotification('Error collecting settings from form.', 'error');
return;
}
console.log(`[huntarrUI] Collected settings for ${app}:`, settings);
// Check if this is general settings and if local_access_bypass is being changed
const isLocalAccessBypassChanged = app === 'general' &&
this.originalSettings &&
this.originalSettings.general &&
this.originalSettings.general.local_access_bypass !== settings.local_access_bypass;
console.log(`[huntarrUI] Local access bypass changed: ${isLocalAccessBypassChanged}`);
console.log(`[huntarrUI] Sending settings payload for ${app}:`, settings);
// Use the correct endpoint based on app type
const endpoint = app === 'general' ? '/api/settings/general' : `/api/settings/${app}`;
HuntarrUtils.fetchWithTimeout(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
})
.then(response => {
if (!response.ok) {
// Try to get error message from response body
return response.json().then(errData => {
throw new Error(errData.error || `HTTP error! status: ${response.status}`);
}).catch(() => {
// Fallback if response body is not JSON or empty
throw new Error(`HTTP error! status: ${response.status}`);
});
}
return response.json();
})
.then(savedConfig => {
console.log('[huntarrUI] Settings saved successfully. Full config received:', savedConfig);
// If local access bypass setting was changed, reload the page
if (isLocalAccessBypassChanged) {
this.showNotification('Settings saved successfully. Reloading page to apply authentication changes...', 'success');
setTimeout(() => {
window.location.href = '/'; // Redirect to home page after a brief delay
}, 1500);
return;
}
this.showNotification('Settings saved successfully', 'success');
// Update original settings state with the full config returned from backend
if (typeof savedConfig === 'object' && savedConfig !== null) {
this.originalSettings = JSON.parse(JSON.stringify(savedConfig));
} else {
console.error('[huntarrUI] Invalid config received from backend after save:', savedConfig);
this.loadAllSettings();
return;
}
// Re-populate the form with the saved data
const currentAppSettings = this.originalSettings[app] || {};
// Preserve instances data if missing in the response but was in our sent data
if (app === 'sonarr' && !currentAppSettings.instances && settings.instances) {
currentAppSettings.instances = settings.instances;
}
this.populateSettingsForm(app, currentAppSettings);
// Update connection status and UI
this.checkAppConnection(app);
this.updateHomeConnectionStatus();
// If general settings were saved, refresh the stateful info display
if (app === 'general') {
// Update the displayed interval hours if it's available in the settings
if (settings.stateful_management_hours && document.getElementById('stateful_management_hours')) {
const intervalInput = document.getElementById('stateful_management_hours');
const intervalDaysSpan = document.getElementById('stateful_management_days');
const expiresDateEl = document.getElementById('stateful_expires_date');
// Update the input value
intervalInput.value = settings.stateful_management_hours;
// Update the days display
if (intervalDaysSpan) {
const days = (settings.stateful_management_hours / 24).toFixed(1);
intervalDaysSpan.textContent = `${days} days`;
}
// Show updating indicator
if (expiresDateEl) {
expiresDateEl.textContent = 'Updating...';
}
// Also directly update the stateful expiration on the server and update UI
this.updateStatefulExpirationOnUI();
} else {
this.loadStatefulInfo();
}
}
})
.catch(error => {
console.error('Error saving settings:', error);
this.showNotification(`Error saving settings: ${error.message}`, 'error');
// If there was an error, mark settings as changed again
this.settingsChanged = true;
this.updateSaveResetButtonState(true);
});
},
// Add or modify this function to handle enabling/disabling save/reset
updateSaveResetButtonState(enable) { // Changed signature
const saveButton = this.elements.saveSettingsButton;
if (saveButton) {
saveButton.disabled = !enable;
// Optional: Add/remove class for styling
if (enable) {
saveButton.classList.remove('disabled-button');
} else {
saveButton.classList.add('disabled-button');
}
}
},
// Get settings from the form, updated to handle instances consistently
getFormSettings: function(app) {
const settings = {};
const form = document.getElementById(`${app}Settings`);
if (!form) {
console.error(`[huntarrUI] Settings form for ${app} not found.`);
return null;
}
// Special handling for Swaparr which has a different structure
if (app === 'swaparr') {
// Get all inputs directly without filtering for instance fields
const inputs = form.querySelectorAll('input, select');
inputs.forEach(input => {
// Extract the field name without the app prefix
let key = input.id;
if (key.startsWith(`${app}_`)) {
key = key.substring(app.length + 1);
}
// Store the value based on input type
if (input.type === 'checkbox') {
settings[key] = input.checked;
} else if (input.type === 'number') {
settings[key] = input.value === '' ? null : parseInt(input.value, 10);
} else {
settings[key] = input.value.trim();
}
});
console.log(`[huntarrUI] Collected Swaparr settings:`, settings);
return settings;
}
// Handle apps that use instances (Sonarr, Radarr, etc.)
// Get all instance items in the form
const instanceItems = form.querySelectorAll('.instance-item');
settings.instances = [];
// Check if multi-instance UI elements exist (like Sonarr)
if (instanceItems.length > 0) {
console.log(`[huntarrUI] Found ${instanceItems.length} instance items for ${app}. Processing multi-instance mode.`);
// Multi-instance logic (current Sonarr logic)
instanceItems.forEach((item, index) => {
const instanceId = item.dataset.instanceId; // Assumes Sonarr uses data-instance-id
const nameInput = form.querySelector(`#${app}_instance_${instanceId}_name`);
const urlInput = form.querySelector(`#${app}_instance_${instanceId}_api_url`);
const keyInput = form.querySelector(`#${app}_instance_${instanceId}_api_key`);
const enabledInput = item.querySelector('.instance-enabled'); // Assumes Sonarr uses this class for enable toggle
if (urlInput && keyInput) { // Need URL and Key at least
settings.instances.push({
// Use nameInput value if available, otherwise generate a default
name: nameInput && nameInput.value.trim() !== '' ? nameInput.value.trim() : `Instance ${index + 1}`,
api_url: urlInput.value.trim(),
api_key: keyInput.value.trim(),
// Default to true if toggle doesn't exist or is checked
enabled: enabledInput ? enabledInput.checked : true
});
}
});
} else {
console.log(`[huntarrUI] No instance items found for ${app}. Processing single-instance mode.`);
// Single-instance logic (for Radarr, Lidarr, etc.)
// Look for the standard IDs used in their forms
const nameInput = form.querySelector(`#${app}_instance_name`); // Check for a specific name field
const urlInput = form.querySelector(`#${app}_api_url`);
const keyInput = form.querySelector(`#${app}_api_key`);
// Assuming single instances might have an enable toggle like #app_enabled
const enabledInput = form.querySelector(`#${app}_enabled`);
// Only add if URL and Key have values
if (urlInput && urlInput.value.trim() && keyInput && keyInput.value.trim()) {
settings.instances.push({
name: nameInput && nameInput.value.trim() !== '' ? nameInput.value.trim() : `${app} Instance 1`, // Default name
api_url: urlInput.value.trim(),
api_key: keyInput.value.trim(),
// Default to true if toggle doesn't exist or is checked
enabled: enabledInput ? enabledInput.checked : true
});
}
}
console.log(`[huntarrUI] Processed instances for ${app}:`, settings.instances);
// Now collect any OTHER settings NOT part of the instance structure
const allInputs = form.querySelectorAll('input, select');
const handledInstanceFieldIds = new Set();
// Identify IDs used in instance collection to avoid double-adding them
if (instanceItems.length > 0) {
// Multi-instance: Iterate items again to get IDs
instanceItems.forEach((item) => {
const instanceId = item.dataset.instanceId;
if(instanceId) {
handledInstanceFieldIds.add(`${app}_instance_${instanceId}_name`);
handledInstanceFieldIds.add(`${app}_instance_${instanceId}_api_url`);
handledInstanceFieldIds.add(`${app}_instance_${instanceId}_api_key`);
const enabledToggle = item.querySelector('.instance-enabled');
if (enabledToggle && enabledToggle.id) handledInstanceFieldIds.add(enabledToggle.id);
}
});
} else {
// Single-instance: Check for standard IDs
if (form.querySelector(`#${app}_instance_name`)) handledInstanceFieldIds.add(`${app}_instance_name`);
if (form.querySelector(`#${app}_api_url`)) handledInstanceFieldIds.add(`${app}_api_url`);
if (form.querySelector(`#${app}_api_key`)) handledInstanceFieldIds.add(`${app}_api_key`);
if (form.querySelector(`#${app}_enabled`)) handledInstanceFieldIds.add(`${app}_enabled`);
}
allInputs.forEach(input => {
// Handle special case for Whisparr version
if (input.id === 'whisparr_version') {
if (app === 'whisparr') {
settings['whisparr_version'] = input.value.trim();
return; // Skip further processing for this field
}
}
// Skip buttons and fields already processed as part of an instance
if (input.type === 'button' || handledInstanceFieldIds.has(input.id)) {
return;
}
// Get the field key (remove app prefix)
let key = input.id;
if (key.startsWith(`${app}_`)) {
key = key.substring(app.length + 1);
}
// Skip empty keys or keys that are just numbers (unlikely but possible)
if (!key || /^\d+$/.test(key)) return;
// Store the value
if (input.type === 'checkbox') {
settings[key] = input.checked;
} else if (input.type === 'number') {
// Handle potential empty string for numbers, store as null or default?
settings[key] = input.value === '' ? null : parseInt(input.value, 10);
} else {
settings[key] = input.value.trim();
}
});
console.log(`[huntarrUI] Final collected settings for ${app}:`, settings);
return settings;
},
// Handle instance management events
setupInstanceEventHandlers: function() {
console.log("DEBUG: setupInstanceEventHandlers called"); // Added logging
const settingsPanels = document.querySelectorAll('.app-settings-panel');
settingsPanels.forEach(panel => {
console.log(`DEBUG: Adding listeners to panel '${panel.id}'`); // Added logging
panel.addEventListener('addInstance', (e) => {
console.log(`DEBUG: addInstance event listener fired for panel '${panel.id}'. Event detail:`, e.detail);
this.addAppInstance(e.detail.appName);
});
panel.addEventListener('removeInstance', (e) => {
this.removeAppInstance(e.detail.appName, e.detail.instanceId);
});
panel.addEventListener('testConnection', (e) => {
this.testInstanceConnection(e.detail.appName, e.detail.instanceId, e.detail.url, e.detail.apiKey);
});
});
},
// Add a new instance to the app
addAppInstance: function(appName) {
console.log(`DEBUG: addAppInstance called for app '${appName}'`);
const container = document.getElementById(`${appName}Settings`);
if (!container) return;
// Get current settings
const currentSettings = this.getFormSettings(appName);
if (!currentSettings.instances) {
currentSettings.instances = [];
}
// Limit to 9 instances
if (currentSettings.instances.length >= 9) {
this.showNotification('Maximum of 9 instances allowed', 'error');
return;
}
// Add new instance with a default name
currentSettings.instances.push({
name: `Instance ${currentSettings.instances.length + 1}`,
api_url: '',
api_key: '',
enabled: true
});
// Regenerate form with new instance
SettingsForms[`generate${appName.charAt(0).toUpperCase()}${appName.slice(1)}Form`](container, currentSettings);
// Update controls like duration displays
SettingsForms.updateDurationDisplay();
this.showNotification('New instance added', 'success');
},
// Remove an instance
removeAppInstance: function(appName, instanceId) {
const container = document.getElementById(`${appName}Settings`);
if (!container) return;
// Get current settings
const currentSettings = this.getFormSettings(appName);
// Remove the instance
if (currentSettings.instances && instanceId >= 0 && instanceId < currentSettings.instances.length) {
// Keep at least one instance
if (currentSettings.instances.length > 1) {
const removedName = currentSettings.instances[instanceId].name;
currentSettings.instances.splice(instanceId, 1);
// Regenerate form
SettingsForms[`generate${appName.charAt(0).toUpperCase()}${appName.slice(1)}Form`](container, currentSettings);
// Update controls like duration displays
SettingsForms.updateDurationDisplay();
this.showNotification(`Instance "${removedName}" removed`, 'info');
} else {
this.showNotification('Cannot remove the last instance', 'error');
}
}
},
// Test connection for a specific instance
testInstanceConnection: function(appName, instanceId, url, apiKey) {
console.log(`Testing connection for ${appName} instance ${instanceId} with URL: ${url}`);
// Make sure instanceId is treated as a number
instanceId = parseInt(instanceId, 10);
// Find the status span where we'll display the result
const statusSpan = document.getElementById(`${appName}_instance_${instanceId}_status`);
if (!statusSpan) {
console.error(`Status span not found for ${appName} instance ${instanceId}`);
return;
}
// Show testing status
statusSpan.textContent = 'Testing...';
statusSpan.className = 'connection-status testing';
// Validate URL and API key
if (!url || !apiKey) {
statusSpan.textContent = 'Missing URL or API key';
statusSpan.className = 'connection-status error';
return;
}
// Check if URL is properly formatted
if (!url.startsWith('http://') && !url.startsWith('https://')) {
statusSpan.textContent = 'URL must start with http:// or https://';
statusSpan.className = 'connection-status error';
return;
}
// Check for trailing slashes in URL
if (url.endsWith('/') || url.endsWith('\\')) {
statusSpan.textContent = 'Remove trailing slash from URL (/ or \\)';
statusSpan.className = 'status-error';
return;
}
// Clean up the URL by removing trailing slashes (redundant but kept for safety)
url = url.trim().replace(/[/\\]+$/, '');
// Make the API request to test the connection
HuntarrUtils.fetchWithTimeout(`/api/${appName}/test-connection`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_url: url,
api_key: apiKey
})
})
.then(response => {
if (!response.ok) {
return response.json().then(errorData => {
throw new Error(errorData.message || this.getConnectionErrorMessage(response.status));
}).catch(() => {
// Fallback if response body is not JSON or empty
throw new Error(this.getConnectionErrorMessage(response.status));
});
}
return response.json();
})
.then(data => {
console.log(`Connection test response data for ${appName} instance ${instanceId}:`, data);
if (data.success) {
statusSpan.textContent = data.message || 'Connected';
statusSpan.className = 'connection-status success';
// If a version was returned, display it
if (data.version) {
statusSpan.textContent += ` (v${data.version})`;
}
} else {
statusSpan.textContent = data.message || 'Failed';
statusSpan.className = 'connection-status error';
}
})
.catch(error => {
console.error(`Error testing connection for ${appName} instance ${instanceId}:`, error);
// Extract the most relevant part of the error message
let errorMessage = error.message || 'Unknown error';
if (errorMessage.includes('Name or service not known')) {
errorMessage = 'Unable to resolve hostname. Check the URL.';
} else if (errorMessage.includes('Connection refused')) {
errorMessage = 'Connection refused. Check that the service is running.';
} else if (errorMessage.includes('connect ETIMEDOUT') || errorMessage.includes('timeout')) {
errorMessage = 'Connection timed out. Check URL and port.';
} else if (errorMessage.includes('401') || errorMessage.includes('Authentication failed')) {
errorMessage = 'Invalid API key';
} else if (errorMessage.includes('404') || errorMessage.includes('not found')) {
errorMessage = 'URL endpoint not found. Check the URL.';
} else if (errorMessage.startsWith('HTTP error!')) {
errorMessage = 'Connection failed. Check URL and port.';
}
statusSpan.textContent = errorMessage;
statusSpan.className = 'connection-status error';
});
},
// Helper function to translate HTTP error codes to user-friendly messages
getConnectionErrorMessage: function(status) {
switch(status) {
case 400:
return 'Invalid request. Check URL format.';
case 401:
return 'Invalid API key';
case 403:
return 'Access forbidden. Check permissions.';
case 404:
return 'Service not found at this URL. Check address.';
case 500:
return 'Server error. Check if the service is working properly.';
case 502:
return 'Bad gateway. Check network connectivity.';
case 503:
return 'Service unavailable. Check if the service is running.';
case 504:
return 'Gateway timeout. Check network connectivity.';
default:
return `Connection error. Check URL and port.`;
}
},
// App connections
checkAppConnections: function() {
this.checkAppConnection('sonarr');
this.checkAppConnection('radarr');
this.checkAppConnection('lidarr');
this.checkAppConnection('readarr'); // Added readarr
this.checkAppConnection('whisparr'); // Added whisparr
},
checkAppConnection: function(app) {
HuntarrUtils.fetchWithTimeout(`/api/status/${app}`)
.then(response => response.json())
.then(data => {
// Pass the whole data object for all apps
this.updateConnectionStatus(app, data);
// Still update the configuredApps flag for potential other uses, but after updating status
this.configuredApps[app] = data.configured === true; // Ensure it's a boolean
})
.catch(error => {
console.error(`Error checking ${app} connection:`, error);
// Pass a default 'not configured' status object on error
this.updateConnectionStatus(app, { configured: false, connected: false });
});
},
updateConnectionStatus: function(app, statusData) {
const statusElement = this.elements[`${app}HomeStatus`];
if (!statusElement) return;
// Find the parent container for the whole app status box
const appBox = statusElement.closest('.app-stats-card'); // CORRECTED SELECTOR
if (!appBox) {
// If the card structure changes, this might fail. Log a warning.
console.warn(`[huntarrUI] Could not find parent '.app-stats-card' element for ${app}`);
}
let isConfigured = false;
let isConnected = false;
// Try to determine configured and connected status from statusData object
// Default to false if properties are missing
isConfigured = statusData?.configured === true;
isConnected = statusData?.connected === true;
// Special handling for *arr apps' multi-instance connected count
let connectedCount = statusData?.connected_count ?? 0;
let totalConfigured = statusData?.total_configured ?? 0;
// For all *arr apps, 'isConfigured' means at least one instance is configured
if (['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'].includes(app)) {
isConfigured = totalConfigured > 0;
// For *arr apps, 'isConnected' means at least one instance is connected
isConnected = isConfigured && connectedCount > 0;
}
// --- Visibility Logic ---
if (isConfigured) {
// Ensure the box is visible
if (appBox) appBox.style.display = '';
} else {
// Not configured - HIDE the box
if (appBox) appBox.style.display = 'none';
// Update badge even if hidden (optional, but good practice)
statusElement.className = 'status-badge not-configured';
statusElement.innerHTML = '<i class="fas fa-times-circle"></i> Not Configured';
return; // No need to update badge further if not configured
}
// --- Badge Update Logic (only runs if configured) ---
if (['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'].includes(app)) {
// *Arr specific badge text (already checked isConfigured)
statusElement.innerHTML = `<i class="fas fa-plug"></i> Connected ${connectedCount}/${totalConfigured}`;
statusElement.className = 'status-badge ' + (isConnected ? 'connected' : 'error');
} else {
// Standard badge update for other configured apps
if (isConnected) {
statusElement.className = 'status-badge connected';
statusElement.innerHTML = '<i class="fas fa-check-circle"></i> Connected';
} else {
statusElement.className = 'status-badge not-connected';
statusElement.innerHTML = '<i class="fas fa-times-circle"></i> Not Connected';
}
}
},
// User actions
startHunt: function() {
HuntarrUtils.fetchWithTimeout('/api/hunt/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
this.showNotification('Hunt started successfully', 'success');
} else {
this.showNotification('Failed to start hunt', 'error');
}
})
.catch(error => {
console.error('Error starting hunt:', error);
this.showNotification('Error starting hunt', 'error');
});
},
stopHunt: function() {
HuntarrUtils.fetchWithTimeout('/api/hunt/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
this.showNotification('Hunt stopped successfully', 'success');
} else {
this.showNotification('Failed to stop hunt', 'error');
}
})
.catch(error => {
console.error('Error stopping hunt:', error);
this.showNotification('Error stopping hunt', 'error');
});
},
// User
loadUsername: function() {
const usernameElement = document.getElementById('username');
if (!usernameElement) return;
HuntarrUtils.fetchWithTimeout('/api/user/info')
.then(response => response.json())
.then(data => {
if (data.username) {
usernameElement.textContent = data.username;
}
// Check if local access bypass is enabled and update UI visibility
this.checkLocalAccessBypassStatus();
})
.catch(error => {
console.error('Error loading username:', error);
// Still check local access bypass status even if username loading failed
this.checkLocalAccessBypassStatus();
});
},
// Check if local access bypass is enabled and update UI accordingly
checkLocalAccessBypassStatus: function() {
console.log("Checking local access bypass status...");
HuntarrUtils.fetchWithTimeout('/api/get_local_access_bypass_status') // Corrected URL
.then(response => {
if (!response.ok) {
// Log error if response is not OK (e.g., 404, 500)
console.error(`Error fetching bypass status: ${response.status} ${response.statusText}`);
// Attempt to read response body for more details, if available
response.text().then(text => console.error('Response body:', text));
// Throw an error to trigger the catch block with a clearer message
throw new Error(`HTTP error ${response.status}`);
}
return response.json(); // Only parse JSON if response is OK
})
.then(data => {
if (data && typeof data.isEnabled === 'boolean') {
console.log("Local access bypass status received:", data.isEnabled);
this.updateUIForLocalAccessBypass(data.isEnabled);
} else {
// Handle cases where response is JSON but not the expected format
console.error('Invalid data format received for bypass status:', data);
this.updateUIForLocalAccessBypass(false); // Default to disabled/showing elements
}
})
.catch(error => {
// Catch network errors and the error thrown from !response.ok
console.error('Error checking local access bypass status:', error);
// Default to showing elements if we can't determine status
this.updateUIForLocalAccessBypass(false);
});
},
// Update UI elements visibility based on local access bypass status
updateUIForLocalAccessBypass: function(isEnabled) {
console.log("Updating UI for local access bypass:", isEnabled);
// Get the user info container in topbar (username and logout button)
const userInfoContainer = document.getElementById('userInfoContainer');
// Get the user nav item in sidebar
const userNav = document.getElementById('userNav');
// Set display style explicitly based on local access bypass setting
if (isEnabled === true) {
console.log("Local access bypass is ENABLED - hiding user elements");
// Hide user info in topbar
if (userInfoContainer) {
userInfoContainer.style.display = 'none';
console.log(" • Hidden userInfoContainer");
} else {
console.warn(" ⚠ userInfoContainer not found");
}
// Hide user nav in sidebar
if (userNav) {
userNav.style.display = 'none';
console.log(" • Hidden userNav");
} else {
console.warn(" ⚠ userNav not found");
}
} else {
console.log("Local access bypass is DISABLED - showing user elements");
// Show user info in topbar
if (userInfoContainer) {
userInfoContainer.style.display = '';
console.log(" • Showing userInfoContainer");
} else {
console.warn(" ⚠ userInfoContainer not found");
}
// Show user nav in sidebar
if (userNav) {
userNav.style.display = '';
console.log(" • Showing userNav");
} else {
console.warn(" ⚠ userNav not found");
}
}
},
logout: function(e) { // Added logout function
e.preventDefault(); // Prevent default link behavior
console.log('[huntarrUI] Logging out...');
HuntarrUtils.fetchWithTimeout('/logout', { // Use the correct endpoint defined in Flask
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('[huntarrUI] Logout successful, redirecting to login.');
window.location.href = '/login'; // Redirect to login page
} else {
console.error('[huntarrUI] Logout failed:', data.message);
this.showNotification('Logout failed. Please try again.', 'error');
}
})
.catch(error => {
console.error('Error during logout:', error);
this.showNotification('An error occurred during logout.', 'error');
});
},
// Media statistics handling
loadMediaStats: function() {
HuntarrUtils.fetchWithTimeout('/api/stats')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data.success && data.stats) {
this.updateStatsDisplay(data.stats);
} else {
console.error('Failed to load statistics:', data.message || 'Unknown error');
}
})
.catch(error => {
console.error('Error fetching statistics:', error);
});
},
updateStatsDisplay: function(stats) {
// Update each app's statistics
const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'];
const statTypes = ['hunted', 'upgraded'];
apps.forEach(app => {
if (stats[app]) {
statTypes.forEach(type => {
const element = document.getElementById(`${app}-${type}`);
if (element) {
// Animate the number change
this.animateNumber(element, parseInt(element.textContent), stats[app][type] || 0);
}
});
}
});
},
animateNumber: function(element, start, end) {
const duration = 1000; // Animation duration in milliseconds
const startTime = performance.now();
const updateNumber = (currentTime) => {
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
// Easing function for smooth animation
const easeOutQuad = progress * (2 - progress);
const currentValue = Math.floor(start + (end - start) * easeOutQuad);
element.textContent = currentValue;
if (progress < 1) {
requestAnimationFrame(updateNumber);
} else {
element.textContent = end; // Ensure we end with the exact target number
}
};
requestAnimationFrame(updateNumber);
},
resetMediaStats: function(appType = null) {
// Directly update the UI first to provide immediate feedback
const stats = {
'sonarr': {'hunted': 0, 'upgraded': 0},
'radarr': {'hunted': 0, 'upgraded': 0},
'lidarr': {'hunted': 0, 'upgraded': 0},
'readarr': {'hunted': 0, 'upgraded': 0},
'whisparr': {'hunted': 0, 'upgraded': 0}
};
// Immediately update UI before even showing the confirmation
if (appType) {
// Only reset the specific app's stats
this.updateStatsDisplay({
[appType]: stats[appType]
});
} else {
// Reset all stats
this.updateStatsDisplay(stats);
}
// Show a success notification
this.showNotification('Statistics reset successfully', 'success');
// Try to send the reset to the server, but don't depend on it
try {
const requestBody = appType ? { app_type: appType } : {};
HuntarrUtils.fetchWithTimeout('/api/stats/reset_public', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => {
// Just log the response, don't rely on it for UI feedback
if (!response.ok) {
console.warn('Server responded with non-OK status for stats reset');
}
return response.json().catch(() => ({}));
})
.then(data => {
console.log('Stats reset response:', data);
})
.catch(error => {
console.warn('Error communicating with server for stats reset:', error);
});
} catch (error) {
console.warn('Error in stats reset:', error);
}
},
// Utility functions
showNotification: function(message, type) {
// Create a notification element
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
// Add to the document
document.body.appendChild(notification);
// Ensure any existing notification is removed first to prevent stacking
const existingNotifications = document.querySelectorAll('.notification');
existingNotifications.forEach(n => {
if (n !== notification) {
n.classList.remove('show');
setTimeout(() => n.remove(), 300);
}
});
// Fade in
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Remove after a delay
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
notification.remove();
}, 300);
}, 3000);
},
capitalizeFirst: function(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
// Load current version from version.txt
loadCurrentVersion: function() {
HuntarrUtils.fetchWithTimeout('/version.txt')
.then(response => {
if (!response.ok) {
throw new Error('Failed to load version.txt');
}
return response.text();
})
.then(version => {
const versionElement = document.getElementById('version-value');
if (versionElement) {
versionElement.textContent = version.trim();
}
})
.catch(error => {
console.error('Error loading current version:', error);
const versionElement = document.getElementById('version-value');
if (versionElement) {
versionElement.textContent = 'Error';
}
});
},
// Load latest version from GitHub releases
loadLatestVersion: function() {
HuntarrUtils.fetchWithTimeout('https://api.github.com/repos/plexguide/Huntarr.io/releases/latest')
.then(response => {
if (!response.ok) {
// Handle rate limiting or other errors
if (response.status === 403) {
console.warn('GitHub API rate limit likely exceeded.');
throw new Error('Rate limited');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
const latestVersionElement = document.getElementById('latest-version-value');
if (latestVersionElement && data && data.tag_name) {
// Remove potential 'v' prefix for consistency if needed, or keep it
latestVersionElement.textContent = data.tag_name;
} else if (latestVersionElement) {
latestVersionElement.textContent = 'N/A';
}
})
.catch(error => {
console.error('Error loading latest version from GitHub:', error);
const latestVersionElement = document.getElementById('latest-version-value');
if (latestVersionElement) {
latestVersionElement.textContent = error.message === 'Rate limited' ? 'Rate Limited' : 'Error';
}
});
},
// Load GitHub star count
loadGitHubStarCount: function() {
const starsElement = document.getElementById('github-stars-value');
if (!starsElement) return;
starsElement.textContent = 'Loading...';
// GitHub API endpoint for repository information
const apiUrl = 'https://api.github.com/repos/plexguide/huntarr';
HuntarrUtils.fetchWithTimeout(apiUrl)
.then(response => {
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data && data.stargazers_count !== undefined) {
// Format the number with commas for thousands
const formattedStars = data.stargazers_count.toLocaleString();
starsElement.textContent = formattedStars;
// Store in localStorage to avoid excessive API requests
const cacheData = {
stars: data.stargazers_count,
timestamp: Date.now()
};
localStorage.setItem('huntarr-github-stars', JSON.stringify(cacheData));
} else {
throw new Error('Star count not found in response');
}
})
.catch(error => {
console.error('Error fetching GitHub stars:', error);
// Try to load from cache if we have it
const cachedData = localStorage.getItem('huntarr-github-stars');
if (cachedData) {
try {
const parsed = JSON.parse(cachedData);
starsElement.textContent = parsed.stars.toLocaleString();
} catch (e) {
starsElement.textContent = 'N/A';
}
} else {
starsElement.textContent = 'N/A';
}
});
},
// Add or modify this function to handle enabling/disabling save/reset
updateSaveResetButtonState(enable) { // Changed signature
const saveButton = this.elements.saveSettingsButton;
if (saveButton) {
saveButton.disabled = !enable;
// Optional: Add/remove class for styling
if (enable) {
saveButton.classList.remove('disabled-button');
} else {
saveButton.classList.add('disabled-button');
}
}
},
// Add updateHomeConnectionStatus if it doesn't exist or needs adjustment
updateHomeConnectionStatus: function() {
console.log('[huntarrUI] Updating home connection statuses...');
// This function should ideally call checkAppConnection for all relevant apps
// or use the stored configuredApps status if checkAppConnection updates it.
this.checkAppConnections(); // Re-check all connections after a save might be simplest
},
// Load stateful management info
loadStatefulInfo: function(attempts = 0, skipCache = false) {
const initialStateEl = document.getElementById('stateful_initial_state');
const expiresDateEl = document.getElementById('stateful_expires_date');
const intervalInput = document.getElementById('stateful_management_hours');
const intervalDaysSpan = document.getElementById('stateful_management_days');
// Max retry attempts - increased for better reliability
const maxAttempts = 5;
console.log(`[StatefulInfo] Loading stateful info (attempt ${attempts + 1}, skipCache: ${skipCache})`);
// Update UI to show loading state instead of N/A on first attempt
if (attempts === 0) {
if (initialStateEl && initialStateEl.textContent !== 'Loading...') initialStateEl.textContent = 'Loading...';
if (expiresDateEl && expiresDateEl.textContent !== 'Updating...') expiresDateEl.textContent = 'Loading...';
}
// First check if we have cached data in localStorage that we can use immediately
const cachedStatefulData = localStorage.getItem('huntarr-stateful-data');
if (!skipCache && cachedStatefulData && attempts === 0) {
try {
const parsedData = JSON.parse(cachedStatefulData);
const cacheAge = Date.now() - parsedData.timestamp;
// Use cache if it's less than 5 minutes old while waiting for fresh data
if (cacheAge < 300000) {
console.log('[StatefulInfo] Using cached data while fetching fresh data');
// Display cached data
if (initialStateEl && parsedData.created_at_ts) {
const createdDate = new Date(parsedData.created_at_ts * 1000);
initialStateEl.textContent = this.formatDateNicely(createdDate);
}
if (expiresDateEl && parsedData.expires_at_ts) {
const expiresDate = new Date(parsedData.expires_at_ts * 1000);
expiresDateEl.textContent = this.formatDateNicely(expiresDate);
}
// Update interval input and days display
if (intervalInput && parsedData.interval_hours) {
intervalInput.value = parsedData.interval_hours;
if (intervalDaysSpan) {
const days = (parsedData.interval_hours / 24).toFixed(1);
intervalDaysSpan.textContent = `${days} days`;
}
}
}
} catch (e) {
console.warn('[StatefulInfo] Error parsing cached data:', e);
}
}
// Always fetch fresh data from the server
HuntarrUtils.fetchWithTimeout('/api/stateful/info', {
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}`);
}
return response.json();
})
.then(data => {
if (data.success) {
// Cache the response with a timestamp for future use
localStorage.setItem('huntarr-stateful-data', JSON.stringify({
...data,
timestamp: Date.now()
}));
// Handle initial state date
if (initialStateEl) {
if (data.created_at_ts) {
const createdDate = new Date(data.created_at_ts * 1000);
initialStateEl.textContent = this.formatDateNicely(createdDate);
} else {
initialStateEl.textContent = 'Not yet created';
// If this is the first state load attempt and no timestamp exists,
// it might be because the state file hasn't been created yet
if (attempts < maxAttempts) {
console.log(`[StatefulInfo] No initial state timestamp, will retry (${attempts + 1}/${maxAttempts})`);
setTimeout(() => {
this.loadStatefulInfo(attempts + 1);
}, 500); // Longer delay for better chance of success
return;
}
}
}
// Handle expiration date
if (expiresDateEl) {
if (data.expires_at_ts) {
const expiresDate = new Date(data.expires_at_ts * 1000);
expiresDateEl.textContent = this.formatDateNicely(expiresDate);
} else {
expiresDateEl.textContent = 'Not set';
}
}
// Update interval input and days display
if (intervalInput && data.interval_hours) {
intervalInput.value = data.interval_hours;
if (intervalDaysSpan) {
const days = (data.interval_hours / 24).toFixed(1);
intervalDaysSpan.textContent = `${days} days`;
}
}
// Hide error notification if it was visible
const notification = document.getElementById('stateful-notification');
if (notification) {
notification.style.display = 'none';
}
// Store the data for future reference
this._cachedStatefulData = data;
console.log('[StatefulInfo] Successfully loaded and displayed stateful data');
} else {
throw new Error(data.message || 'Failed to load stateful info');
}
})
.catch(error => {
console.error(`Error loading stateful info (attempt ${attempts + 1}/${maxAttempts + 1}):`, error);
// Retry if we haven't reached max attempts with exponential backoff
if (attempts < maxAttempts) {
const delay = Math.min(2000, 300 * Math.pow(2, attempts)); // Exponential backoff with max 2000ms
console.log(`[StatefulInfo] Retrying in ${delay}ms (attempt ${attempts + 1}/${maxAttempts})`);
setTimeout(() => {
// Double-check if still on the same page before retrying
if (document.getElementById('stateful_management_hours')) {
this.loadStatefulInfo(attempts + 1);
} else {
console.log(`[StatefulInfo] Stateful info retry cancelled; user navigated away.`);
}
}, delay);
return;
}
// Use cached data as fallback if available
const cachedStatefulData = localStorage.getItem('huntarr-stateful-data');
if (cachedStatefulData) {
try {
console.log('[StatefulInfo] Using cached data as fallback after failed fetch');
const parsedData = JSON.parse(cachedStatefulData);
if (initialStateEl && parsedData.created_at_ts) {
const createdDate = new Date(parsedData.created_at_ts * 1000);
initialStateEl.textContent = this.formatDateNicely(createdDate) + ' (cached)';
} else if (initialStateEl) {
initialStateEl.textContent = 'Not available';
}
if (expiresDateEl && parsedData.expires_at_ts) {
const expiresDate = new Date(parsedData.expires_at_ts * 1000);
expiresDateEl.textContent = this.formatDateNicely(expiresDate) + ' (cached)';
} else if (expiresDateEl) {
expiresDateEl.textContent = 'Not available';
}
// Update interval input and days display from cache
if (intervalInput && parsedData.interval_hours) {
intervalInput.value = parsedData.interval_hours;
if (intervalDaysSpan) {
const days = (parsedData.interval_hours / 24).toFixed(1);
intervalDaysSpan.textContent = `${days} days`;
}
}
return;
} catch (e) {
console.warn('[StatefulInfo] Error parsing cached data as fallback:', e);
}
}
// Final fallback if no cached data
if (initialStateEl) initialStateEl.textContent = 'Not available';
if (expiresDateEl) expiresDateEl.textContent = 'Not available';
// Show error notification
const notification = document.getElementById('stateful-notification');
if (notification) {
notification.style.display = 'block';
notification.textContent = 'Could not load stateful management info. This may affect media tracking.';
}
});
},
// Format date nicely with time, day, and relative time indication
formatDateNicely: function(date) {
if (!(date instanceof Date) || isNaN(date)) {
return 'Invalid date';
}
const options = {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
};
const formattedDate = date.toLocaleDateString(undefined, options);
// Add relative time indicator (e.g., "in 6 days" or "7 days ago")
const now = new Date();
const diffTime = date.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
let relativeTime = '';
if (diffDays > 0) {
relativeTime = ` (in ${diffDays} day${diffDays !== 1 ? 's' : ''})`;
} else if (diffDays < 0) {
relativeTime = ` (${Math.abs(diffDays)} day${Math.abs(diffDays) !== 1 ? 's' : ''} ago)`;
} else {
relativeTime = ' (today)';
}
return `${formattedDate}${relativeTime}`;
},
// Reset stateful management - clear all processed IDs
resetStatefulManagement: function() {
console.log("Reset stateful management function called");
// Show a loading indicator or disable the button
const resetBtn = document.getElementById('reset_stateful_btn');
if (resetBtn) {
resetBtn.disabled = true;
const originalText = resetBtn.innerHTML;
resetBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Resetting...';
console.log("Reset button found and disabled:", resetBtn);
} else {
console.error("Reset button not found in the DOM!");
}
// Add debug logging
console.log("Sending reset request to /api/stateful/reset");
HuntarrUtils.fetchWithTimeout('/api/stateful/reset', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
},
cache: 'no-cache' // Add cache control to prevent caching
})
.then(response => {
console.log("Reset response received:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log("Reset response data:", data);
if (data.success) {
this.showNotification('Stateful management reset successfully', 'success');
// Wait a moment before reloading the info to ensure it's refreshed
setTimeout(() => {
this.loadStatefulInfo(0); // Reload stateful info with fresh attempt
// Re-enable the button
if (resetBtn) {
resetBtn.disabled = false;
resetBtn.innerHTML = '<i class="fas fa-trash"></i> Reset';
}
}, 1000);
} else {
throw new Error(data.message || 'Unknown error resetting stateful management');
}
})
.catch(error => {
console.error("Error resetting stateful management:", error);
this.showNotification(`Error resetting stateful management: ${error.message}`, 'error');
// Re-enable the button
if (resetBtn) {
resetBtn.disabled = false;
resetBtn.innerHTML = '<i class="fas fa-trash"></i> Reset';
}
});
},
// Update stateful management expiration based on hours input
updateStatefulExpirationOnUI: function() {
const hoursInput = document.getElementById('stateful_management_hours');
if (!hoursInput) return;
const hours = parseInt(hoursInput.value) || 168;
// Show updating indicator
const expiresDateEl = document.getElementById('stateful_expires_date');
const initialStateEl = document.getElementById('stateful_initial_state');
if (expiresDateEl) {
expiresDateEl.textContent = 'Updating...';
}
HuntarrUtils.fetchWithTimeout('/api/stateful/update-expiration', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ hours: hours }),
cache: 'no-cache'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}`);
}
return response.json();
})
.then(data => {
if (data.success) {
console.log('[huntarrUI] Stateful expiration updated successfully:', data);
// Get updated info to show proper dates
this.loadStatefulInfo(0, true);
// Show a notification
this.showNotification(`Updated expiration to ${hours} hours (${(hours/24).toFixed(1)} days)`, 'success');
} else {
throw new Error(data.message || 'Unknown error updating expiration');
}
})
.catch(error => {
console.error('Error updating stateful expiration:', error);
this.showNotification(`Failed to update expiration: ${error.message}`, 'error');
// Reset the UI
if (expiresDateEl) {
expiresDateEl.textContent = 'Error updating';
}
// Try to reload original data
setTimeout(() => this.loadStatefulInfo(), 1000);
});
},
// Add the updateStatefulExpiration method
updateStatefulExpiration: function(hours) {
if (!hours || typeof hours !== 'number' || hours <= 0) {
console.error('[huntarrUI] Invalid hours value for updateStatefulExpiration:', hours);
return;
}
console.log(`[huntarrUI] Directly updating stateful expiration to ${hours} hours`);
// Make a direct API call to update the stateful expiration
HuntarrUtils.fetchWithTimeout('/api/stateful/update-expiration', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ hours: hours })
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('[huntarrUI] Stateful expiration updated successfully:', data);
// Update the expiration date display
const expiresDateEl = document.getElementById('stateful_expires_date');
if (expiresDateEl && data.expires_date) {
expiresDateEl.textContent = data.expires_date;
}
})
.catch(error => {
console.error('[huntarrUI] Error updating stateful expiration:', error);
});
},
// Add global event handler and method to track saved settings across all apps
registerGlobalUnsavedChangesHandler: function() {
window.addEventListener('beforeunload', this.handleUnsavedChangesBeforeUnload.bind(this));
// Reset hasUnsavedChanges when settings are saved
document.addEventListener('settings:saved', (event) => {
if (event.detail && event.detail.appType) {
console.log(`settings:saved event received for ${event.detail.appType}`);
if (this.formChanged) {
this.formChanged[event.detail.appType] = false;
}
// Also clear the change tracking in the appsModule if it exists
if (window.appsModule) {
// Reset the app in the tracking array
if (window.appsModule.appsWithChanges &&
window.appsModule.appsWithChanges.includes(event.detail.appType)) {
window.appsModule.appsWithChanges =
window.appsModule.appsWithChanges.filter(app => app !== event.detail.appType);
}
// Only update the overall flag if there are no apps with changes left
if (!window.appsModule.appsWithChanges || window.appsModule.appsWithChanges.length === 0) {
window.appsModule.settingsChanged = false;
}
}
// Check if there are any remaining form changes
this.checkForRemainingChanges();
}
});
},
// New method to check if any forms still have changes
checkForRemainingChanges: function() {
if (!this.formChanged) return;
// Check if any forms still have changes
const hasAnyChanges = Object.values(this.formChanged).some(val => val === true);
console.log('Checking for remaining form changes:', {
formChanged: this.formChanged,
hasAnyChanges: hasAnyChanges
});
// Update the global flag
this.hasUnsavedChanges = hasAnyChanges;
},
// Handle unsaved changes before unload
handleUnsavedChangesBeforeUnload: function(event) {
// Check if we should suppress the check (used for test connection functionality)
if (this.suppressUnsavedChangesCheck || window._suppressUnsavedChangesDialog) {
console.log('Unsaved changes check suppressed');
return;
}
// If we have unsaved changes, show confirmation dialog
if (this.hasUnsavedChanges) {
console.log('Preventing navigation due to unsaved changes');
event.preventDefault();
event.returnValue = 'You have unsaved changes. Do you want to continue without saving?';
return event.returnValue;
}
},
// Add a proper hasFormChanges function to compare form values with original values
hasFormChanges: function(app) {
if (!app || !this.originalSettings || !this.originalSettings[app]) return false;
const form = document.getElementById(`${app}Settings`);
if (!form) return false;
const currentSettings = this.getFormSettings(app);
if (!currentSettings) return false;
// Deep comparison of current settings with original settings
// For complex objects like instances, we need to stringify them for comparison
const originalJSON = JSON.stringify(this.originalSettings[app]);
const currentJSON = JSON.stringify(currentSettings);
return originalJSON !== currentJSON;
},
};
// Initialize when document is ready
document.addEventListener('DOMContentLoaded', () => {
huntarrUI.init();
});
// Expose huntarrUI to the global scope for access by app modules
window.huntarrUI = huntarrUI;