- Add callback_ur, download_folder to addContent API \n - Fix few bugs \n - More declarative UI keywords - Speed up repairs - Few other improvements/bug fixes
615 lines
26 KiB
HTML
615 lines
26 KiB
HTML
{{ define "repair" }}
|
|
<div class="container mt-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="repairForm">
|
|
<div class="mb-3">
|
|
<label for="arrSelect" class="form-label">Select Arr Instance</label>
|
|
<select class="form-select" id="arrSelect">
|
|
<option value="">Select an Arr instance</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="mediaIds" class="form-label">Media IDs</label>
|
|
<input type="text" class="form-control" id="mediaIds"
|
|
placeholder="Enter IDs (comma-separated)">
|
|
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="isAsync" checked>
|
|
<label class="form-check-label" for="isAsync">
|
|
Run in background
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="autoProcess">
|
|
<label class="form-check-label" for="autoProcess">
|
|
Auto Process(this will delete and re-search broken media)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary" id="submitRepair">
|
|
<i class="bi bi-wrench me-2"></i>Start Repair
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Jobs Table Section -->
|
|
<div class="card mt-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i>Repair Jobs</h4>
|
|
<div>
|
|
<button id="deleteSelectedJobs" class="btn btn-sm btn-danger me-2" disabled>
|
|
<i class="bi bi-trash me-1"></i>Delete Selected
|
|
</button>
|
|
<button id="refreshJobs" class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover" id="jobsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="selectAllJobs">
|
|
</div>
|
|
</th>
|
|
<th>ID</th>
|
|
<th>Arr Instances</th>
|
|
<th>Started</th>
|
|
<th>Status</th>
|
|
<th>Broken Items</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="jobsTableBody">
|
|
<!-- Jobs will be loaded here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<nav aria-label="Jobs pagination" class="mt-3">
|
|
<ul class="pagination justify-content-center" id="jobsPagination">
|
|
<!-- Pagination will be generated here -->
|
|
</ul>
|
|
</nav>
|
|
|
|
<div id="noJobsMessage" class="text-center py-3 d-none">
|
|
<p class="text-muted">No repair jobs found</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Job Details Modal -->
|
|
<div class="modal fade" id="jobDetailsModal" tabindex="-1" aria-labelledby="jobDetailsModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="jobDetailsModalLabel">Job Details</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<p><strong>Job ID:</strong> <span id="modalJobId"></span></p>
|
|
<p><strong>Status:</strong> <span id="modalJobStatus"></span></p>
|
|
<p><strong>Started:</strong> <span id="modalJobStarted"></span></p>
|
|
<p><strong>Completed:</strong> <span id="modalJobCompleted"></span></p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<p><strong>Arrs:</strong> <span id="modalJobArrs"></span></p>
|
|
<p><strong>Media IDs:</strong> <span id="modalJobMediaIds"></span></p>
|
|
<p><strong>Auto Process:</strong> <span id="modalJobAutoProcess"></span></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="errorContainer" class="alert alert-danger mb-3 d-none">
|
|
<strong>Error:</strong> <span id="modalJobError"></span>
|
|
</div>
|
|
|
|
<h6>Broken Items</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Arr</th>
|
|
<th>Path</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="brokenItemsTableBody">
|
|
<!-- Broken items will be loaded here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div id="noBrokenItemsMessage" class="text-center py-2 d-none">
|
|
<p class="text-muted">No broken items found</p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
<button type="button" class="btn btn-primary" id="processJobBtn">Process Items</button>
|
|
<button type="button" class="btn btn-warning d-none" id="stopJobBtn">
|
|
<i class="bi bi-stop-fill me-1"></i>Stop Job
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Load Arr instances
|
|
fetcher('/api/arrs')
|
|
.then(response => response.json())
|
|
.then(arrs => {
|
|
const select = document.getElementById('arrSelect');
|
|
arrs.forEach(arr => {
|
|
const option = document.createElement('option');
|
|
option.value = arr.name;
|
|
option.textContent = arr.name;
|
|
select.appendChild(option);
|
|
});
|
|
});
|
|
|
|
// Handle form submission
|
|
document.getElementById('repairForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const submitBtn = document.getElementById('submitRepair');
|
|
const originalText = submitBtn.innerHTML;
|
|
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
|
|
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
|
let arr = document.getElementById('arrSelect').value;
|
|
try {
|
|
const response = await fetcher('/api/repair', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
arr: arr,
|
|
mediaIds: mediaIds,
|
|
async: document.getElementById('isAsync').checked,
|
|
autoProcess: document.getElementById('autoProcess').checked,
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error(await response.text());
|
|
createToast('Repair process initiated successfully!');
|
|
loadJobs(1); // Refresh jobs after submission
|
|
} catch (error) {
|
|
createToast(`Error starting repair: ${error.message}`, 'error');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalText;
|
|
}
|
|
});
|
|
|
|
// Jobs table pagination variables
|
|
let currentPage = 1;
|
|
const itemsPerPage = 10;
|
|
let allJobs = [];
|
|
|
|
// Load jobs function
|
|
async function loadJobs(page) {
|
|
try {
|
|
const response = await fetcher('/api/repair/jobs');
|
|
if (!response.ok) throw new Error('Failed to fetcher jobs');
|
|
|
|
allJobs = await response.json();
|
|
renderJobsTable(page);
|
|
} catch (error) {
|
|
console.error('Error loading jobs:', error);
|
|
createToast(`Error loading jobs: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Return status text and class based on job status
|
|
function getStatus(status) {
|
|
switch (status) {
|
|
case 'started':
|
|
return {text: 'In Progress', class: 'text-primary'};
|
|
case 'failed':
|
|
return {text: 'Failed', class: 'text-danger'};
|
|
case 'completed':
|
|
return {text: 'Completed', class: 'text-success'};
|
|
case 'pending':
|
|
return {text: 'Pending', class: 'text-warning'};
|
|
case 'cancelled':
|
|
return {text: 'Cancelled', class: 'text-secondary'};
|
|
case 'processing':
|
|
return {text: 'Processing', class: 'text-info'};
|
|
default:
|
|
// Return status in title case if unknown
|
|
return {text: status.charAt(0).toUpperCase() + status.slice(1), class: 'text-secondary'};
|
|
}
|
|
}
|
|
|
|
// Render jobs table with pagination
|
|
function renderJobsTable(page) {
|
|
const tableBody = document.getElementById('jobsTableBody');
|
|
const paginationElement = document.getElementById('jobsPagination');
|
|
const noJobsMessage = document.getElementById('noJobsMessage');
|
|
const deleteSelectedBtn = document.getElementById('deleteSelectedJobs');
|
|
|
|
// Clear previous content
|
|
tableBody.innerHTML = '';
|
|
paginationElement.innerHTML = '';
|
|
|
|
document.getElementById('selectAllJobs').checked = false;
|
|
deleteSelectedBtn.disabled = true;
|
|
|
|
if (allJobs.length === 0) {
|
|
noJobsMessage.classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
noJobsMessage.classList.add('d-none');
|
|
|
|
// Calculate pagination
|
|
const totalPages = Math.ceil(allJobs.length / itemsPerPage);
|
|
const startIndex = (page - 1) * itemsPerPage;
|
|
const endIndex = Math.min(startIndex + itemsPerPage, allJobs.length);
|
|
|
|
// Display jobs for current page
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const job = allJobs[i];
|
|
const row = document.createElement('tr');
|
|
|
|
// Format date
|
|
const startedDate = new Date(job.created_at);
|
|
const formattedDate = startedDate.toLocaleString();
|
|
|
|
// Determine status
|
|
let status = getStatus(job.status);
|
|
let canDelete = job.status !== "started";
|
|
let totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0;
|
|
|
|
|
|
row.innerHTML = `
|
|
<td>
|
|
<div class="form-check">
|
|
<input class="form-check-input job-checkbox" type="checkbox" value="${job.id}"
|
|
${canDelete ? '' : 'disabled'} data-can-delete="${canDelete}">
|
|
</div>
|
|
</td>
|
|
<td><a href="#" class="text-link view-job" data-id="${job.id}"><small>${job.id.substring(0, 8)}</small></a></td>
|
|
<td>${job.arrs.join(', ')}</td>
|
|
<td><small>${formattedDate}</small></td>
|
|
<td><span class="${status.class}">${status.text}</span></td>
|
|
<td>${totalItems}</td>
|
|
<td>
|
|
${job.status === "pending" ?
|
|
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
|
|
<i class="bi bi-play-fill"></i> Process
|
|
</button>` :
|
|
`<button class="btn btn-sm btn-primary" disabled>
|
|
<i class="bi bi-eye"></i> Process
|
|
</button>`
|
|
}
|
|
${(job.status === "started" || job.status === "processing") ?
|
|
`<button class="btn btn-sm btn-warning stop-job" data-id="${job.id}">
|
|
<i class="bi bi-stop-fill"></i> Stop
|
|
</button>` :
|
|
''
|
|
}
|
|
${canDelete ?
|
|
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
|
|
<i class="bi bi-trash"></i>
|
|
</button>` :
|
|
`<button class="btn btn-sm btn-danger" disabled>
|
|
<i class="bi bi-trash"></i>
|
|
</button>`
|
|
}
|
|
</td>
|
|
`;
|
|
|
|
tableBody.appendChild(row);
|
|
}
|
|
|
|
// Create pagination
|
|
if (totalPages > 1) {
|
|
// Previous button
|
|
const prevLi = document.createElement('li');
|
|
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
|
|
prevLi.innerHTML = `<a class="page-link" href="#" aria-label="Previous" ${page !== 1 ? `data-page="${page - 1}"` : ''}>
|
|
<span aria-hidden="true">«</span>
|
|
</a>`;
|
|
paginationElement.appendChild(prevLi);
|
|
|
|
// Page numbers
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
const pageLi = document.createElement('li');
|
|
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
|
|
pageLi.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
|
|
paginationElement.appendChild(pageLi);
|
|
}
|
|
|
|
// Next button
|
|
const nextLi = document.createElement('li');
|
|
nextLi.className = `page-item ${page === totalPages ? 'disabled' : ''}`;
|
|
nextLi.innerHTML = `<a class="page-link" href="#" aria-label="Next" ${page !== totalPages ? `data-page="${page + 1}"` : ''}>
|
|
<span aria-hidden="true">»</span>
|
|
</a>`;
|
|
paginationElement.appendChild(nextLi);
|
|
}
|
|
|
|
// Add event listeners to pagination
|
|
document.querySelectorAll('#jobsPagination a[data-page]').forEach(link => {
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const newPage = parseInt(e.currentTarget.dataset.page);
|
|
currentPage = newPage;
|
|
renderJobsTable(newPage);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.job-checkbox').forEach(checkbox => {
|
|
checkbox.addEventListener('change', updateDeleteButtonState);
|
|
});
|
|
|
|
document.querySelectorAll('.delete-job').forEach(button => {
|
|
button.addEventListener('click', (e) => {
|
|
const jobId = e.currentTarget.dataset.id;
|
|
deleteJob(jobId);
|
|
});
|
|
});
|
|
|
|
// Add event listeners to action buttons
|
|
document.querySelectorAll('.process-job').forEach(button => {
|
|
button.addEventListener('click', (e) => {
|
|
const jobId = e.currentTarget.dataset.id;
|
|
processJob(jobId);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.view-job').forEach(button => {
|
|
button.addEventListener('click', (e) => {
|
|
const jobId = e.currentTarget.dataset.id;
|
|
viewJobDetails(jobId);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.stop-job').forEach(button => {
|
|
button.addEventListener('click', (e) => {
|
|
const jobId = e.currentTarget.dataset.id;
|
|
stopJob(jobId);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('selectAllJobs').addEventListener('change', function() {
|
|
const isChecked = this.checked;
|
|
document.querySelectorAll('.job-checkbox:not(:disabled)').forEach(checkbox => {
|
|
checkbox.checked = isChecked;
|
|
});
|
|
updateDeleteButtonState();
|
|
});
|
|
|
|
// Function to update delete button state
|
|
function updateDeleteButtonState() {
|
|
const deleteBtn = document.getElementById('deleteSelectedJobs');
|
|
const selectedCheckboxes = document.querySelectorAll('.job-checkbox:checked');
|
|
deleteBtn.disabled = selectedCheckboxes.length === 0;
|
|
}
|
|
|
|
// Delete selected jobs
|
|
document.getElementById('deleteSelectedJobs').addEventListener('click', async () => {
|
|
const selectedIds = Array.from(
|
|
document.querySelectorAll('.job-checkbox:checked')
|
|
).map(checkbox => checkbox.value);
|
|
|
|
if (!selectedIds.length) return;
|
|
|
|
if (confirm(`Are you sure you want to delete ${selectedIds.length} job(s)?`)) {
|
|
await deleteMultipleJobs(selectedIds);
|
|
}
|
|
});
|
|
|
|
async function deleteJob(jobId) {
|
|
if (confirm('Are you sure you want to delete this job?')) {
|
|
try {
|
|
const response = await fetcher(`/api/repair/jobs`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ ids: [jobId] })
|
|
});
|
|
|
|
if (!response.ok) throw new Error(await response.text());
|
|
createToast('Job deleted successfully');
|
|
await loadJobs(currentPage); // Refresh the jobs list
|
|
} catch (error) {
|
|
createToast(`Error deleting job: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteMultipleJobs(jobIds) {
|
|
try {
|
|
const response = await fetcher(`/api/repair/jobs`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ ids: jobIds })
|
|
});
|
|
|
|
if (!response.ok) throw new Error(await response.text());
|
|
createToast(`${jobIds.length} job(s) deleted successfully`);
|
|
await loadJobs(currentPage); // Refresh the jobs list
|
|
} catch (error) {
|
|
createToast(`Error deleting jobs: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Process job function
|
|
async function processJob(jobId) {
|
|
try {
|
|
const response = await fetcher(`/api/repair/jobs/${jobId}/process`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
});
|
|
|
|
if (!response.ok) throw new Error(await response.text());
|
|
createToast('Job processing started successfully');
|
|
await loadJobs(currentPage); // Refresh the jobs list
|
|
} catch (error) {
|
|
createToast(`Error processing job: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function stopJob(jobId) {
|
|
if (confirm('Are you sure you want to stop this job?')) {
|
|
try {
|
|
const response = await fetcher(`/api/repair/jobs/${jobId}/stop`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
});
|
|
|
|
if (!response.ok) throw new Error(await response.text());
|
|
createToast('Job stop requested successfully');
|
|
await loadJobs(currentPage); // Refresh the jobs list
|
|
} catch (error) {
|
|
createToast(`Error stopping job: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
// View job details function
|
|
function viewJobDetails(jobId) {
|
|
// Find the job
|
|
const job = allJobs.find(j => j.id === jobId);
|
|
if (!job) return;
|
|
|
|
// Prepare modal data
|
|
document.getElementById('modalJobId').textContent = job.id.substring(0, 8);
|
|
|
|
// Format dates
|
|
const startedDate = new Date(job.created_at);
|
|
document.getElementById('modalJobStarted').textContent = startedDate.toLocaleString();
|
|
|
|
if (job.finished_at) {
|
|
const completedDate = new Date(job.finished_at);
|
|
document.getElementById('modalJobCompleted').textContent = completedDate.toLocaleString();
|
|
} else {
|
|
document.getElementById('modalJobCompleted').textContent = 'N/A';
|
|
}
|
|
|
|
// Set status with color
|
|
let status = getStatus(job.status);
|
|
|
|
document.getElementById('modalJobStatus').innerHTML = `<span class="${status.class}">${status.text}</span>`;
|
|
|
|
// Set other job details
|
|
document.getElementById('modalJobArrs').textContent = job.arrs.join(', ');
|
|
document.getElementById('modalJobMediaIds').textContent = job.media_ids && job.media_ids.length > 0 ?
|
|
job.media_ids.join(', ') : 'All';
|
|
document.getElementById('modalJobAutoProcess').textContent = job.auto_process ? 'Yes' : 'No';
|
|
|
|
// Show/hide error message
|
|
const errorContainer = document.getElementById('errorContainer');
|
|
if (job.error) {
|
|
document.getElementById('modalJobError').textContent = job.error;
|
|
errorContainer.classList.remove('d-none');
|
|
} else {
|
|
errorContainer.classList.add('d-none');
|
|
}
|
|
|
|
// Process button visibility
|
|
const processBtn = document.getElementById('processJobBtn');
|
|
if (job.status === 'pending') {
|
|
processBtn.classList.remove('d-none');
|
|
processBtn.onclick = () => {
|
|
processJob(job.id);
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
|
|
modal.hide();
|
|
};
|
|
} else {
|
|
processBtn.classList.add('d-none');
|
|
}
|
|
|
|
// Stop button visibility
|
|
const stopBtn = document.getElementById('stopJobBtn'); // You'll need to add this button to the HTML
|
|
if (job.status === 'started' || job.status === 'processing') {
|
|
stopBtn.classList.remove('d-none');
|
|
stopBtn.onclick = () => {
|
|
stopJob(job.id);
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
|
|
modal.hide();
|
|
};
|
|
} else {
|
|
stopBtn.classList.add('d-none');
|
|
}
|
|
|
|
// Populate broken items table
|
|
const brokenItemsTableBody = document.getElementById('brokenItemsTableBody');
|
|
const noBrokenItemsMessage = document.getElementById('noBrokenItemsMessage');
|
|
brokenItemsTableBody.innerHTML = '';
|
|
|
|
let hasBrokenItems = false;
|
|
|
|
// Check if broken_items exists and has entries
|
|
if (job.broken_items && Object.entries(job.broken_items).length > 0) {
|
|
hasBrokenItems = true;
|
|
|
|
// Loop through each Arr's broken items
|
|
for (const [arrName, items] of Object.entries(job.broken_items)) {
|
|
if (items && items.length > 0) {
|
|
// Add each item to the table
|
|
items.forEach(item => {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${arrName}</td>
|
|
<td><small class="text-muted">${item.path}</small></td>
|
|
`;
|
|
brokenItemsTableBody.appendChild(row);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show/hide no items message
|
|
if (hasBrokenItems) {
|
|
noBrokenItemsMessage.classList.add('d-none');
|
|
} else {
|
|
noBrokenItemsMessage.classList.remove('d-none');
|
|
}
|
|
|
|
// Show the modal
|
|
const modal = new bootstrap.Modal(document.getElementById('jobDetailsModal'));
|
|
modal.show();
|
|
}
|
|
|
|
// Add event listener for refresh button
|
|
document.getElementById('refreshJobs').addEventListener('click', () => {
|
|
loadJobs(currentPage);
|
|
});
|
|
|
|
// Load jobs on page load
|
|
loadJobs(1);
|
|
});
|
|
</script>
|
|
{{ end }} |