diff --git a/frontend/src/routes/jobs/JobsList.tsx b/frontend/src/routes/jobs/JobsList.tsx index 4f6287c..8305e88 100644 --- a/frontend/src/routes/jobs/JobsList.tsx +++ b/frontend/src/routes/jobs/JobsList.tsx @@ -6,8 +6,12 @@ import { useJobs, useBulkDeleteJobs, useReprocessJob } from '../../hooks/useJob' import { StatusBadge } from '../../components/StatusBadge'; import { useToastContext } from '../../contexts/ToastContext'; import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext'; +import { apiClient } from '../../lib/api'; import type { Job } from '../../types/api'; +// Statuses eligible for bulk download (approved or completed) +const DOWNLOADABLE_STATUSES = ['completed', 'approved_english', 'approved_source']; + const STATUS_OPTIONS = [ { value: '', label: 'All Statuses' }, { value: 'created,ingesting,ai_processing', label: 'Processing' }, @@ -35,9 +39,11 @@ export function JobsList() { const [sortBy, setSortBy] = useState('created_at_desc'); const [currentPage, setCurrentPage] = useState(1); const [selectedJobs, setSelectedJobs] = useState>(new Set()); - const [bulkAction, setBulkAction] = useState<'delete' | 'reprocess' | ''>(''); + const [bulkAction, setBulkAction] = useState<'delete' | 'reprocess' | 'download' | ''>(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showReprocessConfirm, setShowReprocessConfirm] = useState(false); + const [showDownloadConfirm, setShowDownloadConfirm] = useState(false); + const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null); const pageSize = 20; // Update filters from URL params @@ -180,6 +186,123 @@ export function JobsList() { } }; + // Check if a job is eligible for bulk download + const isDownloadableStatus = (status: string) => DOWNLOADABLE_STATUSES.includes(status); + + // Get counts for download confirmation modal + const getDownloadEligibility = () => { + const selectedJobsList = filteredAndSortedJobs.filter(job => selectedJobs.has(job.id)); + const eligibleJobs = selectedJobsList.filter(job => isDownloadableStatus(job.status)); + const ineligibleJobs = selectedJobsList.filter(job => !isDownloadableStatus(job.status)); + return { selectedJobsList, eligibleJobs, ineligibleJobs }; + }; + + const handleBulkDownload = async () => { + setShowDownloadConfirm(false); + + // Filter to eligible jobs + const eligibleJobs = filteredAndSortedJobs.filter( + job => selectedJobs.has(job.id) && isDownloadableStatus(job.status) + ); + + if (eligibleJobs.length === 0) { + toast.error('No eligible jobs to download'); + return; + } + + // Collect all files to download + const allFiles: { url: string; filename: string }[] = []; + + for (const job of eligibleJobs) { + try { + const downloads = await apiClient.getJobDownloads(job.id); + + // Sanitize job title for filename + const safeTitle = job.title.replace(/[^a-zA-Z0-9-_]/g, '_').substring(0, 50); + + // Add source video + if (downloads.downloads.source_video) { + allFiles.push({ + url: downloads.downloads.source_video as string, + filename: `${safeTitle}_source.mp4` + }); + } + + // Add per-language assets + for (const [lang, assets] of Object.entries(downloads.downloads)) { + if (lang === 'source_video') continue; + const langAssets = assets as Record; + + if (langAssets.captions_vtt) { + allFiles.push({ + url: langAssets.captions_vtt, + filename: `${safeTitle}_${lang}_captions.vtt` + }); + } + if (langAssets.audio_description_vtt) { + allFiles.push({ + url: langAssets.audio_description_vtt, + filename: `${safeTitle}_${lang}_audio_description.vtt` + }); + } + if (langAssets.audio_description_mp3) { + allFiles.push({ + url: langAssets.audio_description_mp3, + filename: `${safeTitle}_${lang}_audio_description.mp3` + }); + } + } + } catch (error) { + console.error(`Failed to get downloads for job ${job.id}:`, error); + } + } + + if (allFiles.length === 0) { + toast.error('No files available to download'); + return; + } + + // Sequential download with progress + setDownloadProgress({ current: 0, total: allFiles.length }); + + let successCount = 0; + for (let i = 0; i < allFiles.length; i++) { + const { url, filename } = allFiles[i]; + setDownloadProgress({ current: i + 1, total: allFiles.length }); + + try { + // Fetch and trigger download + const response = await fetch(url); + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); + successCount++; + + // Small delay between downloads to avoid overwhelming browser + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + console.error(`Failed to download ${filename}:`, error); + } + } + + setDownloadProgress(null); + + if (successCount === allFiles.length) { + toast.success(`Downloaded ${allFiles.length} files from ${eligibleJobs.length} job(s)`); + } else { + toast.warning(`Downloaded ${successCount}/${allFiles.length} files. Some downloads failed.`); + } + + clearSelection(); + }; + const isAdmin = user?.role === 'admin'; const isProduction = user?.role === 'production'; const canManageJobs = isAdmin || isProduction; @@ -348,12 +471,13 @@ export function JobsList() { <> {bulkAction === 'delete' && ( @@ -376,6 +500,16 @@ export function JobsList() { )} + {bulkAction === 'download' && ( + + )} + + + + + + + ); + })()} + + {/* Download Progress Indicator */} + {downloadProgress && ( +
+
+
+ + Downloading {downloadProgress.current} of {downloadProgress.total} files... + +
+
+ )}
); } \ No newline at end of file