refactor: remove Swaparr app and related functionality

This commit is contained in:
Admin9705
2025-06-05 13:17:07 +02:00
parent 7b16c93d9e
commit 751817ff27
33 changed files with 42 additions and 1590 deletions

View File

@@ -111,7 +111,7 @@ var
i: Integer;
begin
// Define all required configuration directories
SetArrayLength(ConfigDirs, 14);
SetArrayLength(ConfigDirs, 13);
ConfigDirs[0] := '\config';
ConfigDirs[1] := '\config\logs';
ConfigDirs[2] := '\config\stateful';
@@ -121,11 +121,10 @@ begin
ConfigDirs[6] := '\config\scheduler';
ConfigDirs[7] := '\config\reset';
ConfigDirs[8] := '\config\tally';
ConfigDirs[9] := '\config\swaparr';
ConfigDirs[10] := '\config\eros';
ConfigDirs[11] := '\logs';
ConfigDirs[12] := '\frontend\templates';
ConfigDirs[13] := '\frontend\static';
ConfigDirs[9] := '\config\eros';
ConfigDirs[10] := '\logs';
ConfigDirs[11] := '\frontend\templates';
ConfigDirs[12] := '\frontend\static';
// Create all necessary configuration directories with explicit permissions
for i := 0 to GetArrayLength(ConfigDirs) - 1 do
@@ -302,7 +301,7 @@ begin
begin
Log('Non-admin installation - attempting to ensure directories are writable...');
SetArrayLength(Permissions, 14);
SetArrayLength(Permissions, 13);
Permissions[0] := '\config';
Permissions[1] := '\config\logs';
Permissions[2] := '\config\stateful';
@@ -312,11 +311,10 @@ begin
Permissions[6] := '\config\scheduler';
Permissions[7] := '\config\reset';
Permissions[8] := '\config\tally';
Permissions[9] := '\config\swaparr';
Permissions[10] := '\config\eros';
Permissions[11] := '\logs';
Permissions[12] := '\frontend\templates';
Permissions[13] := '\frontend\static';
Permissions[9] := '\config\eros';
Permissions[10] := '\logs';
Permissions[11] := '\frontend\templates';
Permissions[12] := '\frontend\static';
for i := 0 to GetArrayLength(Permissions) - 1 do
begin

View File

@@ -107,7 +107,6 @@ Section "Huntarr Application (required)" SecCore
CreateDirectory "$INSTDIR\config\scheduler"
CreateDirectory "$INSTDIR\config\reset"
CreateDirectory "$INSTDIR\config\tally"
CreateDirectory "$INSTDIR\config\swaparr"
CreateDirectory "$INSTDIR\config\eros"
CreateDirectory "$INSTDIR\logs"
CreateDirectory "$INSTDIR\frontend\templates"

View File

@@ -81,12 +81,11 @@ HISTORY_DIR = CONFIG_PATH / "history"
SCHEDULER_DIR = CONFIG_PATH / "scheduler"
RESET_DIR = CONFIG_PATH / "reset" # Add reset directory
TALLY_DIR = CONFIG_PATH / "tally" # Add tally directory for stats
SWAPARR_DIR = CONFIG_PATH / "swaparr" # Add Swaparr directory
EROS_DIR = CONFIG_PATH / "eros" # Add Eros directory
# Create all directories with enhanced error reporting
for dir_path in [LOG_DIR, SETTINGS_DIR, USER_DIR, STATEFUL_DIR, HISTORY_DIR,
SCHEDULER_DIR, RESET_DIR, TALLY_DIR, SWAPARR_DIR, EROS_DIR]:
SCHEDULER_DIR, RESET_DIR, TALLY_DIR, EROS_DIR]:
try:
dir_path.mkdir(parents=True, exist_ok=True)
@@ -120,10 +119,6 @@ def get_reset_path(app_type):
"""Get the path to an app's reset file"""
return RESET_DIR / f"{app_type}.reset"
def get_swaparr_state_path():
"""Get the Swaparr state directory"""
return SWAPARR_DIR
def get_eros_config_path():
"""Get the Eros config file path"""
return CONFIG_PATH / "eros.json"

View File

@@ -52,7 +52,6 @@ def setup_config_directories(base_dir=None):
config_path / "scheduler",
config_path / "reset",
config_path / "tally",
config_path / "swaparr",
config_path / "eros"
]

View File

@@ -38,7 +38,6 @@ def setup_environment():
os.path.join(config_dir, "scheduler"),
os.path.join(config_dir, "reset"),
os.path.join(config_dir, "tally"),
os.path.join(config_dir, "swaparr"),
os.path.join(config_dir, "eros")
]

View File

@@ -41,7 +41,7 @@ html, body {
.app-content-panel,
#appsContainer,
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps,
#whisparrApps, #erosApps, #swaparrApps, #cleanuperrApps,
#whisparrApps, #erosApps, #cleanuperrApps,
.additional-options-section,
.additional-options,
.skip-series-refresh,
@@ -64,7 +64,7 @@ table, tr, td, tbody, thead {
}
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps,
#whisparrApps, #erosApps, #swaparrApps, #cleanuperrApps {
#whisparrApps, #erosApps, #cleanuperrApps {
padding-bottom: 30px !important;
margin-bottom: 20px !important;
}

View File

