bulk delete
Local Docker Build (Dev) / build-dev (push) Successful in 18s

This commit is contained in:
2025-10-29 10:06:35 -04:00
parent 6e491fb091
commit 7fffaac247
3 changed files with 214 additions and 373 deletions
+94 -371
View File
@@ -53,9 +53,6 @@ function switchTab(tabName) {
case 'reports':
loadReport();
break;
case 'scheduled-scans':
loadScheduledScans();
break;
case 'tools':
loadDetailedStats();
break;
@@ -507,10 +504,21 @@ function showEpisodesModal(data) {
</label>
</div>
<div class="mb-3">
<button id="bulk-select-all" class="btn btn-sm btn-secondary" onclick="toggleSelectAll()">
<i class="fas fa-check-square"></i> Select All
</button>
<button id="bulk-delete-selected" class="btn btn-sm btn-danger" onclick="bulkDeleteSelected()" style="margin-left: 10px;" disabled>
<i class="fas fa-trash"></i> Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th width="40px">
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
</th>
<th>Episode</th>
<th>Aired</th>
<th>Date Added</th>
@@ -533,7 +541,10 @@ function showEpisodesModal(data) {
`<td>${dateadded}</td>`;
return `
<tr class="${rowClass}" data-has-date="${!missingDate}">
<tr class="${rowClass}" data-has-date="${!missingDate}" data-imdb="${data.series.imdb_id}" data-season="${episode.season}" data-episode="${episode.episode}">
<td>
<input type="checkbox" class="episode-checkbox" onchange="updateBulkDeleteButton()">
</td>
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
<td>${episode.aired || '-'}</td>
${dateCell}
@@ -1630,392 +1641,104 @@ async function logout() {
}
}
// Scheduled Scans Functions
async function loadScheduledScans() {
try {
// Load schedules
const response = await fetch('/api/admin/scheduled-scans');
const data = await response.json();
if (data.success) {
populateSchedulesTable(data.scans);
} else {
showToast('❌ Failed to load scheduled scans', 'error');
}
// Load execution history
const execResponse = await fetch('/api/admin/scheduled-scans/executions');
const execData = await execResponse.json();
if (execData.success) {
populateExecutionsTable(execData.executions);
}
} catch (error) {
console.error('Failed to load scheduled scans:', error);
showToast('❌ Error loading scheduled scans', 'error');
// Bulk delete functions for TV episodes
function toggleSelectAll() {
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const episodeCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && episodeCheckboxes.length > 0) {
const shouldCheck = selectAllCheckbox.checked;
episodeCheckboxes.forEach(checkbox => {
checkbox.checked = shouldCheck;
});
updateBulkDeleteButton();
}
}
function populateSchedulesTable(scans) {
const tbody = document.getElementById('schedules-table-body');
tbody.innerHTML = '';
function updateBulkDeleteButton() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const selectedCountSpan = document.getElementById('selected-count');
if (scans.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No scheduled scans configured</td></tr>';
if (selectedCountSpan) {
selectedCountSpan.textContent = selectedCount;
}
if (bulkDeleteButton) {
bulkDeleteButton.disabled = selectedCount === 0;
}
// Update select all checkbox state
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const allCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && allCheckboxes.length > 0) {
selectAllCheckbox.checked = selectedCount === allCheckboxes.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
}
}
async function bulkDeleteSelected() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
if (selectedCount === 0) {
showToast('❌ No episodes selected', 'error');
return;
}
scans.forEach(scan => {
const row = document.createElement('tr');
const nextRun = scan.next_run_at ? new Date(scan.next_run_at).toLocaleString() : 'Not scheduled';
const lastRun = scan.last_run_at ? new Date(scan.last_run_at).toLocaleString() : 'Never';
const statusBadge = scan.enabled ?
'<span class="badge badge-success">Enabled</span>' :
'<span class="badge badge-secondary">Disabled</span>';
row.innerHTML = `
<td>
<strong>${scan.name}</strong>
${scan.description ? `<br><small class="text-muted">${scan.description}</small>` : ''}
</td>
<td>
<span class="badge badge-info">${scan.media_type}</span>
</td>
<td>
<span class="badge badge-primary">${scan.scan_mode}</span>
</td>
<td>
<code>${scan.cron_expression}</code>
</td>
<td>${lastRun}</td>
<td>${nextRun}</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group">
<button class="btn btn-sm btn-outline" onclick="editSchedule(${scan.id})" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline" onclick="toggleSchedule(${scan.id})" title="Toggle">
<i class="fas fa-power-off"></i>
</button>
<button class="btn btn-sm btn-outline" onclick="runSchedule(${scan.id})" title="Run Now">
<i class="fas fa-play"></i>
</button>
<button class="btn btn-sm btn-outline text-red" onclick="deleteSchedule(${scan.id})" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
function populateExecutionsTable(executions) {
const tbody = document.getElementById('executions-table-body');
tbody.innerHTML = '';
if (executions.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No execution history</td></tr>';
if (!confirm(`Are you sure you want to delete ${selectedCount} episode(s)? This action cannot be undone.`)) {
return;
}
executions.forEach(exec => {
const row = document.createElement('tr');
const started = new Date(exec.started_at).toLocaleString();
const duration = exec.execution_time_seconds ?
`${exec.execution_time_seconds}s` : 'N/A';
let statusBadge;
switch (exec.status) {
case 'completed':
statusBadge = '<span class="badge badge-success">Completed</span>';
break;
case 'failed':
statusBadge = '<span class="badge badge-danger">Failed</span>';
break;
case 'running':
statusBadge = '<span class="badge badge-primary">Running</span>';
break;
default:
statusBadge = `<span class="badge badge-secondary">${exec.status}</span>`;
}
row.innerHTML = `
<td>${exec.schedule_name}</td>
<td>${started}</td>
<td>${duration}</td>
<td>${statusBadge}</td>
<td>${exec.items_processed || 0}</td>
<td>${exec.items_skipped || 0}</td>
<td>${exec.items_failed || 0}</td>
<td>
${exec.error_message ?
`<button class="btn btn-sm btn-outline" onclick="showExecutionDetails(${exec.id})" title="View Details">
<i class="fas fa-info-circle"></i>
</button>` : ''}
</td>
`;
tbody.appendChild(row);
});
}
function openScheduleModal(scheduleId = null) {
const modal = document.getElementById('schedule-modal');
const title = document.getElementById('schedule-modal-title');
const submitText = document.getElementById('schedule-submit-text');
const form = document.getElementById('schedule-form');
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const originalText = bulkDeleteButton.innerHTML;
bulkDeleteButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
bulkDeleteButton.disabled = true;
// Reset form
form.reset();
document.getElementById('schedule-id').value = '';
document.getElementById('schedule-enabled').checked = true;
let successCount = 0;
let failCount = 0;
if (scheduleId) {
title.textContent = 'Edit Schedule';
submitText.textContent = 'Update Schedule';
loadScheduleForEdit(scheduleId);
} else {
title.textContent = 'Add New Schedule';
submitText.textContent = 'Create Schedule';
}
modal.style.display = 'block';
}
function closeScheduleModal() {
document.getElementById('schedule-modal').style.display = 'none';
}
async function loadScheduleForEdit(scheduleId) {
try {
const response = await fetch('/api/admin/scheduled-scans');
const data = await response.json();
// Process deletions
for (const checkbox of selectedCheckboxes) {
const row = checkbox.closest('tr');
const imdbId = row.getAttribute('data-imdb');
const season = parseInt(row.getAttribute('data-season'));
const episode = parseInt(row.getAttribute('data-episode'));
if (data.success) {
const schedule = data.scans.find(s => s.id === scheduleId);
if (schedule) {
document.getElementById('schedule-id').value = schedule.id;
document.getElementById('schedule-name').value = schedule.name;
document.getElementById('schedule-description').value = schedule.description || '';
document.getElementById('schedule-media-type').value = schedule.media_type;
document.getElementById('schedule-scan-mode').value = schedule.scan_mode;
document.getElementById('schedule-cron').value = schedule.cron_expression;
document.getElementById('schedule-paths').value = schedule.specific_paths || '';
document.getElementById('schedule-enabled').checked = schedule.enabled;
try {
const response = await apiCall(`/api/tv/${imdbId}/${season}/${episode}`, {
method: 'DELETE'
});
if (response.success) {
// Remove the row from the table
row.remove();
successCount++;
} else {
failCount++;
console.error(`Failed to delete S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, response.message);
}
} catch (error) {
failCount++;
console.error(`Error deleting S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, error);
}
} catch (error) {
console.error('Failed to load schedule for edit:', error);
showToast('❌ Failed to load schedule details', 'error');
}
}
async function toggleSchedule(scheduleId) {
try {
const response = await fetch(`/api/admin/scheduled-scans/${scheduleId}/toggle`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast(`${data.message}`, 'success');
loadScheduledScans(); // Reload table
} else {
showToast(`${data.error}`, 'error');
}
} catch (error) {
console.error('Failed to toggle schedule:', error);
showToast('❌ Error toggling schedule', 'error');
}
}
async function runSchedule(scheduleId) {
if (!confirm('Are you sure you want to run this scan now?')) {
return;
}
try {
const response = await fetch(`/api/admin/scheduled-scans/${scheduleId}/run`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast(`${data.message}`, 'success');
// Reload executions table after a moment
setTimeout(() => {
loadScheduledScans();
}, 2000);
} else {
showToast(`${data.error}`, 'error');
}
} catch (error) {
console.error('Failed to run schedule:', error);
showToast('❌ Error running schedule', 'error');
}
}
async function deleteSchedule(scheduleId) {
if (!confirm('Are you sure you want to delete this scheduled scan? This action cannot be undone.')) {
return;
}
// Update UI
updateEpisodeModalCounts();
updateBulkDeleteButton();
try {
const response = await fetch(`/api/admin/scheduled-scans/${scheduleId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast(`${data.message}`, 'success');
loadScheduledScans(); // Reload table
} else {
showToast(`${data.error}`, 'error');
}
} catch (error) {
console.error('Failed to delete schedule:', error);
showToast('❌ Error deleting schedule', 'error');
}
}
function editSchedule(scheduleId) {
openScheduleModal(scheduleId);
}
// Cron Builder Functions
function openCronBuilder() {
document.getElementById('cron-builder-modal').style.display = 'block';
updateCronPreview();
}
function closeCronBuilder() {
document.getElementById('cron-builder-modal').style.display = 'none';
}
function setCronPreset(expression) {
const parts = expression.split(' ');
document.getElementById('cron-minute').value = parts[0];
document.getElementById('cron-hour').value = parts[1];
document.getElementById('cron-day').value = parts[2];
document.getElementById('cron-month').value = parts[3];
document.getElementById('cron-dow').value = parts[4];
updateCronPreview();
}
function updateCronPreview() {
const minute = document.getElementById('cron-minute').value || '*';
const hour = document.getElementById('cron-hour').value || '*';
const day = document.getElementById('cron-day').value || '*';
const month = document.getElementById('cron-month').value || '*';
const dow = document.getElementById('cron-dow').value || '*';
// Reset button
bulkDeleteButton.innerHTML = originalText;
bulkDeleteButton.disabled = true;
const expression = `${minute} ${hour} ${day} ${month} ${dow}`;
document.getElementById('cron-preview-text').textContent = expression;
// Simple description generation
let description = 'Runs ';
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && dow === '*') {
description += `daily at ${hour}:00`;
} else if (minute === '0' && hour !== '*' && day === '*' && month === '*' && dow !== '*') {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
description += `weekly on ${days[dow]} at ${hour}:00`;
} else if (minute === '0' && hour !== '*' && day !== '*' && month === '*' && dow === '*') {
description += `monthly on day ${day} at ${hour}:00`;
// Show results
if (successCount > 0 && failCount === 0) {
showToast(`✅ Successfully deleted ${successCount} episode(s)`, 'success');
} else if (successCount > 0 && failCount > 0) {
showToast(`⚠️ Deleted ${successCount} episode(s), ${failCount} failed`, 'warning');
} else {
description += 'with custom schedule';
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
}
document.getElementById('cron-description').textContent = description;
}
function applyCronExpression() {
const expression = document.getElementById('cron-preview-text').textContent;
document.getElementById('schedule-cron').value = expression;
closeCronBuilder();
}
// Event listeners for scheduled scans
document.addEventListener('DOMContentLoaded', function() {
// Add Schedule button
const addScheduleBtn = document.getElementById('add-schedule-btn');
if (addScheduleBtn) {
addScheduleBtn.addEventListener('click', () => openScheduleModal());
}
// Cron builder button
const cronBuilderBtn = document.getElementById('cron-builder-btn');
if (cronBuilderBtn) {
cronBuilderBtn.addEventListener('click', openCronBuilder);
}
// Cron field change listeners
const cronFields = ['cron-minute', 'cron-hour', 'cron-day', 'cron-month', 'cron-dow'];
cronFields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('input', updateCronPreview);
}
});
// Schedule form submission
const scheduleForm = document.getElementById('schedule-form');
if (scheduleForm) {
scheduleForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const scheduleId = document.getElementById('schedule-id').value;
const scheduleData = {
name: formData.get('schedule-name') || document.getElementById('schedule-name').value,
description: formData.get('schedule-description') || document.getElementById('schedule-description').value,
cron_expression: formData.get('schedule-cron') || document.getElementById('schedule-cron').value,
media_type: formData.get('schedule-media-type') || document.getElementById('schedule-media-type').value,
scan_mode: formData.get('schedule-scan-mode') || document.getElementById('schedule-scan-mode').value,
specific_paths: formData.get('schedule-paths') || document.getElementById('schedule-paths').value,
enabled: document.getElementById('schedule-enabled').checked
};
try {
let url, method;
if (scheduleId) {
url = `/api/admin/scheduled-scans/${scheduleId}`;
method = 'PUT';
} else {
url = '/api/admin/scheduled-scans';
method = 'POST';
}
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(scheduleData)
});
const data = await response.json();
if (data.success) {
showToast(`${data.message}`, 'success');
closeScheduleModal();
loadScheduledScans(); // Reload table
} else {
showToast(`${data.error}`, 'error');
}
} catch (error) {
console.error('Failed to save schedule:', error);
showToast('❌ Error saving schedule', 'error');
}
});
}
});
+2
View File
@@ -8,3 +8,5 @@ aiofiles==23.2.1
aiohttp==3.8.6
psutil==5.9.6
python-dotenv==1.0.0
APScheduler==3.10.4
croniter==1.4.1
+118 -2
View File
@@ -504,10 +504,21 @@ function showEpisodesModal(data) {
</label>
</div>
<div class="mb-3">
<button id="bulk-select-all" class="btn btn-sm btn-secondary" onclick="toggleSelectAll()">
<i class="fas fa-check-square"></i> Select All
</button>
<button id="bulk-delete-selected" class="btn btn-sm btn-danger" onclick="bulkDeleteSelected()" style="margin-left: 10px;" disabled>
<i class="fas fa-trash"></i> Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th width="40px">
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
</th>
<th>Episode</th>
<th>Aired</th>
<th>Date Added</th>
@@ -530,7 +541,10 @@ function showEpisodesModal(data) {
`<td>${dateadded}</td>`;
return `
<tr class="${rowClass}" data-has-date="${!missingDate}">
<tr class="${rowClass}" data-has-date="${!missingDate}" data-imdb="${data.series.imdb_id}" data-season="${episode.season}" data-episode="${episode.episode}">
<td>
<input type="checkbox" class="episode-checkbox" onchange="updateBulkDeleteButton()">
</td>
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
<td>${episode.aired || '-'}</td>
${dateCell}
@@ -1625,4 +1639,106 @@ async function logout() {
console.error('Logout failed:', error);
showToast('❌ Logout error', 'error');
}
}
}
// Bulk delete functions for TV episodes
function toggleSelectAll() {
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const episodeCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && episodeCheckboxes.length > 0) {
const shouldCheck = selectAllCheckbox.checked;
episodeCheckboxes.forEach(checkbox => {
checkbox.checked = shouldCheck;
});
updateBulkDeleteButton();
}
}
function updateBulkDeleteButton() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const selectedCountSpan = document.getElementById('selected-count');
if (selectedCountSpan) {
selectedCountSpan.textContent = selectedCount;
}
if (bulkDeleteButton) {
bulkDeleteButton.disabled = selectedCount === 0;
}
// Update select all checkbox state
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const allCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && allCheckboxes.length > 0) {
selectAllCheckbox.checked = selectedCount === allCheckboxes.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
}
}
async function bulkDeleteSelected() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
if (selectedCount === 0) {
showToast('❌ No episodes selected', 'error');
return;
}
if (!confirm(`Are you sure you want to delete ${selectedCount} episode(s)? This action cannot be undone.`)) {
return;
}
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const originalText = bulkDeleteButton.innerHTML;
bulkDeleteButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
bulkDeleteButton.disabled = true;
let successCount = 0;
let failCount = 0;
// Process deletions
for (const checkbox of selectedCheckboxes) {
const row = checkbox.closest('tr');
const imdbId = row.getAttribute('data-imdb');
const season = parseInt(row.getAttribute('data-season'));
const episode = parseInt(row.getAttribute('data-episode'));
try {
const response = await apiCall(`/api/tv/${imdbId}/${season}/${episode}`, {
method: 'DELETE'
});
if (response.success) {
// Remove the row from the table
row.remove();
successCount++;
} else {
failCount++;
console.error(`Failed to delete S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, response.message);
}
} catch (error) {
failCount++;
console.error(`Error deleting S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, error);
}
}
// Update UI
updateEpisodeModalCounts();
updateBulkDeleteButton();
// Reset button
bulkDeleteButton.innerHTML = originalText;
bulkDeleteButton.disabled = true;
// Show results
if (successCount > 0 && failCount === 0) {
showToast(`✅ Successfully deleted ${successCount} episode(s)`, 'success');
} else if (successCount > 0 && failCount > 0) {
showToast(`⚠️ Deleted ${successCount} episode(s), ${failCount} failed`, 'warning');
} else {
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
}
}