From 44ef3ff741dde7e6e9dbfb0f00a9d6ff1925e7c3 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 22 Dec 2025 19:36:25 -0600 Subject: [PATCH] feat: add bulk download action for approved/completed jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Download All Files" option to the bulk actions menu on the All Jobs page. When selected, downloads all assets (source video, VTTs, MP3s for all languages) from jobs in approved_english, approved_source, or completed status. - Shows confirmation modal with eligible/ineligible job counts - Downloads files sequentially with progress indicator - Skips jobs not in approved/completed status with warning 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/routes/jobs/JobsList.tsx | 222 +++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 2 deletions(-) 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