diff --git a/backend/requirements-runtime.txt b/backend/requirements-runtime.txt index 408f153..8112b0e 100644 --- a/backend/requirements-runtime.txt +++ b/backend/requirements-runtime.txt @@ -7,5 +7,6 @@ numpy>=1.24.0 Pillow>=10.0.0 questionary>=2.0.0 python-dotenv>=1.2.2 +yt-dlp>=2025.6.30 google-api-python-client>=2.0.1 google-auth-oauthlib>=1.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index e5fcbfc..bb6ae7b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,6 +20,7 @@ questionary>=2.0.0 # Utilities python-dotenv>=1.2.2 +yt-dlp>=2025.6.30 # YouTube analytics (optional — only needed for live OAuth sync; CSV import works without these) google-api-python-client>=2.0.1 diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index 8e26116..3784588 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -11,10 +11,12 @@ import { Home as HomeGlyph, Users as UsersGlyph, Inbox, + Download as DownloadGlyph, } from 'lucide-react'; import CopyButton from './CopyButton'; const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; +const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const api = async (path, opts = {}) => { const res = await fetch(`/api${path}`, { headers: { 'Content-Type': 'application/json', ...opts.headers }, ...opts }); let body = null; @@ -528,6 +530,9 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const [logoUploading, setLogoUploading] = useState(false); const [browsing, setBrowsing] = useState(false); + const [downloadingVideo, setDownloadingVideo] = useState(false); + const [downloadJobId, setDownloadJobId] = useState(null); + const downloadStream = useJob(downloadJobId); const [clipHistory, setClipHistory] = useState([]); const [historyOpen, setHistoryOpen] = useState(false); @@ -737,7 +742,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const autoTranscribeRef = useRef(''); useEffect(() => { const vp = videoPath.trim(); - if (transcriptMode === 'whisper' && vp && !transcript && !transcribing && vp !== autoTranscribeRef.current) { + if (transcriptMode === 'whisper' && vp && !isHttpUrl(vp) && !transcript && !transcribing && vp !== autoTranscribeRef.current) { autoTranscribeRef.current = vp; autoTranscribe(vp); } @@ -846,7 +851,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( // Video source URL const videoUrl = previewSrc ? `/api/preview/${previewSrc}` - : videoPath + : videoPath && !isHttpUrl(videoPath) ? `/api/stream-source?path=${encodeURIComponent(videoPath)}` : null; @@ -894,6 +899,49 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( finally { setBrowsing(false); } }, []); + const downloadVideo = async () => { + const url = videoPath.trim(); + if (!url || downloadingVideo) return; + setDownloadingVideo(true); setError(null); + try { + const d = await api('/download-video', { method: 'POST', body: JSON.stringify({ url }) }); + if (d.error) { setError(d.error); setDownloadingVideo(false); return; } + if (d.job_id) { setDownloadJobId(d.job_id); return; } + setError('Download failed: missing job id'); + setDownloadingVideo(false); + } catch (e) { setError('Download failed: ' + e.message); setDownloadingVideo(false); } + }; + + useEffect(() => { + if (!downloadStream) return; + if (downloadStream.status === 'done') { + const d = downloadStream.result; + if (!d?.file_path) { + setError('Download finished without a video file.'); + setDownloadingVideo(false); + setDownloadJobId(null); + return; + } + setFile(d); + setVideoPath(d.file_path); + setTranscript(null); + setCachedTranscript(false); + setTranscriptText(''); + setTranscriptFileName(''); + setSuggestions([]); + setDeselected(new Set()); + setResults([]); + setEnergyData({}); + autoTranscribeRef.current = ''; + setDownloadingVideo(false); + setDownloadJobId(null); + } else if (downloadStream.status === 'error') { + setError('Download failed: ' + (downloadStream.error || 'Unknown error')); + setDownloadingVideo(false); + setDownloadJobId(null); + } + }, [downloadStream?.status]); + const findMoment = async () => { const text = momentText.trim(); if (!text || findingMoment) return; @@ -981,7 +1029,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( parts.push('Then call suggest_clips with your suggestions.'); } const settings = []; - if (videoPath) settings.push(`Video: ${videoPath.split('/').pop()}`); + if (videoPath) settings.push(`Video: ${videoPath.split(/[\\/]/).pop()}`); settings.push(`Style: ${captionStyle}`); settings.push(`Crop: ${cropStrategy}`); if (logoPath) settings.push(`Logo: set`); @@ -991,6 +1039,11 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( }; const copyGeneratePrompt = async () => { + if (isProcessing) return; + if (sourceIsUrl) { + setError('Download the video first, then find best moments.'); + return; + } // If user has pasted a transcript but it hasn't been parsed yet, parse it first if (transcriptMode === 'import' && transcriptText.trim() && !transcript) { setError(null); @@ -1090,7 +1143,8 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( } }; - const isProcessing = phase === 'parsing' || phase === 'suggesting' || phase === 'exporting' || transcribing; + const isProcessing = phase === 'parsing' || phase === 'suggesting' || phase === 'exporting' || transcribing || downloadingVideo; + const sourceIsUrl = isHttpUrl(videoPath); const selectedClips = suggestions.filter((_, i) => !deselected.has(i)); const getExportStatus = (resultIdx) => { @@ -1184,17 +1238,28 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( )} )} - {videoPath && ( + {videoPath && !sourceIsUrl && (
-
{videoPath.split('/').pop()}
+
{videoPath.split(/[\\/]/).pop()}
)} - setVideoPath(e.target.value)} - disabled={isProcessing || browsing} - style={{ marginTop: 8, fontSize: 12, padding: '9px 13px', background: 'var(--bg)', borderColor: videoPath ? 'var(--green-border)' : 'var(--border)' }} /> +
+ setVideoPath(e.target.value)} + disabled={isProcessing || browsing} + onKeyDown={e => { if (e.key === 'Enter' && sourceIsUrl) downloadVideo(); }} + style={{ flex: 1, fontSize: 12, padding: '9px 13px', background: 'var(--bg)', borderColor: videoPath ? 'var(--green-border)' : 'var(--border)' }} /> + +
+ {downloadingVideo && ( +
+
+
+ )}
{/* Transcript */} @@ -1483,8 +1548,13 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( {/* Generate */} {phase === 'idle' && (
+ {sourceIsUrl && ( +
+ Download the video first, then find best moments. +
+ )}