From 26e61c5ed740854122a7264e79acfb080780e879 Mon Sep 17 00:00:00 2001
From: Teethat Kamsai <79137211+teethatkamsai@users.noreply.github.com>
Date: Sun, 5 Jul 2026 13:39:04 +0700
Subject: [PATCH] Add yt-dlp web UI import
---
backend/requirements-runtime.txt | 1 +
backend/requirements.txt | 1 +
src/ui/client/EpisodeWorkspace.jsx | 92 ++++++++++--
src/ui/web-server.ts | 219 ++++++++++++++++++++++++++++-
4 files changed, 301 insertions(+), 12 deletions(-)
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.
+
+ )}