// Repair management for Decypharr class RepairManager { constructor() { this.state = { jobs: [], currentJob: null, allBrokenItems: [], filteredItems: [], selectedItems: new Set(), currentPage: 1, currentItemsPage: 1, itemsPerPage: 10, itemsPerModalPage: 20, searchTerm: '', arrFilter: '', pathFilter: '', sortBy: 'created_at', sortDirection: 'desc' }; this.refs = { repairForm: document.getElementById('repairForm'), arrSelect: document.getElementById('arrSelect'), mediaIds: document.getElementById('mediaIds'), isAsync: document.getElementById('isAsync'), autoProcess: document.getElementById('autoProcess'), submitBtn: document.getElementById('submitRepair'), // Jobs table jobsTable: document.getElementById('jobsTable'), jobsTableBody: document.getElementById('jobsTableBody'), jobsPagination: document.getElementById('jobsPagination'), noJobsMessage: document.getElementById('noJobsMessage'), refreshJobs: document.getElementById('refreshJobs'), deleteSelectedJobs: document.getElementById('deleteSelectedJobs'), selectAllJobs: document.getElementById('selectAllJobs'), // Modal elements jobDetailsModal: document.getElementById('jobDetailsModal'), modalJobId: document.getElementById('modalJobId'), modalJobStatus: document.getElementById('modalJobStatus'), modalJobStarted: document.getElementById('modalJobStarted'), modalJobCompleted: document.getElementById('modalJobCompleted'), modalJobArrs: document.getElementById('modalJobArrs'), modalJobMediaIds: document.getElementById('modalJobMediaIds'), modalJobAutoProcess: document.getElementById('modalJobAutoProcess'), modalJobError: document.getElementById('modalJobError'), errorContainer: document.getElementById('errorContainer'), // Broken items brokenItemsTableBody: document.getElementById('brokenItemsTableBody'), itemsPagination: document.getElementById('itemsPagination'), noBrokenItemsMessage: document.getElementById('noBrokenItemsMessage'), noFilteredItemsMessage: document.getElementById('noFilteredItemsMessage'), totalItemsCount: document.getElementById('totalItemsCount'), modalFooterStats: document.getElementById('modalFooterStats'), // Filters itemSearchInput: document.getElementById('itemSearchInput'), arrFilterSelect: document.getElementById('arrFilterSelect'), pathFilterSelect: document.getElementById('pathFilterSelect'), clearFiltersBtn: document.getElementById('clearFiltersBtn'), // Action buttons processJobBtn: document.getElementById('processJobBtn'), stopJobBtn: document.getElementById('stopJobBtn') }; this.init(); } init() { this.bindEvents(); this.loadArrInstances(); this.loadJobs(); this.startAutoRefresh(); } bindEvents() { // Form submission this.refs.repairForm.addEventListener('submit', (e) => this.handleFormSubmit(e)); // Jobs table events this.refs.refreshJobs.addEventListener('click', () => this.loadJobs()); this.refs.deleteSelectedJobs.addEventListener('click', () => this.deleteSelectedJobs()); this.refs.selectAllJobs.addEventListener('change', (e) => this.toggleSelectAllJobs(e.target.checked)); // Modal events this.refs.processJobBtn.addEventListener('click', () => this.processCurrentJob()); this.refs.stopJobBtn.addEventListener('click', () => this.stopCurrentJob()); // Filter events this.refs.itemSearchInput.addEventListener('input', window.decypharrUtils.debounce(() => this.applyFilters(), 300)); this.refs.arrFilterSelect.addEventListener('change', () => this.applyFilters()); this.refs.pathFilterSelect.addEventListener('change', () => this.applyFilters()); this.refs.clearFiltersBtn.addEventListener('click', () => this.clearFilters()); // Table row events (using event delegation) this.refs.jobsTableBody.addEventListener('click', (e) => this.handleJobTableClick(e)); this.refs.brokenItemsTableBody.addEventListener('click', (e) => this.handleItemTableClick(e)); } async loadArrInstances() { try { const response = await window.decypharrUtils.fetcher('/api/arrs'); if (!response.ok) throw new Error('Failed to load Arr instances'); const arrs = await response.json(); // Clear existing options (keep the default one) this.refs.arrSelect.innerHTML = ''; arrs.forEach(arr => { const option = document.createElement('option'); option.value = arr.name; option.textContent = `${arr.name} (${arr.host})`; this.refs.arrSelect.appendChild(option); }); } catch (error) { console.error('Error loading Arr instances:', error); window.decypharrUtils.createToast('Failed to load Arr instances', 'error'); } } async handleFormSubmit(e) { e.preventDefault(); const arr = this.refs.arrSelect.value; const mediaIdsValue = this.refs.mediaIds.value.trim(); const mediaIds = mediaIdsValue ? mediaIdsValue.split(',').map(id => id.trim()).filter(Boolean) : []; try { window.decypharrUtils.setButtonLoading(this.refs.submitBtn, true); const response = await window.decypharrUtils.fetcher('/api/repair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ arr: arr, mediaIds: mediaIds.length > 0 ? mediaIds : null, async: this.refs.isAsync.checked, autoProcess: this.refs.autoProcess.checked }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to start repair'); } const result = await response.json(); window.decypharrUtils.createToast( `Repair job started successfully! Job ID: ${result.job_id?.substring(0, 8) || 'Unknown'}`, 'success' ); // Clear form this.refs.mediaIds.value = ''; // Refresh jobs list await this.loadJobs(); } catch (error) { console.error('Error starting repair:', error); window.decypharrUtils.createToast(`Error starting repair: ${error.message}`, 'error'); } finally { window.decypharrUtils.setButtonLoading(this.refs.submitBtn, false); } } async loadJobs() { try { const response = await window.decypharrUtils.fetcher('/api/repair/jobs'); if (!response.ok) throw new Error('Failed to fetch jobs'); this.state.jobs = await response.json(); this.renderJobsTable(); } catch (error) { console.error('Error loading jobs:', error); window.decypharrUtils.createToast('Error loading repair jobs', 'error'); } } renderJobsTable() { const jobs = this.getSortedJobs(); const totalPages = Math.ceil(jobs.length / this.state.itemsPerPage); const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage; const endIndex = Math.min(startIndex + this.state.itemsPerPage, jobs.length); const pageJobs = jobs.slice(startIndex, endIndex); // Clear table this.refs.jobsTableBody.innerHTML = ''; this.refs.jobsPagination.innerHTML = ''; // Reset selection this.refs.selectAllJobs.checked = false; this.refs.deleteSelectedJobs.disabled = true; if (jobs.length === 0) { this.refs.noJobsMessage.classList.remove('hidden'); return; } this.refs.noJobsMessage.classList.add('hidden'); // Render jobs pageJobs.forEach(job => { const row = this.createJobRow(job); this.refs.jobsTableBody.appendChild(row); }); // Render pagination this.renderJobsPagination(totalPages); // Update selection state this.updateJobSelectionState(); } createJobRow(job) { const row = document.createElement('tr'); row.className = 'hover:bg-base-200 transition-colors'; row.dataset.jobId = job.id; const status = this.getJobStatus(job.status); const startedDate = new Date(job.created_at).toLocaleString(); const totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0; const canDelete = !['started', 'processing'].includes(job.status); row.innerHTML = `
${job.arrs.map(arr => `
${arr}
`).join('')}
${status.text}
${totalItems}
${job.status === 'pending' ? ` ` : ''} ${['started', 'processing'].includes(job.status) ? ` ` : ''} ${canDelete ? ` ` : ` `}
`; return row; } getJobStatus(status) { const statusMap = { 'pending': { text: 'Pending', class: 'badge-warning' }, 'started': { text: 'Running', class: 'badge-primary' }, 'processing': { text: 'Processing', class: 'badge-info' }, 'completed': { text: 'Completed', class: 'badge-success' }, 'failed': { text: 'Failed', class: 'badge-error' }, 'cancelled': { text: 'Cancelled', class: 'badge-ghost' } }; return statusMap[status] || { text: status, class: 'badge-ghost' }; } getSortedJobs() { const jobs = [...this.state.jobs]; jobs.sort((a, b) => { let valueA, valueB; switch (this.state.sortBy) { case 'created_at': valueA = new Date(a.created_at).getTime(); valueB = new Date(b.created_at).getTime(); break; case 'status': valueA = a.status; valueB = b.status; break; case 'arrs': valueA = a.arrs.join(','); valueB = b.arrs.join(','); break; default: valueA = a[this.state.sortBy] || ''; valueB = b[this.state.sortBy] || ''; } if (typeof valueA === 'string') { return this.state.sortDirection === 'asc' ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); } else { return this.state.sortDirection === 'asc' ? valueA - valueB : valueB - valueA; } }); return jobs; } renderJobsPagination(totalPages) { if (totalPages <= 1) return; const pagination = document.createElement('div'); pagination.className = 'join'; // Previous button const prevBtn = document.createElement('button'); prevBtn.className = `join-item btn btn-sm ${this.state.currentPage === 1 ? 'btn-disabled' : ''}`; prevBtn.innerHTML = ''; prevBtn.disabled = this.state.currentPage === 1; if (this.state.currentPage > 1) { prevBtn.addEventListener('click', () => { this.state.currentPage--; this.renderJobsTable(); }); } pagination.appendChild(prevBtn); // Page numbers const maxPageButtons = 5; let startPage = Math.max(1, this.state.currentPage - Math.floor(maxPageButtons / 2)); let endPage = Math.min(totalPages, startPage + maxPageButtons - 1); if (endPage - startPage + 1 < maxPageButtons) { startPage = Math.max(1, endPage - maxPageButtons + 1); } for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement('button'); pageBtn.className = `join-item btn btn-sm ${i === this.state.currentPage ? 'btn-active' : ''}`; pageBtn.textContent = i; pageBtn.addEventListener('click', () => { this.state.currentPage = i; this.renderJobsTable(); }); pagination.appendChild(pageBtn); } // Next button const nextBtn = document.createElement('button'); nextBtn.className = `join-item btn btn-sm ${this.state.currentPage === totalPages ? 'btn-disabled' : ''}`; nextBtn.innerHTML = ''; nextBtn.disabled = this.state.currentPage === totalPages; if (this.state.currentPage < totalPages) { nextBtn.addEventListener('click', () => { this.state.currentPage++; this.renderJobsTable(); }); } pagination.appendChild(nextBtn); this.refs.jobsPagination.appendChild(pagination); } handleJobTableClick(e) { const target = e.target.closest('button'); if (!target) return; const jobId = target.dataset.jobId; if (!jobId) return; if (target.classList.contains('view-job')) { this.viewJobDetails(jobId); } else if (target.classList.contains('process-job')) { this.processJob(jobId); } else if (target.classList.contains('stop-job')) { this.stopJob(jobId); } else if (target.classList.contains('delete-job')) { this.deleteJob(jobId); } // Handle checkbox changes const checkbox = e.target.closest('.job-checkbox'); if (checkbox) { this.updateJobSelectionState(); } } async viewJobDetails(jobId) { const job = this.state.jobs.find(j => j.id === jobId); if (!job) return; this.state.currentJob = job; this.populateJobModal(job); this.refs.jobDetailsModal.showModal(); } populateJobModal(job) { // Basic job info this.refs.modalJobId.textContent = job.id.substring(0, 8); this.refs.modalJobArrs.textContent = job.arrs.join(', '); this.refs.modalJobMediaIds.textContent = job.media_ids && job.media_ids.length > 0 ? job.media_ids.join(', ') : 'All media'; this.refs.modalJobAutoProcess.textContent = job.auto_process ? 'Yes' : 'No'; // Dates this.refs.modalJobStarted.textContent = new Date(job.created_at).toLocaleString(); this.refs.modalJobCompleted.textContent = job.finished_at ? new Date(job.finished_at).toLocaleString() : 'N/A'; // Status const status = this.getJobStatus(job.status); this.refs.modalJobStatus.innerHTML = `${status.text}`; // Error handling if (job.error) { this.refs.modalJobError.textContent = job.error; this.refs.errorContainer.classList.remove('hidden'); } else { this.refs.errorContainer.classList.add('hidden'); } // Action buttons this.refs.processJobBtn.classList.toggle('hidden', job.status !== 'pending'); this.refs.stopJobBtn.classList.toggle('hidden', !['started', 'processing'].includes(job.status)); // Process broken items if (job.broken_items) { this.state.allBrokenItems = this.processItemsData(job.broken_items); this.state.filteredItems = [...this.state.allBrokenItems]; this.populateArrFilter(); this.state.currentItemsPage = 1; this.renderBrokenItemsTable(); } else { this.state.allBrokenItems = []; this.state.filteredItems = []; this.renderBrokenItemsTable(); } this.updateItemsStats(); } processItemsData(brokenItems) { const items = []; Object.entries(brokenItems).forEach(([arrName, itemsArray]) => { if (itemsArray && itemsArray.length > 0) { itemsArray.forEach((item, index) => { items.push({ id: `${arrName}-${index}`, arr: arrName, path: item.path || item.file_path || 'Unknown path', size: item.size || 0, type: this.getFileType(item.path || ''), fileId: item.fileId || item.id || `${arrName}-${index}` }); }); } }); return items; } getFileType(path) { const movieExtensions = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm']; const tvIndicators = ['/TV/', '/Television/', '/Series/', '/Shows/', '/tv/', '/series/']; const pathLower = path.toLowerCase(); // Check for TV indicators first if (tvIndicators.some(indicator => pathLower.includes(indicator.toLowerCase()))) { return 'tv'; } // Check for movie indicators if (movieExtensions.some(ext => pathLower.endsWith(ext))) { return pathLower.includes('/movies/') || pathLower.includes('/films/') ? 'movie' : 'tv'; } return 'other'; } populateArrFilter() { this.refs.arrFilterSelect.innerHTML = ''; const uniqueArrs = [...new Set(this.state.allBrokenItems.map(item => item.arr))]; uniqueArrs.forEach(arr => { const option = document.createElement('option'); option.value = arr; option.textContent = arr; this.refs.arrFilterSelect.appendChild(option); }); } applyFilters() { const searchTerm = this.refs.itemSearchInput.value.toLowerCase(); const arrFilter = this.refs.arrFilterSelect.value; const pathFilter = this.refs.pathFilterSelect.value; this.state.filteredItems = this.state.allBrokenItems.filter(item => { const matchesSearch = !searchTerm || item.path.toLowerCase().includes(searchTerm); const matchesArr = !arrFilter || item.arr === arrFilter; const matchesPath = !pathFilter || item.type === pathFilter; return matchesSearch && matchesArr && matchesPath; }); this.state.currentItemsPage = 1; this.renderBrokenItemsTable(); this.updateItemsStats(); } clearFilters() { this.refs.itemSearchInput.value = ''; this.refs.arrFilterSelect.value = ''; this.refs.pathFilterSelect.value = ''; this.applyFilters(); } renderBrokenItemsTable() { this.refs.brokenItemsTableBody.innerHTML = ''; this.refs.itemsPagination.innerHTML = ''; if (this.state.allBrokenItems.length === 0) { this.refs.noBrokenItemsMessage.classList.remove('hidden'); this.refs.noFilteredItemsMessage.classList.add('hidden'); return; } if (this.state.filteredItems.length === 0) { this.refs.noBrokenItemsMessage.classList.add('hidden'); this.refs.noFilteredItemsMessage.classList.remove('hidden'); return; } this.refs.noBrokenItemsMessage.classList.add('hidden'); this.refs.noFilteredItemsMessage.classList.add('hidden'); // Pagination const totalPages = Math.ceil(this.state.filteredItems.length / this.state.itemsPerModalPage); const startIndex = (this.state.currentItemsPage - 1) * this.state.itemsPerModalPage; const endIndex = Math.min(startIndex + this.state.itemsPerModalPage, this.state.filteredItems.length); const pageItems = this.state.filteredItems.slice(startIndex, endIndex); // Render items pageItems.forEach(item => { const row = this.createBrokenItemRow(item); this.refs.brokenItemsTableBody.appendChild(row); }); // Render pagination this.renderItemsPagination(totalPages); } createBrokenItemRow(item) { const row = document.createElement('tr'); row.className = 'hover:bg-base-200 transition-colors cursor-pointer'; row.dataset.itemId = item.id; const typeColor = { 'movie': 'badge-primary', 'tv': 'badge-secondary', 'other': 'badge-ghost' }; row.innerHTML = `
${window.decypharrUtils.escapeHtml(item.arr)}
${window.decypharrUtils.escapeHtml(item.path)}
${item.type}
${window.decypharrUtils.formatBytes(item.size)} `; return row; } renderItemsPagination(totalPages) { if (totalPages <= 1) return; const pagination = document.createElement('div'); pagination.className = 'join'; // Previous button const prevBtn = document.createElement('button'); prevBtn.className = `join-item btn btn-sm ${this.state.currentItemsPage === 1 ? 'btn-disabled' : ''}`; prevBtn.innerHTML = ''; prevBtn.disabled = this.state.currentItemsPage === 1; if (this.state.currentItemsPage > 1) { prevBtn.addEventListener('click', () => { this.state.currentItemsPage--; this.renderBrokenItemsTable(); }); } pagination.appendChild(prevBtn); // Page numbers const maxButtons = 5; let startPage = Math.max(1, this.state.currentItemsPage - Math.floor(maxButtons / 2)); let endPage = Math.min(totalPages, startPage + maxButtons - 1); for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement('button'); pageBtn.className = `join-item btn btn-sm ${i === this.state.currentItemsPage ? 'btn-active' : ''}`; pageBtn.textContent = i; pageBtn.addEventListener('click', () => { this.state.currentItemsPage = i; this.renderBrokenItemsTable(); }); pagination.appendChild(pageBtn); } // Next button const nextBtn = document.createElement('button'); nextBtn.className = `join-item btn btn-sm ${this.state.currentItemsPage === totalPages ? 'btn-disabled' : ''}`; nextBtn.innerHTML = ''; nextBtn.disabled = this.state.currentItemsPage === totalPages; if (this.state.currentItemsPage < totalPages) { nextBtn.addEventListener('click', () => { this.state.currentItemsPage++; this.renderBrokenItemsTable(); }); } pagination.appendChild(nextBtn); this.refs.itemsPagination.appendChild(pagination); } updateItemsStats() { this.refs.totalItemsCount.textContent = this.state.allBrokenItems.length; this.refs.modalFooterStats.textContent = `Total: ${this.state.allBrokenItems.length} | Filtered: ${this.state.filteredItems.length}`; } // Job management methods async processJob(jobId) { try { const response = await window.decypharrUtils.fetcher(`/api/repair/jobs/${jobId}/process`, { method: 'POST' }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to process job'); } window.decypharrUtils.createToast('Job processing started', 'success'); await this.loadJobs(); } catch (error) { console.error('Error processing job:', error); window.decypharrUtils.createToast(`Error processing job: ${error.message}`, 'error'); } } async stopJob(jobId) { if (!confirm('Are you sure you want to stop this job?')) return; try { const response = await window.decypharrUtils.fetcher(`/api/repair/jobs/${jobId}/stop`, { method: 'POST' }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to stop job'); } window.decypharrUtils.createToast('Job stop requested', 'success'); await this.loadJobs(); } catch (error) { console.error('Error stopping job:', error); window.decypharrUtils.createToast(`Error stopping job: ${error.message}`, 'error'); } } async deleteJob(jobId) { if (!confirm('Are you sure you want to delete this job?')) return; try { const response = await window.decypharrUtils.fetcher('/api/repair/jobs', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [jobId] }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to delete job'); } window.decypharrUtils.createToast('Job deleted successfully', 'success'); await this.loadJobs(); } catch (error) { console.error('Error deleting job:', error); window.decypharrUtils.createToast(`Error deleting job: ${error.message}`, 'error'); } } async deleteSelectedJobs() { const selectedIds = Array.from( document.querySelectorAll('.job-checkbox:checked') ).map(checkbox => checkbox.value); if (selectedIds.length === 0) return; if (!confirm(`Are you sure you want to delete ${selectedIds.length} job(s)?`)) return; try { const response = await window.decypharrUtils.fetcher('/api/repair/jobs', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to delete jobs'); } window.decypharrUtils.createToast(`${selectedIds.length} job(s) deleted successfully`, 'success'); await this.loadJobs(); } catch (error) { console.error('Error deleting jobs:', error); window.decypharrUtils.createToast(`Error deleting jobs: ${error.message}`, 'error'); } } toggleSelectAllJobs(checked) { const checkboxes = document.querySelectorAll('.job-checkbox:not(:disabled)'); checkboxes.forEach(checkbox => { checkbox.checked = checked; }); this.updateJobSelectionState(); } updateJobSelectionState() { const checkboxes = document.querySelectorAll('.job-checkbox'); const checkedBoxes = document.querySelectorAll('.job-checkbox:checked'); const enabledBoxes = document.querySelectorAll('.job-checkbox:not(:disabled)'); // Update delete button state this.refs.deleteSelectedJobs.disabled = checkedBoxes.length === 0; // Update select all checkbox state if (enabledBoxes.length === 0) { this.refs.selectAllJobs.checked = false; this.refs.selectAllJobs.indeterminate = false; } else if (checkedBoxes.length === enabledBoxes.length) { this.refs.selectAllJobs.checked = true; this.refs.selectAllJobs.indeterminate = false; } else if (checkedBoxes.length > 0) { this.refs.selectAllJobs.checked = false; this.refs.selectAllJobs.indeterminate = true; } else { this.refs.selectAllJobs.checked = false; this.refs.selectAllJobs.indeterminate = false; } } // Modal action methods async processCurrentJob() { if (!this.state.currentJob) return; await this.processJob(this.state.currentJob.id); this.refs.jobDetailsModal.close(); } async stopCurrentJob() { if (!this.state.currentJob) return; await this.stopJob(this.state.currentJob.id); this.refs.jobDetailsModal.close(); } handleItemTableClick(e) { const row = e.target.closest('tr'); if (!row) return; const itemId = row.dataset.itemId; if (!itemId) return; // Toggle selection if (this.state.selectedItems.has(itemId)) { this.state.selectedItems.delete(itemId); row.classList.remove('bg-primary/10'); } else { this.state.selectedItems.add(itemId); row.classList.add('bg-primary/10'); } } // Auto-refresh functionality startAutoRefresh() { // Refresh jobs every 10 seconds this.refreshInterval = setInterval(() => { // Only refresh if not on a modal or if there are active jobs const hasActiveJobs = this.state.jobs.some(job => ['started', 'processing', 'pending'].includes(job.status) ); if (hasActiveJobs || !this.refs.jobDetailsModal.open) { this.loadJobs(); } }, 10000); // Handle page visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden) { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } else { if (!this.refreshInterval) { this.startAutoRefresh(); } } }); // Clean up on page unload window.addEventListener('beforeunload', () => { if (this.refreshInterval) { clearInterval(this.refreshInterval); } }); } // Utility methods formatJobDuration(startTime, endTime) { if (!startTime) return 'N/A'; const start = new Date(startTime); const end = endTime ? new Date(endTime) : new Date(); const duration = Math.floor((end - start) / 1000); // seconds return window.decypharrUtils.formatDuration(duration); } getJobProgress(job) { if (!job.broken_items) return 0; const totalItems = Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0); if (totalItems === 0) return 100; // This would need to be implemented based on your API // For now, return based on status switch (job.status) { case 'completed': return 100; case 'failed': return 0; case 'cancelled': return 0; default: return 0; // Would need progress info from API } } // Export functionality async exportJobData(jobId) { const job = this.state.jobs.find(j => j.id === jobId); if (!job) return; const exportData = { job_id: job.id, status: job.status, created_at: job.created_at, finished_at: job.finished_at, arrs: job.arrs, media_ids: job.media_ids, auto_process: job.auto_process, broken_items: job.broken_items, error: job.error }; try { const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `repair-job-${job.id.substring(0, 8)}-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); window.decypharrUtils.createToast('Job data exported successfully', 'success'); } catch (error) { console.error('Error exporting job data:', error); window.decypharrUtils.createToast('Failed to export job data', 'error'); } } // Statistics methods getJobStatistics() { const stats = { total: this.state.jobs.length, pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 }; this.state.jobs.forEach(job => { switch (job.status) { case 'pending': stats.pending++; break; case 'started': case 'processing': stats.running++; break; case 'completed': stats.completed++; break; case 'failed': stats.failed++; break; case 'cancelled': stats.cancelled++; break; } }); return stats; } // Search and filter helpers searchJobs(searchTerm) { if (!searchTerm) return this.state.jobs; const term = searchTerm.toLowerCase(); return this.state.jobs.filter(job => job.id.toLowerCase().includes(term) || job.arrs.some(arr => arr.toLowerCase().includes(term)) || (job.media_ids && job.media_ids.some(id => id.toString().includes(term))) ); } filterJobsByStatus(status) { if (!status) return this.state.jobs; return this.state.jobs.filter(job => job.status === status); } filterJobsByDate(startDate, endDate) { if (!startDate && !endDate) return this.state.jobs; return this.state.jobs.filter(job => { const jobDate = new Date(job.created_at); if (startDate && jobDate < new Date(startDate)) return false; if (endDate && jobDate > new Date(endDate)) return false; return true; }); } // Cleanup methods destroy() { if (this.refreshInterval) { clearInterval(this.refreshInterval); } // Remove event listeners Object.values(this.refs).forEach(ref => { if (ref && ref.removeEventListener) { // Note: In a real implementation, you'd need to keep track of // the specific event listeners to remove them properly } }); } } // Additional utility functions for repair operations const RepairUtils = { // Format repair status for display formatRepairStatus(status, error = null) { const statusConfig = { 'pending': { icon: 'bi-clock', class: 'text-warning', message: 'Waiting to start' }, 'started': { icon: 'bi-play-circle', class: 'text-primary', message: 'Repair in progress' }, 'processing': { icon: 'bi-gear', class: 'text-info', message: 'Processing results' }, 'completed': { icon: 'bi-check-circle', class: 'text-success', message: 'Repair completed successfully' }, 'failed': { icon: 'bi-x-circle', class: 'text-error', message: error || 'Repair failed' }, 'cancelled': { icon: 'bi-stop-circle', class: 'text-warning', message: 'Repair was cancelled' } }; return statusConfig[status] || { icon: 'bi-question-circle', class: 'text-gray-500', message: `Unknown status: ${status}` }; }, // Validate media IDs input validateMediaIds(input) { if (!input || !input.trim()) return { valid: true, ids: [] }; const ids = input.split(',').map(id => id.trim()).filter(Boolean); const invalidIds = ids.filter(id => !/^\d+$/.test(id)); if (invalidIds.length > 0) { return { valid: false, error: `Invalid media IDs: ${invalidIds.join(', ')}. Only numeric IDs are allowed.`, ids: [] }; } return { valid: true, ids }; }, // Generate repair summary generateRepairSummary(job) { if (!job.broken_items) return 'No broken items found'; const itemCounts = Object.entries(job.broken_items).map(([arr, items]) => `${arr}: ${items.length} items` ); const totalItems = Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0); return `Found ${totalItems} broken items across ${Object.keys(job.broken_items).length} Arr instance(s): ${itemCounts.join(', ')}`; }, // Calculate repair completion percentage calculateProgress(job) { // This would need to be implemented based on your API // For now, return based on status switch (job.status) { case 'pending': return 0; case 'started': return 25; case 'processing': return 75; case 'completed': return 100; case 'failed': case 'cancelled': return 0; default: return 0; } } };