diff --git a/frontend/static/css/new-style.css b/frontend/static/css/new-style.css
index 759c3893..bac5e53d 100644
--- a/frontend/static/css/new-style.css
+++ b/frontend/static/css/new-style.css
@@ -1065,3 +1065,101 @@ input:checked + .slider:before {
.connection-status.testing {
color: var(--accent-color);
}
+
+/* Swaparr specific styles */
+.swaparr-panel {
+ margin-bottom: 15px;
+ border-radius: 8px;
+ background-color: var(--bg-secondary);
+ border-left: 4px solid var(--accent-color);
+}
+
+.swaparr-config {
+ padding: 12px;
+}
+
+.swaparr-config h3 {
+ margin-top: 0;
+ margin-bottom: 10px;
+ color: var(--accent-color);
+}
+
+.swaparr-config-content {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.swaparr-config-content span {
+ background-color: var(--bg-tertiary);
+ padding: 5px 10px;
+ border-radius: 4px;
+ font-family: monospace;
+}
+
+.swaparr-table {
+ width: 100%;
+ overflow-x: auto;
+ margin-bottom: 15px;
+}
+
+.swaparr-table table {
+ width: 100%;
+ border-collapse: collapse;
+ background-color: var(--bg-secondary);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.swaparr-table th {
+ background-color: var(--bg-tertiary);
+ padding: 10px;
+ text-align: left;
+ color: var(--text-secondary);
+}
+
+.swaparr-table td {
+ padding: 10px;
+ border-bottom: 1px solid var(--bg-tertiary);
+}
+
+.swaparr-status-striked {
+ background-color: rgba(255, 193, 7, 0.1);
+}
+
+.swaparr-status-pending {
+ background-color: rgba(13, 110, 253, 0.1);
+}
+
+.swaparr-status-ignored {
+ background-color: rgba(108, 117, 125, 0.1);
+}
+
+.swaparr-status-normal {
+ background-color: rgba(25, 135, 84, 0.1);
+}
+
+.swaparr-status-removed {
+ background-color: rgba(220, 53, 69, 0.1);
+}
+
+/* When in dark mode */
+.dark-theme .swaparr-status-striked {
+ background-color: rgba(255, 193, 7, 0.2);
+}
+
+.dark-theme .swaparr-status-pending {
+ background-color: rgba(13, 110, 253, 0.2);
+}
+
+.dark-theme .swaparr-status-ignored {
+ background-color: rgba(108, 117, 125, 0.2);
+}
+
+.dark-theme .swaparr-status-normal {
+ background-color: rgba(25, 135, 84, 0.2);
+}
+
+.dark-theme .swaparr-status-removed {
+ background-color: rgba(220, 53, 69, 0.2);
+}
diff --git a/frontend/static/js/apps/swaparr.js b/frontend/static/js/apps/swaparr.js
new file mode 100644
index 00000000..9a0f10ad
--- /dev/null
+++ b/frontend/static/js/apps/swaparr.js
@@ -0,0 +1,381 @@
+// Swaparr-specific functionality
+
+(function(app) {
+ if (!app) {
+ console.error("Huntarr App core is not loaded!");
+ return;
+ }
+
+ const swaparrModule = {
+ elements: {},
+ isTableView: true, // Default to table view for Swaparr logs
+ hasRenderedAnyContent: false, // Track if we've rendered any content
+
+ // Store data for display
+ logData: {
+ config: {
+ platform: '',
+ maxStrikes: 3,
+ scanInterval: '10m',
+ maxDownloadTime: '2h',
+ ignoreAboveSize: '25 GB'
+ },
+ downloads: [], // Will store download status records
+ rawLogs: [] // Store raw logs for backup display
+ },
+
+ init: function() {
+ console.log('[Swaparr Module] Initializing...');
+ this.setupLogProcessor();
+
+ // Add a listener for when the log tab changes to Swaparr
+ const swaparrTab = document.querySelector('.log-tab[data-app="swaparr"]');
+ if (swaparrTab) {
+ swaparrTab.addEventListener('click', () => {
+ console.log('[Swaparr Module] Swaparr tab clicked');
+ // Small delay to ensure everything is ready
+ setTimeout(() => {
+ this.ensureContentRendered();
+ }, 200);
+ });
+ }
+ },
+
+ setupLogProcessor: function() {
+ // Setup a listener for custom event from huntarrUI's log processing
+ document.addEventListener('swaparrLogReceived', (event) => {
+ console.log('[Swaparr Module] Received log event:', event.detail.logData.substring(0, 100) + '...');
+ this.processLogLine(event.detail.logData);
+ });
+ },
+
+ processLogLine: function(logLine) {
+ // Always store raw logs for backup display
+ this.logData.rawLogs.push(logLine);
+
+ // Limit raw logs storage to prevent memory issues
+ if (this.logData.rawLogs.length > 500) {
+ this.logData.rawLogs.shift();
+ }
+
+ // Process log lines specific to Swaparr
+ if (!logLine) return;
+
+ // Check if this looks like a Swaparr config line and extract information
+ if (logLine.includes('Platform:') && logLine.includes('Max strikes:')) {
+ this.extractConfigInfo(logLine);
+ this.renderConfigPanel();
+ return;
+ }
+
+ // Look for strike-related logs from system
+ if (logLine.includes('Added strike') ||
+ logLine.includes('Max strikes reached') ||
+ logLine.includes('removing download') ||
+ logLine.includes('Would have removed')) {
+
+ this.processStrikeLog(logLine);
+ return;
+ }
+
+ // Check if this is a table header/separator line
+ if (logLine.includes('strikes') && logLine.includes('status') && logLine.includes('name') && logLine.includes('size') && logLine.includes('eta')) {
+ // This is the header line, we can ignore it or use it to confirm table format
+ return;
+ }
+
+ // Try to match download info line
+ // Format: [strikes/max] status name size eta
+ // Example: 2/3 Striked MyDownload.mkv 1.5 GB 2h 15m
+ const downloadLinePattern = /(\d+\/\d+)\s+(\w+)\s+(.+?)\s+(\d+(?:\.\d+)?)\s*(\w+)\s+([\ddhms\s]+|Infinite)/;
+ const match = logLine.match(downloadLinePattern);
+
+ if (match) {
+ // Extract download information
+ const downloadInfo = {
+ strikes: match[1],
+ status: match[2],
+ name: match[3],
+ size: match[4] + ' ' + match[5],
+ eta: match[6]
+ };
+
+ // Update or add to our list of downloads
+ this.updateDownloadsList(downloadInfo);
+ this.renderTableView();
+ }
+
+ // If we're viewing the Swaparr tab, always ensure content is rendered
+ if (app.currentLogApp === 'swaparr') {
+ this.ensureContentRendered();
+ }
+ },
+
+ // Process strike-related logs from system logs
+ processStrikeLog: function(logLine) {
+ // Try to extract download name and strike info
+ let downloadName = '';
+ let strikes = '1/3'; // Default value
+ let status = 'Striked';
+
+ // Extract download name
+ if (logLine.includes('Added strike')) {
+ const match = logLine.match(/Added strike \((\d+)\/(\d+)\) to (.+?) - Reason:/);
+ if (match) {
+ strikes = `${match[1]}/${match[2]}`;
+ downloadName = match[3];
+ status = 'Striked';
+ }
+ } else if (logLine.includes('Max strikes reached')) {
+ const match = logLine.match(/Max strikes reached for (.+?), removing download/);
+ if (match) {
+ downloadName = match[1];
+ status = 'Removed';
+ }
+ } else if (logLine.includes('Would have removed')) {
+ const match = logLine.match(/Would have removed (.+?) after (\d+) strikes/);
+ if (match) {
+ downloadName = match[1];
+ status = 'Pending Removal';
+ strikes = `${match[2]}/3`;
+ }
+ }
+
+ if (downloadName) {
+ // Create a download info object with partial information
+ const downloadInfo = {
+ strikes: strikes,
+ status: status,
+ name: downloadName,
+ size: 'Unknown',
+ eta: 'Unknown'
+ };
+
+ // Update downloads list
+ this.updateDownloadsList(downloadInfo);
+ this.renderTableView();
+ }
+ },
+
+ extractConfigInfo: function(logLine) {
+ // Extract the config data from the log line
+ const platformMatch = logLine.match(/Platform:\s+(\w+)/);
+ const maxStrikesMatch = logLine.match(/Max strikes:\s+(\d+)/);
+ const scanIntervalMatch = logLine.match(/Scan interval:\s+(\d+\w+)/);
+ const maxDownloadTimeMatch = logLine.match(/Max download time:\s+(\d+\w+)/);
+ const ignoreSizeMatch = logLine.match(/Ignore above size:\s+(\d+\s*\w+)/);
+
+ if (platformMatch) this.logData.config.platform = platformMatch[1];
+ if (maxStrikesMatch) this.logData.config.maxStrikes = maxStrikesMatch[1];
+ if (scanIntervalMatch) this.logData.config.scanInterval = scanIntervalMatch[1];
+ if (maxDownloadTimeMatch) this.logData.config.maxDownloadTime = maxDownloadTimeMatch[1];
+ if (ignoreSizeMatch) this.logData.config.ignoreAboveSize = ignoreSizeMatch[1];
+ },
+
+ updateDownloadsList: function(downloadInfo) {
+ // Find if this download already exists in our list
+ const existingIndex = this.logData.downloads.findIndex(item =>
+ item.name.trim() === downloadInfo.name.trim()
+ );
+
+ if (existingIndex >= 0) {
+ // Update existing entry
+ this.logData.downloads[existingIndex] = downloadInfo;
+ } else {
+ // Add new entry
+ this.logData.downloads.push(downloadInfo);
+ }
+ },
+
+ renderConfigPanel: function() {
+ // Find the logs container
+ const logsContainer = document.getElementById('logsContainer');
+ if (!logsContainer) return;
+
+ // If the user has selected swaparr logs, show the config panel at the top
+ if (app.currentLogApp === 'swaparr') {
+ // Check if config panel already exists
+ let configPanel = document.getElementById('swaparr-config-panel');
+ if (!configPanel) {
+ // Create the panel
+ configPanel = document.createElement('div');
+ configPanel.id = 'swaparr-config-panel';
+ configPanel.classList.add('swaparr-panel');
+ logsContainer.appendChild(configPanel);
+ }
+
+ // Update the panel content
+ configPanel.innerHTML = `
+
+
Swaparr${this.logData.config.platform ? ' — ' + this.logData.config.platform : ''}
+
+ Max strikes: ${this.logData.config.maxStrikes}
+ Scan interval: ${this.logData.config.scanInterval}
+ Max download time: ${this.logData.config.maxDownloadTime}
+ Ignore above size: ${this.logData.config.ignoreAboveSize}
+
+
+ `;
+
+ this.hasRenderedAnyContent = true;
+ }
+ },
+
+ renderTableView: function() {
+ // Find the logs container
+ const logsContainer = document.getElementById('logsContainer');
+ if (!logsContainer || app.currentLogApp !== 'swaparr') return;
+
+ // Check if table already exists
+ let tableView = document.getElementById('swaparr-table-view');
+ if (!tableView) {
+ // Create the table
+ tableView = document.createElement('div');
+ tableView.id = 'swaparr-table-view';
+ tableView.classList.add('swaparr-table');
+ logsContainer.appendChild(tableView);
+ }
+
+ // Only render table if we have downloads to show
+ if (this.logData.downloads.length > 0) {
+ // Generate table HTML
+ let tableHTML = `
+
+
+
+ | Strikes |
+ Status |
+ Name |
+ Size |
+ ETA |
+
+
+
+ `;
+
+ // Add each download as a row
+ this.logData.downloads.forEach(download => {
+ // Apply status-specific CSS class
+ let statusClass = download.status.toLowerCase();
+
+ // Normalize some status values
+ if (statusClass === 'pending removal') statusClass = 'pending';
+ if (statusClass === 'removed') statusClass = 'removed';
+ if (statusClass === 'striked') statusClass = 'striked';
+ if (statusClass === 'normal') statusClass = 'normal';
+ if (statusClass === 'ignored') statusClass = 'ignored';
+
+ tableHTML += `
+
+ | ${download.strikes} |
+ ${download.status} |
+ ${download.name} |
+ ${download.size} |
+ ${download.eta} |
+
+ `;
+ });
+
+ tableHTML += `
+
+
+ `;
+
+ tableView.innerHTML = tableHTML;
+ this.hasRenderedAnyContent = true;
+ }
+ },
+
+ // Render raw logs if we don't have structured content
+ renderRawLogs: function() {
+ // Only show raw logs if we have no other content
+ if (this.hasRenderedAnyContent) return;
+
+ const logsContainer = document.getElementById('logsContainer');
+ if (!logsContainer || app.currentLogApp !== 'swaparr') return;
+
+ // Start with a message
+ const noDataMessage = document.createElement('div');
+ noDataMessage.classList.add('swaparr-panel');
+ noDataMessage.innerHTML = `
+
+
Swaparr Logs
+
Waiting for structured Swaparr data. Showing raw logs below:
+
+ `;
+ logsContainer.appendChild(noDataMessage);
+
+ // Add raw logs
+ for (const logLine of this.logData.rawLogs) {
+ const logEntry = document.createElement('div');
+ logEntry.className = 'log-entry';
+ logEntry.innerHTML = `${logLine}`;
+
+ // Basic level detection
+ if (logLine.includes('ERROR')) logEntry.classList.add('log-error');
+ else if (logLine.includes('WARN') || logLine.includes('WARNING')) logEntry.classList.add('log-warning');
+ else if (logLine.includes('DEBUG')) logEntry.classList.add('log-debug');
+ else logEntry.classList.add('log-info');
+
+ logsContainer.appendChild(logEntry);
+ }
+
+ this.hasRenderedAnyContent = true;
+ },
+
+ // Make sure we display something in the Swaparr tab
+ ensureContentRendered: function() {
+ console.log('[Swaparr Module] Ensuring content is rendered, has content:', this.hasRenderedAnyContent);
+
+ // Reset rendered flag
+ this.hasRenderedAnyContent = false;
+
+ // Check if we're viewing Swaparr tab
+ if (app.currentLogApp !== 'swaparr') return;
+
+ // First try to render structured content
+ this.renderConfigPanel();
+ this.renderTableView();
+
+ // If no structured content, show raw logs
+ if (!this.hasRenderedAnyContent) {
+ this.renderRawLogs();
+ }
+ },
+
+ // Clear the data when switching log views
+ clearData: function() {
+ this.logData.downloads = [];
+ // Keep raw logs for now
+ this.hasRenderedAnyContent = false;
+ }
+ };
+
+ // Initialize the module
+ document.addEventListener('DOMContentLoaded', () => {
+ swaparrModule.init();
+
+ if (app) {
+ app.swaparrModule = swaparrModule;
+
+ // Setup a handler for when log tabs are changed
+ document.querySelectorAll('.log-tab').forEach(tab => {
+ tab.addEventListener('click', (e) => {
+ // If switching to swaparr tab, make sure we render the view
+ if (e.target.getAttribute('data-app') === 'swaparr') {
+ console.log('[Swaparr Module] Swaparr tab clicked via delegation');
+ // Small delay to allow logs to load
+ setTimeout(() => {
+ swaparrModule.ensureContentRendered();
+ }, 200);
+ }
+ // If switching away from swaparr tab, clear the data
+ else if (app.currentLogApp === 'swaparr') {
+ swaparrModule.clearData();
+ }
+ });
+ });
+ }
+ });
+
+})(window.huntarrUI); // Pass the global UI object
\ No newline at end of file
diff --git a/frontend/static/js/new-main.js b/frontend/static/js/new-main.js
index 9ce86d82..b9259cd2 100644
--- a/frontend/static/js/new-main.js
+++ b/frontend/static/js/new-main.js
@@ -487,34 +487,105 @@ let huntarrUI = {
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 Swaparr-related system logs (added strikes, etc.)
+ if (logAppType === 'system' &&
+ (logString.includes('Added strike') ||
+ logString.includes('Max strikes reached') ||
+ logString.includes('Would have removed') ||
+ logString.includes('strikes, removing download') ||
+ logString.includes('processing stalled downloads'))) {
+ logAppType = 'swaparr';
+ }
+
+ // 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;
+ // Special handling for Swaparr logs to enable table view
+ if (logAppType === 'swaparr' && this.currentLogApp === 'swaparr') {
+ if (match) {
+ const [, appName, timestamp, loggerName, level, message] = match;
+
+ logEntry.innerHTML = `
+ ${timestamp.split(' ')[1]}
+ ${appName ? `[${appName}]` : ''}
+ ${level}
+ (${loggerName.replace('huntarr.', '')})
+ ${message}
+ `;
+ logEntry.classList.add(`log-${level.toLowerCase()}`);
+ } else {
+ // Fallback for lines that don't match the expected format
+ logEntry.innerHTML = `${logString}`;
+
+ // 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');
+ }
- // Use backticks for template literal
- logEntry.innerHTML = `
- ${timestamp.split(' ')[1]}
- ${appName ? `[${appName}]` : ''}
- ${level}
- (${loggerName.replace('huntarr.', '')})
- ${message}
- `; // End template literal with backtick
- logEntry.classList.add(`log-${level.toLowerCase()}`);
-
- } else {
- // Fallback for lines that don't match the expected format
- logEntry.innerHTML = `${logString}`;
- // 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'); // Added WARN check
- 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);
+
+ // 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);
+ }
+ // Standard log handling for other apps or all logs
+ else {
+ if (match) {
+ const [, appName, timestamp, loggerName, level, message] = match;
+
+ logEntry.innerHTML = `
+ ${timestamp.split(' ')[1]}
+ ${appName ? `[${appName}]` : ''}
+ ${level}
+ (${loggerName.replace('huntarr.', '')})
+ ${message}
+ `;
+ logEntry.classList.add(`log-${level.toLowerCase()}`);
+ } else {
+ // Fallback for lines that don't match the expected format
+ logEntry.innerHTML = `${logString}`;
+
+ // 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);
}
-
- // Add to logs container
- this.elements.logsContainer.appendChild(logEntry);
// Auto-scroll to bottom if enabled
if (this.autoScroll) {
diff --git a/frontend/templates/base.html b/frontend/templates/base.html
index 506bc602..c971f1aa 100644
--- a/frontend/templates/base.html
+++ b/frontend/templates/base.html
@@ -21,6 +21,7 @@
+