@@ -28,7 +28,7 @@
/* Reduce excessive padding in various containers */
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps,
#whisparrApps, #erosApps, #swaparrApps, #cleanuperrApps {
#whisparrApps, #erosApps, #cleanuperrApps {
padding-bottom: 50px !important;
margin-bottom: 20px !important;
overflow: visible !important;
@@ -81,7 +81,6 @@ body #lidarrApps,
body #readarrApps,
body #whisparrApps,
body #erosApps,
body #swaparrApps,
body #cleanuperrApps {
overflow: visible !important;
max-height: none !important;
@@ -105,7 +104,7 @@ body #cleanuperrApps {
/* Reduce bottom spacing on mobile */
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps,
#whisparrApps, #erosApps, #swaparrApps, #cleanuperrApps,
#whisparrApps, #erosApps, #cleanuperrApps,
.additional-options, .skip-series-refresh {
margin-bottom: 50px !important;
padding-bottom: 30px !important;

View File

@@ -1750,104 +1750,6 @@ input:checked + .toggle-slider:before {
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);
}
/* Log Dropdown Styles */
.log-dropdown-container {
position: relative;

View File

@@ -1,381 +0,0 @@
// 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 = `
<div class="swaparr-config">
<h3>Swaparr${this.logData.config.platform ? ' — ' + this.logData.config.platform : ''}</h3>
<div class="swaparr-config-content">
<span>Max strikes: ${this.logData.config.maxStrikes}</span>
<span>Scan interval: ${this.logData.config.scanInterval}</span>
<span>Max download time: ${this.logData.config.maxDownloadTime}</span>
<span>Ignore above size: ${this.logData.config.ignoreAboveSize}</span>
</div>
</div>
`;
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 = `
<table>
<thead>
<tr>
<th>Strikes</th>
<th>Status</th>
<th>Name</th>
<th>Size</th>
<th>ETA</th>
</tr>
</thead>
<tbody>
`;
// 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 += `
<tr class="swaparr-status-${statusClass}">
<td>${download.strikes}</td>
<td>${download.status}</td>
<td>${download.name}</td>
<td>${download.size}</td>
<td>${download.eta}</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
`;
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 = `
<div class="swaparr-config">
<h3>Swaparr Logs</h3>
<p>Waiting for structured Swaparr data. Showing raw logs below:</p>
</div>
`;
logsContainer.appendChild(noDataMessage);
// Add raw logs
for (const logLine of this.logData.rawLogs) {
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
logEntry.innerHTML = `<span class="log-message">${logLine}</span>`;
// 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

View File

@@ -351,16 +351,6 @@ window.LogsModule = {
this.applyFilterToSingleEntry(logEntry, currentLogLevel);
}
// Special event dispatching for Swaparr logs
if (logAppType === 'swaparr' && this.currentLogApp === 'swaparr') {
const swaparrEvent = new CustomEvent('swaparrLogReceived', {
detail: {
logData: match && match[5] ? match[5] : logString
}
});
document.dispatchEvent(swaparrEvent);
}
// Auto-scroll to top
if (this.autoScroll) {
window.scrollTo({

View File

@@ -1001,7 +1001,6 @@ let huntarrUI = {
if (data.readarr) this.populateSettingsForm('readarr', data.readarr);
if (data.whisparr) this.populateSettingsForm('whisparr', data.whisparr);
if (data.eros) this.populateSettingsForm('eros', data.eros);
if (data.swaparr) this.populateSettingsForm('swaparr', data.swaparr);
if (data.general) this.populateSettingsForm('general', data.general);
// Update duration displays (like sleep durations)
@@ -2023,7 +2022,7 @@ let huntarrUI = {
updateStatsDisplay: function(stats) {
// Update each app's statistics
const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'];
const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'];
const statTypes = ['hunted', 'upgraded'];
// More robust low usage mode detection - check multiple sources
@@ -2164,8 +2163,7 @@ let huntarrUI = {
'lidarr': {'hunted': 0, 'upgraded': 0},
'readarr': {'hunted': 0, 'upgraded': 0},
'whisparr': {'hunted': 0, 'upgraded': 0},
'eros': {'hunted': 0, 'upgraded': 0},
'swaparr': {'hunted': 0, 'upgraded': 0}
'eros': {'hunted': 0, 'upgraded': 0}
};
// Immediately update UI before even showing the confirmation
@@ -3194,7 +3192,7 @@ let huntarrUI = {
/^\s*\[?\s*$/, // Just opening bracket
/^\s*\]?,?\s*$/, // Just closing bracket
/^,?\s*$/, // Just comma or whitespace
/^[^"]*':\s*[^,]*,.*'.*':/, // Mid-object fragments like "g_items': 1, 'hunt_upgrade_items': 0"
/^[^"]*':\s*[^,]*,.*':/, // Mid-object fragments like "g_items': 1, 'hunt_upgrade_items': 0"
/^[a-zA-Z_][a-zA-Z0-9_]*':\s*\d+,/, // Property names starting without quotes
/^[a-zA-Z_][a-zA-Z0-9_]*':\s*True|False,/, // Boolean properties without opening quotes
/^[a-zA-Z_][a-zA-Z0-9_]*':\s*'[^']*',/, // String properties without opening quotes

View File

@@ -909,187 +909,6 @@ const SettingsForms = {
this.updateDurationDisplay();
},
// Generate Swaparr settings form
generateSwaparrForm: function(container, settings = {}) {
// Add data-app-type attribute to container
container.setAttribute('data-app-type', 'swaparr');
container.innerHTML = `
<div class="settings-group">
<h3>Swaparr (Beta) - Only For Torrent Users</h3>
<div class="setting-item">
<p>Swaparr addresses the issue of stalled downloads and I rewrote it to support Huntarr. Visit Swaparr's <a href="https://github.com/ThijmenGThN/swaparr" target="_blank">GitHub</a> for more information and support the developer!</p>
</div>
</div>
<div class="settings-group">
<h3>Swaparr Settings</h3>
<div class="setting-item">
<label for="swaparr_enabled"><a href="/Huntarr.io/docs/#/guides/swaparr?id=overview" class="info-icon" title="Learn more about Swaparr's functionality" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Enable Swaparr:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="swaparr_enabled" ${settings.enabled ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help">Enable automatic handling of stalled downloads</p>
</div>
<div class="setting-item">
<label for="swaparr_max_strikes"><a href="/Huntarr.io/docs/#/guides/swaparr?id=strike-system" class="info-icon" title="Learn more about strike system for stalled downloads" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Maximum Strikes:</label>
<input type="number" id="swaparr_max_strikes" min="1" max="10" value="${settings.max_strikes || 3}">
<p class="setting-help">Number of strikes before removing a stalled download</p>
</div>
<div class="setting-item">
<label for="swaparr_max_download_time"><a href="/Huntarr.io/docs/#/guides/swaparr?id=time-thresholds" class="info-icon" title="Learn more about maximum download time setting" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Max Download Time:</label>
<input type="text" id="swaparr_max_download_time" value="${settings.max_download_time || '2h'}">
<p class="setting-help">Maximum time a download can be stalled (e.g., 30m, 2h, 1d)</p>
</div>
<div class="setting-item">
<label for="swaparr_ignore_above_size"><a href="https://huntarr.io" class="info-icon" title="Learn more about size threshold settings" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Ignore Above Size:</label>
<input type="text" id="swaparr_ignore_above_size" value="${settings.ignore_above_size || '25GB'}">
<p class="setting-help">Ignore files larger than this size (e.g., 1GB, 25GB, 1TB)</p>
</div>
<div class="setting-item">
<label for="swaparr_remove_from_client"><a href="https://huntarr.io" class="info-icon" title="Learn more about client removal options" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Remove From Client:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="swaparr_remove_from_client" ${settings.remove_from_client !== false ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help">Remove the download from the torrent/usenet client when removed</p>
</div>
<div class="setting-item">
<label for="swaparr_dry_run"><a href="https://huntarr.io" class="info-icon" title="Learn more about dry run testing mode" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>Dry Run Mode:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="swaparr_dry_run" ${settings.dry_run === true ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help">Log actions but don't actually remove downloads. Useful for testing the first time!</p>
</div>
</div>
<div class="settings-group">
<h3>Swaparr Status</h3>
<div id="swaparr_status_container">
<div class="button-container" style="display: flex; justify-content: flex-end; margin-bottom: 15px;">
<button type="button" id="reset_swaparr_strikes" style="background-color: #e74c3c; color: white; border: none; padding: 5px 10px; border-radius: 4px; font-size: 0.9em; cursor: pointer;">
<i class="fas fa-trash"></i> Reset
</button>
</div>
<div id="swaparr_status" class="status-display">
<p>Loading Swaparr status...</p>
</div>
</div>
</div>
`;
// Load Swaparr status automatically
const resetStrikesBtn = container.querySelector('#reset_swaparr_strikes');
const statusContainer = container.querySelector('#swaparr_status');
HuntarrUtils.fetchWithTimeout('/api/swaparr/status')
.then(response => response.json())
.then(data => {
let statusHTML = '';
// Add stats for each app if available
if (data.statistics && Object.keys(data.statistics).length > 0) {
statusHTML += '<ul>';
for (const [app, stats] of Object.entries(data.statistics)) {
statusHTML += `<li><strong>${app.toUpperCase()}</strong>: `;
if (stats.error) {
statusHTML += `Error: ${stats.error}</li>`;
} else {
statusHTML += `${stats.currently_striked} currently striked, ${stats.removed} removed (${stats.total_tracked} total tracked)</li>`;
}
}
statusHTML += '</ul>';
} else {
statusHTML += '<p>No statistics available yet.</p>';
}
statusContainer.innerHTML = statusHTML;
})
.catch(error => {
console.error('Error loading Swaparr status:', error);
statusContainer.innerHTML = `<p>Error fetching status: ${error.message}</p>`;
});
// Add event listener for the Reset Strikes button
if (resetStrikesBtn) {
resetStrikesBtn.addEventListener('click', function() {
if (confirm('Are you sure you want to reset all Swaparr strikes? This will clear the strike history for all apps.')) {
statusContainer.innerHTML = '<p>Resetting strikes...</p>';
HuntarrUtils.fetchWithTimeout('/api/swaparr/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusContainer.innerHTML = `<p>Success: ${data.message}</p>`;
// Reload status after a short delay
setTimeout(() => {
HuntarrUtils.fetchWithTimeout('/api/swaparr/status')
.then(response => response.json())
.then(data => {
let statusHTML = '';
if (data.statistics && Object.keys(data.statistics).length > 0) {
statusHTML += '<ul>';
for (const [app, stats] of Object.entries(data.statistics)) {
statusHTML += `<li><strong>${app.toUpperCase()}</strong>: `;
if (stats.error) {
statusHTML += `Error: ${stats.error}</li>`;
} else {
statusHTML += `${stats.currently_striked} currently striked, ${stats.removed} removed (${stats.total_tracked} total tracked)</li>`;
}
}
statusHTML += '</ul>';
} else {
statusHTML += '<p>No statistics available yet.</p>';
}
statusContainer.innerHTML = statusHTML;
});
}, 1000);
} else {
statusContainer.innerHTML = `<p>Error: ${data.message}</p>`;
}
})
.catch(error => {
statusContainer.innerHTML = `<p>Error resetting strikes: ${error.message}</p>`;
});
}
});
} else if (!resetStrikesBtn) {
console.warn('Could not find #reset_swaparr_strikes to attach listener.');
} else {
console.warn('huntarrUI or huntarrUI.resetStatefulManagement is not available.');
}
// Add confirmation dialog for local access bypass toggle
const localAccessBypassCheckbox = container.querySelector('#local_access_bypass');
if (localAccessBypassCheckbox) {
// Store original state
const originalState = localAccessBypassCheckbox.checked;
localAccessBypassCheckbox.addEventListener('change', function() {
const newState = this.checked;
// Preview the UI changes immediately, but they'll be reverted if user doesn't save
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.updateUIForLocalAccessBypass === 'function') {
huntarrUI.updateUIForLocalAccessBypass(newState);
}
// Also ensure the main app knows settings have changed if the preview runs
if (typeof huntarrUI !== 'undefined' && typeof huntarrUI.markSettingsAsChanged === 'function') {
huntarrUI.markSettingsAsChanged();
}
});
}
},
// Format date nicely for display
formatDate: function(date) {
if (!date) return 'Never';
@@ -1310,14 +1129,6 @@ const SettingsForms = {
settings.sleep_duration = getInputValue('#eros_sleep_duration', 900);
settings.hourly_cap = getInputValue('#eros_hourly_cap', 20);
}
else if (appType === 'swaparr') {
settings.enabled = getInputValue('#swaparr_enabled', false);
settings.max_strikes = getInputValue('#swaparr_max_strikes', 3);
settings.max_download_time = getInputValue('#swaparr_max_download_time', '2h');
settings.ignore_above_size = getInputValue('#swaparr_ignore_above_size', '25GB');
settings.remove_from_client = getInputValue('#swaparr_remove_from_client', true);
settings.dry_run = getInputValue('#swaparr_dry_run', false);
}
}
console.log('Collected settings for', appType, settings);

View File

@@ -88,7 +88,7 @@ document.addEventListener('DOMContentLoaded', function() {
*/
function initStatsTooltips() {
// Add event listeners to statistics numbers
const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros', 'swaparr'];
const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'eros'];
const statTypes = ['hunted', 'upgraded'];
apps.forEach(app => {
@@ -136,8 +136,7 @@ function showStatsTooltip(e) {
'lidarr': { name: 'Lidarr', color: '#2ecc71', description: type === 'hunted' ? 'Album searches triggered' : 'Albums upgraded to better quality' },
'readarr': { name: 'Readarr', color: '#e74c3c', description: type === 'hunted' ? 'Book searches triggered' : 'Books upgraded to better quality' },
'whisparr': { name: 'Whisparr', color: '#9b59b6', description: type === 'hunted' ? 'Adult video searches triggered' : 'Adult videos upgraded to better quality' },
'eros': { name: 'Eros', color: '#1abc9c', description: type === 'hunted' ? 'Audio searches triggered' : 'Audio files upgraded to better quality' },
'swaparr': { name: 'Swaparr', color: '#e67e22', description: type === 'hunted' ? 'Content swap operations' : 'Content swap upgrades' }
'eros': { name: 'Eros', color: '#1abc9c', description: type === 'hunted' ? 'Audio searches triggered' : 'Audio files upgraded to better quality' }
};
const detail = appDetails[app] || { name: app, color: '#95a5a6', description: type === 'hunted' ? 'Searches triggered' : 'Content upgraded' };

View File

@@ -25,7 +25,8 @@
<script src="./static/js/apps/radarr.js"></script>
<script src="./static/js/apps/lidarr.js"></script>
<script src="./static/js/apps/readarr.js"></script>
<script src="./static/js/apps/swaparr.js"></script>
<script src="./static/js/apps/whisparr.js"></script>
<script src="./static/js/apps/eros.js"></script>
<!-- ...existing code... -->
</body>
</html>

View File

@@ -121,9 +121,9 @@
<option value="lidarr">Lidarr</option>
<option value="readarr">Readarr</option>
<option value="whisparr">Whisparr V2</option>
<option value="eros">Whisparr V3</option>
<option value="swaparr">Swaparr</option>
<option value="cleanuperr">Cleanuperr</option>
<option value="whisparr">Whisparr V3</option>
<option value="eros">Eros</option>
<option value="hunting">Hunt Manager</option>
</select>
</div>
@@ -148,7 +148,6 @@
<div id="readarrApps" class="app-apps-panel app-content-panel"></div>
<div id="whisparrApps" class="app-apps-panel app-content-panel"></div>
<div id="erosApps" class="app-apps-panel app-content-panel"></div>
<div id="swaparrApps" class="app-apps-panel app-content-panel"></div>
<div id="cleanuperrApps" class="app-apps-panel app-content-panel"></div>
</div>
</div>
@@ -206,7 +205,7 @@
}
/* Ensure Additional Options section is fully visible */
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps, #whisparrApps, #erosApps, #swaparrApps {
#sonarrApps, #radarrApps, #lidarrApps, #readarrApps, #whisparrApps, #erosApps {
padding-bottom: 150px; /* Extra padding to ensure bottom content is visible */
margin-bottom: 50px;
}
@@ -264,7 +263,6 @@
#readarrApps::-webkit-scrollbar,
#whisparrApps::-webkit-scrollbar,
#erosApps::-webkit-scrollbar,
#swaparrApps::-webkit-scrollbar,
table::-webkit-scrollbar,
tr::-webkit-scrollbar,
td::-webkit-scrollbar {

View File

@@ -12,9 +12,8 @@
<option value="radarr">Radarr</option>
<option value="lidarr">Lidarr</option>
<option value="readarr">Readarr</option>
<option value="whisparr">Whisparr V2</option>
<option value="eros">Whisparr V3</option>
<option value="swaparr">Swaparr</option>
<option value="whisparr">Whisparr</option>
<option value="eros">Eros</option>
<option value="hunting">Hunt Manager</option>
<option value="system">System</option>
</select>

View File

@@ -10,7 +10,6 @@ from src.primary.apps.radarr_routes import radarr_bp
from src.primary.apps.lidarr_routes import lidarr_bp
from src.primary.apps.readarr_routes import readarr_bp
from src.primary.apps.whisparr_routes import whisparr_bp
from src.primary.apps.swaparr_routes import swaparr_bp
from src.primary.apps.eros_routes import eros_bp
__all__ = [
@@ -19,6 +18,5 @@ __all__ = [
"lidarr_bp",
"readarr_bp",
"whisparr_bp",
"swaparr_bp",
"eros_bp"
]

View File

@@ -1,185 +0,0 @@
"""
Swaparr module for Huntarr
Handles stalled downloads in Starr apps based on the original Swaparr application
"""
from flask import Blueprint, request, jsonify
import os
import json
from src.primary.utils.logger import get_logger
from src.primary.settings_manager import load_settings, save_settings
from src.primary.apps.swaparr.handler import process_stalled_downloads
from src.primary.apps.radarr import get_configured_instances as get_radarr_instances
from src.primary.apps.sonarr import get_configured_instances as get_sonarr_instances
from src.primary.apps.lidarr import get_configured_instances as get_lidarr_instances
from src.primary.apps.readarr import get_configured_instances as get_readarr_instances
def get_configured_instances():
"""Get all configured Starr app instances from their respective settings"""
try:
from src.primary.apps.whisparr import get_configured_instances as get_whisparr_instances
whisparr_instances = get_whisparr_instances()
except ImportError:
whisparr_instances = []
try:
from src.primary.apps.eros import get_configured_instances as get_eros_instances
eros_instances = get_eros_instances()
except ImportError:
eros_instances = []
instances = {
"radarr": get_radarr_instances(),
"sonarr": get_sonarr_instances(),
"lidarr": get_lidarr_instances(),
"readarr": get_readarr_instances(),
"whisparr": whisparr_instances,
"eros": eros_instances
}
logger = get_logger("swaparr")
logger.info(f"Found {sum(len(v) for v in instances.values())} configured Starr app instances")
return instances
swaparr_bp = Blueprint('swaparr', __name__)
swaparr_logger = get_logger("swaparr")
@swaparr_bp.route('/status', methods=['GET'])
def get_status():
"""Get Swaparr status and statistics"""
settings = load_settings("swaparr")
enabled = settings.get("enabled", False)
# Get strike statistics from all app state directories
statistics = {}
# Use the cross-platform path from handler module
from src.primary.apps.swaparr.handler import SWAPARR_STATE_DIR
state_dir = SWAPARR_STATE_DIR
if os.path.exists(state_dir):
for app_name in os.listdir(state_dir):
app_dir = os.path.join(state_dir, app_name)
if os.path.isdir(app_dir):
strike_file = os.path.join(app_dir, "strikes.json")
if os.path.exists(strike_file):
try:
with open(strike_file, 'r') as f:
strike_data = json.load(f)
total_items = len(strike_data)
removed_items = sum(1 for item in strike_data.values() if item.get("removed", False))
striked_items = sum(1 for item in strike_data.values()
if item.get("strikes", 0) > 0 and not item.get("removed", False))
statistics[app_name] = {
"total_tracked": total_items,
"currently_striked": striked_items,
"removed": removed_items
}
except (json.JSONDecodeError, IOError) as e:
swaparr_logger.error(f"Error reading strike data for {app_name}: {str(e)}")
statistics[app_name] = {"error": str(e)}
return jsonify({
"enabled": enabled,
"settings": {
"max_strikes": settings.get("max_strikes", 3),
"max_download_time": settings.get("max_download_time", "2h"),
"ignore_above_size": settings.get("ignore_above_size", "25GB"),
"remove_from_client": settings.get("remove_from_client", True),
"dry_run": settings.get("dry_run", False)
},
"statistics": statistics
})
@swaparr_bp.route('/settings', methods=['GET'])
def get_settings():
"""Get Swaparr settings"""
settings = load_settings("swaparr")
return jsonify(settings)
@swaparr_bp.route('/settings', methods=['POST'])
def update_settings():
"""Update Swaparr settings"""
data = request.json
if not data:
return jsonify({"success": False, "message": "No data provided"}), 400
# Load current settings
settings = load_settings("swaparr")
# Update settings with provided data
for key, value in data.items():
settings[key] = value
# Save updated settings
success = save_settings("swaparr", settings)
if success:
return jsonify({"success": True, "message": "Settings updated successfully"})
else:
return jsonify({"success": False, "message": "Failed to save settings"}), 500
@swaparr_bp.route('/reset', methods=['POST'])
def reset_strikes():
"""Reset all strikes for all apps or a specific app"""
data = request.json
app_name = data.get('app_name') if data else None
# Use the cross-platform path from handler module
from src.primary.apps.swaparr.handler import SWAPARR_STATE_DIR
state_dir = SWAPARR_STATE_DIR
if not os.path.exists(state_dir):
return jsonify({"success": True, "message": "No strike data to reset"})
if app_name:
# Reset strikes for a specific app
app_dir = os.path.join(state_dir, app_name)
if os.path.exists(app_dir):
strike_file = os.path.join(app_dir, "strikes.json")
if os.path.exists(strike_file):
try:
os.remove(strike_file)
swaparr_logger.info(f"Reset strikes for {app_name}")
return jsonify({"success": True, "message": f"Strikes reset for {app_name}"})
except IOError as e:
swaparr_logger.error(f"Error resetting strikes for {app_name}: {str(e)}")
return jsonify({"success": False, "message": f"Failed to reset strikes for {app_name}: {str(e)}"}), 500
return jsonify({"success": False, "message": f"No strike data found for {app_name}"}), 404
else:
# Reset strikes for all apps
try:
for app_name in os.listdir(state_dir):
app_dir = os.path.join(state_dir, app_name)
if os.path.isdir(app_dir):
strike_file = os.path.join(app_dir, "strikes.json")
if os.path.exists(strike_file):
os.remove(strike_file)
swaparr_logger.info("Reset all strikes")
return jsonify({"success": True, "message": "All strikes reset"})
except IOError as e:
swaparr_logger.error(f"Error resetting all strikes: {str(e)}")
return jsonify({"success": False, "message": f"Failed to reset all strikes: {str(e)}"}), 500
def is_configured():
"""Check if Swaparr has any configured Starr app instances"""
instances = get_configured_instances()
return any(len(app_instances) > 0 for app_instances in instances.values())
def run_swaparr():
"""Run Swaparr cycle to check for stalled downloads in all configured Starr app instances"""
settings = load_settings("swaparr")
if not settings or not settings.get("enabled", False):
swaparr_logger.debug("Swaparr is disabled, skipping run")
return
instances = get_configured_instances()
# Process stalled downloads for each app type and instance
for app_name, app_instances in instances.items():
for app_settings in app_instances:
process_stalled_downloads(app_name, app_settings, settings)

View File

@@ -1,16 +0,0 @@
"""
Swaparr app module for Huntarr
Contains functionality for handling stalled downloads in Starr apps
"""
# Add necessary imports for get_configured_instances
from src.primary.settings_manager import load_settings
from src.primary.utils.logger import get_logger
swaparr_logger = get_logger("swaparr") # Get the logger instance
# We don't need the get_configured_instances function here anymore as it's defined in swaparr.py
# to avoid circular imports
# Export just the swaparr_logger for now
__all__ = ["swaparr_logger"]

View File

@@ -1,470 +0,0 @@
"""
Implementation of the swaparr functionality to detect and remove stalled downloads in Starr apps.
Based on the functionality provided by https://github.com/ThijmenGThN/swaparr
"""
import os
import json
import time
import hashlib
from datetime import datetime, timedelta
import requests
from src.primary.utils.logger import get_logger
from src.primary.settings_manager import load_settings
from src.primary.state import get_state_file_path
# Create logger
swaparr_logger = get_logger("swaparr")
# Use the centralized path configuration
from src.primary.utils.config_paths import SWAPARR_DIR
# Use cross-platform path for state directory
SWAPARR_STATE_DIR = str(SWAPARR_DIR) # Convert to string for compatibility with os.path
def ensure_state_directory(app_name):
"""Ensure the state directory exists for tracking strikes for a specific app"""
app_state_dir = os.path.join(SWAPARR_STATE_DIR, app_name)
if not os.path.exists(app_state_dir):
os.makedirs(app_state_dir, exist_ok=True)
swaparr_logger.info(f"Created swaparr state directory for {app_name}: {app_state_dir}")
return app_state_dir
def load_strike_data(app_name):
"""Load strike data for a specific app"""
app_state_dir = ensure_state_directory(app_name)
strike_file = os.path.join(app_state_dir, "strikes.json")
if not os.path.exists(strike_file):
return {}
try:
with open(strike_file, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
swaparr_logger.error(f"Error loading strike data for {app_name}: {str(e)}")
return {}
def save_strike_data(app_name, strike_data):
"""Save strike data for a specific app"""
app_state_dir = ensure_state_directory(app_name)
strike_file = os.path.join(app_state_dir, "strikes.json")
try:
with open(strike_file, 'w') as f:
json.dump(strike_data, f, indent=2)
except IOError as e:
swaparr_logger.error(f"Error saving strike data for {app_name}: {str(e)}")
def load_removed_items(app_name):
"""Load list of permanently removed items"""
app_state_dir = ensure_state_directory(app_name)
removed_file = os.path.join(app_state_dir, "removed_items.json")
if not os.path.exists(removed_file):
return {}
try:
with open(removed_file, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
swaparr_logger.error(f"Error loading removed items for {app_name}: {str(e)}")
return {}
def save_removed_items(app_name, removed_items):
"""Save list of permanently removed items"""
app_state_dir = ensure_state_directory(app_name)
removed_file = os.path.join(app_state_dir, "removed_items.json")
try:
with open(removed_file, 'w') as f:
json.dump(removed_items, f, indent=2)
except IOError as e:
swaparr_logger.error(f"Error saving removed items for {app_name}: {str(e)}")
def generate_item_hash(item):
"""Generate a unique hash for an item based on its name and size.
This helps track items across restarts even if their queue ID changes."""
hash_input = f"{item['name']}_{item['size']}"
return hashlib.md5(hash_input.encode('utf-8')).hexdigest()
def parse_time_string_to_seconds(time_string):
"""Parse a time string like '2h', '30m', '1d' to seconds"""
if not time_string:
return 7200 # Default 2 hours
unit = time_string[-1].lower()
try:
value = int(time_string[:-1])
except ValueError:
swaparr_logger.error(f"Invalid time string: {time_string}, using default 2 hours")
return 7200
if unit == 'd':
return value * 86400 # Days to seconds
elif unit == 'h':
return value * 3600 # Hours to seconds
elif unit == 'm':
return value * 60 # Minutes to seconds
else:
swaparr_logger.error(f"Unknown time unit in: {time_string}, using default 2 hours")
return 7200
def parse_size_string_to_bytes(size_string):
"""Parse a size string like '25GB', '1TB' to bytes"""
if not size_string:
return 25 * 1024 * 1024 * 1024 # Default 25GB
# Extract the numeric part and unit
unit = ""
for i in range(len(size_string) - 1, -1, -1):
if not size_string[i].isalpha():
value = float(size_string[:i+1])
unit = size_string[i+1:].upper()
break
else:
swaparr_logger.error(f"Invalid size string: {size_string}, using default 25GB")
return 25 * 1024 * 1024 * 1024
# Convert to bytes based on unit
if unit == 'B':
return int(value)
elif unit == 'KB':
return int(value * 1024)
elif unit == 'MB':
return int(value * 1024 * 1024)
elif unit == 'GB':
return int(value * 1024 * 1024 * 1024)
elif unit == 'TB':
return int(value * 1024 * 1024 * 1024 * 1024)
else:
swaparr_logger.error(f"Unknown size unit in: {size_string}, using default 25GB")
return 25 * 1024 * 1024 * 1024
def get_queue_items(app_name, api_url, api_key, api_timeout=120):
"""Get download queue items from a Starr app API with pagination support"""
api_version_map = {
"radarr": "v3",
"sonarr": "v3",
"lidarr": "v1",
"readarr": "v1",
"whisparr": "v3"
}
api_version = api_version_map.get(app_name, "v3")
# Initialize an empty list to store all records
all_records = []
# Start with page 1
page = 1
page_size = 100 # Request a large page size to reduce API calls
while True:
# Add pagination parameters
queue_url = f"{api_url.rstrip('/')}/api/{api_version}/queue?page={page}&pageSize={page_size}"
headers = {'X-Api-Key': api_key}
try:
response = requests.get(queue_url, headers=headers, timeout=api_timeout)
response.raise_for_status()
queue_data = response.json()
if api_version in ["v3"]: # Radarr, Sonarr, Whisparr use v3
records = queue_data.get("records", [])
total_records = queue_data.get("totalRecords", 0)
else: # Lidarr, Readarr use v1
records = queue_data
total_records = len(records)
# Add this page's records to our collection
all_records.extend(records)
# If we've fetched all records or there are no more, break the loop
if len(all_records) >= total_records or len(records) == 0:
break
# Otherwise, move to the next page
page += 1
except requests.exceptions.RequestException as e:
swaparr_logger.error(f"Error fetching queue for {app_name} (page {page}): {str(e)}")
break
swaparr_logger.info(f"Fetched {len(all_records)} queue items for {app_name}")
# Normalize the response based on app type
if app_name in ["radarr", "whisparr", "eros"]:
return parse_queue_items(all_records, "movie", app_name)
elif app_name == "sonarr":
return parse_queue_items(all_records, "series", app_name)
elif app_name == "lidarr":
return parse_queue_items(all_records, "album", app_name)
elif app_name == "readarr":
return parse_queue_items(all_records, "book", app_name)
else:
swaparr_logger.error(f"Unknown app type: {app_name}")
return []
def parse_queue_items(records, item_type, app_name):
"""Parse queue items from API response into a standardized format"""
queue_items = []
for record in records:
# Skip non-dictionary records
if not isinstance(record, dict):
swaparr_logger.warning(f"Skipping non-dictionary record in {app_name} queue: {record}")
continue
# Extract the name based on the item type
name = None
if item_type == "movie" and record.get("movie"):
name = record["movie"].get("title", "Unknown Movie")
elif item_type == "series" and record.get("series"):
name = record["series"].get("title", "Unknown Series")
elif item_type == "album" and record.get("album"):
name = record["album"].get("title", "Unknown Album")
elif item_type == "book" and record.get("book"):
name = record["book"].get("title", "Unknown Book")
# If no name was found, try to use the download title
if not name and record.get("title"):
name = record.get("title", "Unknown Download")
# Parse ETA if available
eta_seconds = 0
if record.get("timeleft"):
eta = record.get("timeleft", "")
# Basic parsing of timeleft format like "00:30:00" (30 minutes)
try:
eta_parts = eta.split(':')
if len(eta_parts) == 3:
eta_seconds = int(eta_parts[0]) * 3600 + int(eta_parts[1]) * 60 + int(eta_parts[2])
except (ValueError, IndexError):
eta_seconds = 0
queue_items.append({
"id": record.get("id"),
"name": name,
"size": record.get("size", 0),
"status": record.get("status", "unknown").lower(),
"eta": eta_seconds,
"error_message": record.get("errorMessage", "")
})
return queue_items
def delete_download(app_name, api_url, api_key, download_id, remove_from_client=True, api_timeout=120):
"""Delete a download from a Starr app"""
api_version_map = {
"radarr": "v3",
"sonarr": "v3",
"lidarr": "v1",
"readarr": "v1",
"whisparr": "v3"
}
api_version = api_version_map.get(app_name, "v3")
delete_url = f"{api_url.rstrip('/')}/api/{api_version}/queue/{download_id}?removeFromClient={str(remove_from_client).lower()}&blocklist=true"
headers = {'X-Api-Key': api_key}
try:
response = requests.delete(delete_url, headers=headers, timeout=api_timeout)
response.raise_for_status()
swaparr_logger.info(f"Successfully removed download {download_id} from {app_name}")
return True
except requests.exceptions.RequestException as e:
swaparr_logger.error(f"Error removing download {download_id} from {app_name}: {str(e)}")
return False
def process_stalled_downloads(app_name, app_settings, swaparr_settings=None):
"""Process stalled downloads for a specific app instance"""
if not swaparr_settings:
swaparr_settings = load_settings("swaparr")
if not swaparr_settings or not swaparr_settings.get("enabled", False):
swaparr_logger.debug(f"Swaparr is disabled, skipping {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
return
swaparr_logger.info(f"Processing stalled downloads for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
# Get settings
max_strikes = swaparr_settings.get("max_strikes", 3)
max_download_time = parse_time_string_to_seconds(swaparr_settings.get("max_download_time", "2h"))
ignore_above_size = parse_size_string_to_bytes(swaparr_settings.get("ignore_above_size", "25GB"))
remove_from_client = swaparr_settings.get("remove_from_client", True)
dry_run = swaparr_settings.get("dry_run", False)
api_url = app_settings.get("api_url")
api_key = app_settings.get("api_key")
api_timeout = app_settings.get("api_timeout", 120)
if not api_url or not api_key:
swaparr_logger.error(f"Missing API URL or API Key for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
return
# Load existing strike data
strike_data = load_strike_data(app_name)
# Load list of permanently removed items
removed_items = load_removed_items(app_name)
# Clean up expired removed items (older than 30 days)
now = datetime.utcnow()
for item_hash in list(removed_items.keys()):
removed_date = datetime.fromisoformat(removed_items[item_hash]["removed_time"].replace('Z', '+00:00'))
if (now - removed_date) > timedelta(days=30):
swaparr_logger.debug(f"Removing expired entry from removed items list: {removed_items[item_hash]['name']}")
del removed_items[item_hash]
# Get current queue items
queue_items = get_queue_items(app_name, api_url, api_key, api_timeout)
if not queue_items:
swaparr_logger.info(f"No queue items found for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
return
# Keep track of items still in queue for cleanup
current_item_ids = set(item["id"] for item in queue_items)
# Clean up items that are no longer in the queue
for item_id in list(strike_data.keys()):
if int(item_id) not in current_item_ids:
swaparr_logger.debug(f"Removing item {item_id} from strike list as it's no longer in the queue")
del strike_data[item_id]
# Process each queue item
for item in queue_items:
item_id = str(item["id"])
item_state = "Normal"
item_hash = generate_item_hash(item)
# Check if this item has been previously removed
if item_hash in removed_items:
last_removed_date = datetime.fromisoformat(removed_items[item_hash]["removed_time"].replace('Z', '+00:00'))
days_since_removal = (now - last_removed_date).days
# Re-remove it automatically if it's been less than 7 days since last removal
if days_since_removal < 7:
swaparr_logger.warning(f"Found previously removed download that reappeared: {item['name']} (removed {days_since_removal} days ago)")
if not dry_run:
if delete_download(app_name, api_url, api_key, item["id"], remove_from_client, api_timeout):
swaparr_logger.info(f"Re-removed previously removed download: {item['name']}")
# Update the removal time
removed_items[item_hash]["removed_time"] = datetime.utcnow().isoformat()
else:
swaparr_logger.info(f"DRY RUN: Would have re-removed previously removed download: {item['name']}")
item_state = "Re-removed" if not dry_run else "Would Re-remove (Dry Run)"
continue
# Skip large files if configured
if item["size"] >= ignore_above_size:
swaparr_logger.debug(f"Ignoring large download: {item['name']} ({item['size']} bytes > {ignore_above_size} bytes)")
item_state = "Ignored (Size)"
continue
# Handle delayed items - we'll skip these
if item["status"] == "delay":
swaparr_logger.debug(f"Ignoring delayed download: {item['name']}")
item_state = "Ignored (Delayed)"
continue
# Special handling for "queued" status
# We only skip truly queued items, not those with metadata issues
metadata_issue = "metadata" in item["status"].lower() or "metadata" in item["error_message"].lower()
if item["status"] == "queued" and not metadata_issue:
# For regular queued items, check how long they've been in strike data
if item_id in strike_data and "first_strike_time" in strike_data[item_id]:
first_strike = datetime.fromisoformat(strike_data[item_id]["first_strike_time"].replace('Z', '+00:00'))
if (now - first_strike) < timedelta(hours=1):
# Skip if it's been less than 1 hour since first seeing it
swaparr_logger.debug(f"Ignoring recently queued download: {item['name']}")
item_state = "Ignored (Recently Queued)"
continue
else:
# Initialize with first strike time for queued items
if item_id not in strike_data:
strike_data[item_id] = {
"strikes": 0,
"name": item["name"],
"first_strike_time": datetime.utcnow().isoformat(),
"last_strike_time": None
}
swaparr_logger.debug(f"Monitoring new queued download: {item['name']}")
item_state = "Monitoring (Queued)"
continue
# Initialize strike count if not already in strike data
if item_id not in strike_data:
strike_data[item_id] = {
"strikes": 0,
"name": item["name"],
"first_strike_time": datetime.utcnow().isoformat(),
"last_strike_time": None
}
# Check if download should be striked
should_strike = False
strike_reason = ""
# Strike if metadata issue, eta too long, or no progress (eta = 0 and not queued)
if metadata_issue:
should_strike = True
strike_reason = "Metadata"
elif item["eta"] >= max_download_time:
should_strike = True
strike_reason = "ETA too long"
elif item["eta"] == 0 and item["status"] not in ["queued", "delay"]:
should_strike = True
strike_reason = "No progress"
# If we should strike this item, add a strike
if should_strike:
strike_data[item_id]["strikes"] += 1
strike_data[item_id]["last_strike_time"] = datetime.utcnow().isoformat()
if strike_data[item_id]["first_strike_time"] is None:
strike_data[item_id]["first_strike_time"] = datetime.utcnow().isoformat()
current_strikes = strike_data[item_id]["strikes"]
swaparr_logger.info(f"Added strike ({current_strikes}/{max_strikes}) to {item['name']} - Reason: {strike_reason}")
# If max strikes reached, remove the download
if current_strikes >= max_strikes:
swaparr_logger.warning(f"Max strikes reached for {item['name']}, removing download")
if not dry_run:
if delete_download(app_name, api_url, api_key, item["id"], remove_from_client, api_timeout):
swaparr_logger.info(f"Successfully removed {item['name']} after {max_strikes} strikes")
# Keep the item in strike data for reference but mark as removed
strike_data[item_id]["removed"] = True
strike_data[item_id]["removed_time"] = datetime.utcnow().isoformat()
# Add to removed items list for persistent tracking
removed_items[item_hash] = {
"name": item["name"],
"size": item["size"],
"removed_time": datetime.utcnow().isoformat(),
"reason": strike_reason
}
else:
swaparr_logger.info(f"DRY RUN: Would have removed {item['name']} after {max_strikes} strikes")
item_state = "Removed" if not dry_run else "Would Remove (Dry Run)"
else:
item_state = f"Striked ({current_strikes}/{max_strikes})"
swaparr_logger.debug(f"Processed download: {item['name']} - State: {item_state}")
# Save updated strike data
save_strike_data(app_name, strike_data)
# Save updated removed items list
save_removed_items(app_name, removed_items)
swaparr_logger.info(f"Finished processing stalled downloads for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")

View File

@@ -1,136 +0,0 @@
"""
Route definitions for Swaparr API endpoints.
"""
from flask import Blueprint, request, jsonify
import os
import json
from src.primary.utils.logger import get_logger
from src.primary.settings_manager import load_settings, save_settings
from src.primary.apps.swaparr.handler import process_stalled_downloads
# Import centralized path configuration
from src.primary.utils.config_paths import CONFIG_PATH, SWAPARR_STATE_DIR
# Create the blueprint directly in this file
swaparr_bp = Blueprint('swaparr', __name__)
swaparr_logger = get_logger("swaparr")
@swaparr_bp.route('/status', methods=['GET'])
def get_status():
"""Get Swaparr status and statistics"""
settings = load_settings("swaparr")
enabled = settings.get("enabled", False)
# Get strike statistics from all app state directories
statistics = {}
state_dir = SWAPARR_STATE_DIR
if os.path.exists(state_dir):
for app_name in os.listdir(state_dir):
app_dir = os.path.join(state_dir, app_name)
if os.path.isdir(app_dir):
strike_file = os.path.join(app_dir, "strikes.json")
if os.path.exists(strike_file):
try:
with open(strike_file, 'r') as f:
strike_data = json.load(f)
total_items = len(strike_data)
removed_items = sum(1 for item in strike_data.values() if item.get("removed", False))
striked_items = sum(1 for item in strike_data.values()
if item.get("strikes", 0) > 0 and not item.get("removed", False))
statistics[app_name] = {
"total_tracked": total_items,
"currently_striked": striked_items,
"removed": removed_items
}
except (json.JSONDecodeError, IOError) as e:
swaparr_logger.error(f"Error reading strike data for {app_name}: {str(e)}")
statistics[app_name] = {"error": str(e)}
return jsonify({
"enabled": enabled,
"settings": {
"max_strikes": settings.get("max_strikes", 3),
"max_download_time": settings.get("max_download_time", "2h"),
"ignore_above_size": settings.get("ignore_above_size", "25GB"),
"remove_from_client": settings.get("remove_from_client", True),
"dry_run": settings.get("dry_run", False)
},
"statistics": statistics
})
@swaparr_bp.route('/settings', methods=['GET'])
def get_settings():
"""Get Swaparr settings"""
settings = load_settings("swaparr")
return jsonify(settings)
@swaparr_bp.route('/settings', methods=['POST'])
def update_settings():
"""Update Swaparr settings"""
data = request.json
if not data:
return jsonify({"success": False, "message": "No data provided"}), 400
# Load current settings
settings = load_settings("swaparr")
# Update settings with provided data
for key, value in data.items():
settings[key] = value
# Save updated settings
success = save_settings("swaparr", settings)
if success:
return jsonify({"success": True, "message": "Settings updated successfully"})
else:
return jsonify({"success": False, "message": "Failed to save settings"}), 500
@swaparr_bp.route('/reset', methods=['POST'])
def reset_strikes():
"""Reset all strikes for all apps or a specific app"""
data = request.json
app_name = data.get('app_name') if data else None
state_dir = SWAPARR_STATE_DIR
if not os.path.exists(state_dir):
return jsonify({"success": True, "message": "No strike data to reset"})
if app_name:
# Reset strikes for a specific app
app_dir = os.path.join(state_dir, app_name)
if os.path.exists(app_dir):
strike_file = os.path.join(app_dir, "strikes.json")
if os.path.exists(strike_file):
try:
os.remove(strike_file)
swaparr_logger.info(f"Reset strikes for {app_name}")
return jsonify({"success": True, "message": f"Strikes reset for {app_name}"})
except IOError as e:
swaparr_logger.error(f"Error resetting strikes for {app_name}: {str(e)}")
return jsonify({"success": False, "message": f"Failed to reset strikes for {app_name}: {str(e)}"}), 500
return jsonify({"success": False, "message": f"No strike data found for {app_name}"}), 404
else:
# Reset strikes for all apps
try:
for app_name in os.listdir(state_dir):
app_dir = os.path.join(state_dir, app_name)
if os.path.isdir(app_dir):
strike_file = os.path.join(app_dir, "strikes.json")
if os.path.exists(strike_file):
os.remove(strike_file)
swaparr_logger.info("Reset all strikes")
return jsonify({"success": True, "message": "All strikes reset"})
except IOError as e:
swaparr_logger.error(f"Error resetting all strikes: {str(e)}")
return jsonify({"success": False, "message": f"Failed to reset all strikes: {str(e)}"}), 500
def register_routes(app):
"""Register Swaparr routes with the Flask app."""
app.register_blueprint(swaparr_bp, url_prefix='/api/swaparr')

View File

@@ -393,28 +393,6 @@ def app_specific_loop(app_type: str) -> None:
if not stop_event.is_set():
time.sleep(1) # Short pause
# --- Process Swaparr (stalled downloads) --- #
try:
# Try to import Swaparr module
if not 'process_stalled_downloads' in locals():
try:
# Import directly from handler module to avoid circular imports
from src.primary.apps.swaparr.handler import process_stalled_downloads
swaparr_logger = get_logger("swaparr")
swaparr_logger.debug(f"Successfully imported Swaparr module")
except (ImportError, AttributeError) as e:
app_logger.debug(f"Swaparr module not available or missing functions: {e}")
process_stalled_downloads = None
# Check if Swaparr is enabled
swaparr_settings = settings_manager.load_settings("swaparr")
if swaparr_settings and swaparr_settings.get("enabled", False) and process_stalled_downloads:
app_logger.info(f"Running Swaparr on {app_type} instance: {instance_name}")
process_stalled_downloads(app_type, combined_settings, swaparr_settings)
app_logger.info(f"Completed Swaparr processing for {app_type} instance: {instance_name}")
except Exception as e:
app_logger.error(f"Error during Swaparr processing for {instance_name}: {e}", exc_info=True)
# --- Cycle End & Sleep --- #
calculate_reset_time(app_type) # Pass app_type here if needed by the function

View File

@@ -1,8 +0,0 @@
{
"enabled": false,
"max_strikes": 3,
"max_download_time": "2h",
"ignore_above_size": "25GB",
"remove_from_client": true,
"dry_run": false
}

View File

@@ -23,8 +23,7 @@ history_locks = {
"lidarr": threading.Lock(),
"readarr": threading.Lock(),
"whisparr": threading.Lock(),
"eros": threading.Lock(),
"swaparr": threading.Lock()
"eros": threading.Lock()
}
def ensure_history_dir():

View File

@@ -33,11 +33,11 @@ def migrate_json_configs():
"eros.json",
"lidarr.json",
"readarr.json",
"swaparr.json",
"general.json",
"radarr.json",
"sonarr.json",
"whisparr.json"
"whisparr.json",
"eros.json"
]
# Flag to track if any files were migrated

View File

@@ -20,7 +20,7 @@ def get_app_history(app_type):
page_size = 20
# Validate app_type
valid_app_types = ["all", "sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "swaparr"]
valid_app_types = ["all", "sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"]
if app_type not in valid_app_types:
return jsonify({"error": f"Invalid app type: {app_type}"}), 400
@@ -36,7 +36,7 @@ def clear_app_history(app_type):
"""Clear history for a specific app or all apps"""
try:
# Validate app_type
valid_app_types = ["all", "sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "swaparr"]
valid_app_types = ["all", "sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"]
if app_type not in valid_app_types:
return jsonify({"error": f"Invalid app type: {app_type}"}), 400

View File

@@ -28,7 +28,7 @@ from src.primary.utils.config_paths import SETTINGS_DIR
DEFAULT_CONFIGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), 'default_configs'))
# Update or add this as a class attribute or constant
KNOWN_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "general", "swaparr"]
KNOWN_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "general"]
# Add a settings cache with timestamps to avoid excessive disk reads
settings_cache = {} # Format: {app_name: {'timestamp': timestamp, 'data': settings_dict}}

View File

@@ -33,7 +33,7 @@ def get_state_file_path(app_type, state_name):
The path to the state file
"""
# Define known app types
known_app_types = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "swaparr"]
known_app_types = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"]
# If app_type is not in known types, log a warning but don't fail
if app_type not in known_app_types and app_type != "general":

View File

@@ -127,8 +127,7 @@ def get_default_stats() -> Dict[str, Dict[str, int]]:
"lidarr": {"hunted": 0, "upgraded": 0},
"readarr": {"hunted": 0, "upgraded": 0},
"whisparr": {"hunted": 0, "upgraded": 0},
"eros": {"hunted": 0, "upgraded": 0},
"swaparr": {"hunted": 0, "upgraded": 0}
"eros": {"hunted": 0, "upgraded": 0}
}
def get_default_hourly_caps() -> Dict[str, Dict[str, int]]:
@@ -391,7 +390,7 @@ def increment_stat(app_type: str, stat_type: str, count: int = 1) -> bool:
Returns:
True if successful, False otherwise
"""
if app_type not in ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "swaparr"]:
if app_type not in ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"]:
logger.error(f"Invalid app_type: {app_type}")
return False
@@ -399,8 +398,7 @@ def increment_stat(app_type: str, stat_type: str, count: int = 1) -> bool:
logger.error(f"Invalid stat_type: {stat_type}")
return False
# Also increment the hourly API cap for this app, unless it's swaparr which doesn't have an API
if app_type != "swaparr":
# Also increment the hourly API cap for this app
increment_hourly_cap(app_type, count)
with stats_lock:
@@ -439,7 +437,7 @@ def increment_stat_only(app_type: str, stat_type: str, count: int = 1) -> bool:
Returns:
True if successful, False otherwise
"""
if app_type not in ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "swaparr"]:
if app_type not in ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"]:
logger.error(f"Invalid app_type: {app_type}")
return False

View File

@@ -25,7 +25,6 @@ CLEAN_LOG_FILES = {
"readarr": LOG_DIR / "clean_readarr.log",
"whisparr": LOG_DIR / "clean_whisparr.log",
"eros": LOG_DIR / "clean_eros.log",
"swaparr": LOG_DIR / "clean_swaparr.log",
"hunting": LOG_DIR / "clean_hunting.log"
}
@@ -154,7 +153,6 @@ class CleanLogFormatter(logging.Formatter):
'readarr': 'readarr',
'whisparr': 'whisparr',
'eros': 'eros',
'swaparr': 'swaparr',
'hunting': 'hunting',
}

View File

@@ -44,7 +44,6 @@ try:
STATEFUL_DIR = CONFIG_PATH / "stateful"
RESET_DIR = CONFIG_PATH / "reset"
SCHEDULER_DIR = CONFIG_PATH / "scheduler"
SWAPARR_STATE_DIR = CONFIG_PATH / "swaparr"
# Create essential directories
USER_DIR.mkdir(exist_ok=True)
@@ -53,7 +52,6 @@ try:
STATEFUL_DIR.mkdir(exist_ok=True)
RESET_DIR.mkdir(exist_ok=True)
SCHEDULER_DIR.mkdir(exist_ok=True)
SWAPARR_STATE_DIR.mkdir(exist_ok=True)
print(f"Using configuration directory: {CONFIG_DIR}")
# Check write permissions with a test file
test_file = CONFIG_PATH / f"write_test_{int(time.time())}.tmp"
@@ -88,12 +86,11 @@ HISTORY_DIR = CONFIG_PATH / "history"
SCHEDULER_DIR = CONFIG_PATH / "scheduler"
RESET_DIR = CONFIG_PATH / "reset" # Add reset directory
TALLY_DIR = CONFIG_PATH / "tally" # Add tally directory for stats
SWAPARR_DIR = CONFIG_PATH / "swaparr" # Add Swaparr directory
EROS_DIR = CONFIG_PATH / "eros" # Add Eros directory
# Create all directories
for dir_path in [LOG_DIR, SETTINGS_DIR, USER_DIR, STATEFUL_DIR, HISTORY_DIR,
SCHEDULER_DIR, RESET_DIR, TALLY_DIR, SWAPARR_DIR, EROS_DIR]:
SCHEDULER_DIR, RESET_DIR, TALLY_DIR, EROS_DIR]:
try:
dir_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
@@ -117,10 +114,6 @@ def get_reset_path(app_type):
"""Get the path to an app's reset file"""
return RESET_DIR / f"{app_type}.reset"
def get_swaparr_state_path():
"""Get the Swaparr state directory"""
return SWAPARR_DIR
def get_eros_config_path():
"""Get the Eros config file path"""
return CONFIG_PATH / "eros.json"

View File

@@ -28,7 +28,6 @@ APP_LOG_FILES = {
"readarr": LOG_DIR / "readarr.log", # Updated filename
"whisparr": LOG_DIR / "whisparr.log", # Added Whisparr
"eros": LOG_DIR / "eros.log", # Added Eros for Whisparr V3
"swaparr": LOG_DIR / "swaparr.log", # Added Swaparr
"hunting": LOG_DIR / "hunting.log" # Added Hunt Manager - fixed key
}

View File

@@ -37,7 +37,7 @@ from src.primary.auth import (
from src.primary.routes.common import common_bp
# Import blueprints for each app from the centralized blueprints module
from src.primary.apps.blueprints import sonarr_bp, radarr_bp, lidarr_bp, readarr_bp, whisparr_bp, swaparr_bp, eros_bp
from src.primary.apps.blueprints import sonarr_bp, radarr_bp, lidarr_bp, readarr_bp, whisparr_bp, eros_bp
# Import stateful blueprint
from src.primary.stateful_routes import stateful_api
@@ -262,7 +262,6 @@ app.register_blueprint(lidarr_bp, url_prefix='/api/lidarr')
app.register_blueprint(readarr_bp, url_prefix='/api/readarr')
app.register_blueprint(whisparr_bp, url_prefix='/api/whisparr')
app.register_blueprint(eros_bp, url_prefix='/api/eros')
app.register_blueprint(swaparr_bp, url_prefix='/api/swaparr')
app.register_blueprint(stateful_api, url_prefix='/api/stateful')
app.register_blueprint(history_blueprint, url_prefix='/api/history')
app.register_blueprint(scheduler_api)
@@ -289,7 +288,6 @@ KNOWN_LOG_FILES = {
"readarr": CLEAN_LOG_FILES.get("readarr"),
"whisparr": CLEAN_LOG_FILES.get("whisparr"),
"eros": CLEAN_LOG_FILES.get("eros"), # Added Eros to known log files
"swaparr": CLEAN_LOG_FILES.get("swaparr"), # Added Swaparr to known log files
"hunting": CLEAN_LOG_FILES.get("hunting"), # Added Hunt Manager to known log files - fixed key
"system": CLEAN_LOG_FILES.get("system"), # Map 'system' to the clean huntarr log
}