yonagush Claude Sonnet 4.6 commited on
Commit
a72d248
·
1 Parent(s): 31dce00

Fix YouTube DNS failure via Invidious proxy + retheme to chainstreet gold

Browse files

YouTube fix:
- HuggingFace datacenter IPs can't resolve youtube.com (DNS blocked at network level)
- New approach: detect YouTube URLs, route download through Invidious proxy instances
(inv.nadeko.net, invidious.privacydev.net, iv.melmac.space, etc.)
- Invidious /api/v1/videos/{id} gives stream info; ?local=true proxies download
through Invidious so youtube.com is never contacted
- Falls back to yt-dlp for TikTok/Instagram/non-YouTube and if all Invidious fail
- yt-dlp still uses tv_embedded/mweb clients + cookies support

Brand retheme to chainstreet gold:
- primary-500: #E8A020 (chainstreet logo gold, replaces purple #6C63FF)
- accent-500: #FFB800 (amber highlight, replaces pink #FF6B6B)
- dark-900: #0A0907 (warm near-black matching logo background)
- All glows, gradients, buttons, progress bars, scrollbar updated to gold/amber
- Hardcoded hex values updated across Hero, App, BrandKitEditor, ClipCard, api.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

frontend/src/App.jsx CHANGED
@@ -27,13 +27,13 @@ function App() {
27
  style: 'highlight_word',
28
  fontSize: 48,
29
  fontColor: '#FFFFFF',
30
- highlightColor: '#6C63FF',
31
  backgroundColor: 'rgba(0,0,0,0.6)',
32
  position: 'bottom',
33
  },
34
  brandKit: {
35
- primaryColor: '#6C63FF',
36
- secondaryColor: '#FF6B6B',
37
  logoFile: null,
38
  logoPreview: null,
39
  watermarkPosition: 'top-right',
 
27
  style: 'highlight_word',
28
  fontSize: 48,
29
  fontColor: '#FFFFFF',
30
+ highlightColor: '#E8A020',
31
  backgroundColor: 'rgba(0,0,0,0.6)',
32
  position: 'bottom',
33
  },
34
  brandKit: {
35
+ primaryColor: '#E8A020',
36
+ secondaryColor: '#FFB800',
37
  logoFile: null,
38
  logoPreview: null,
39
  watermarkPosition: 'top-right',
frontend/src/components/BrandKitEditor.jsx CHANGED
@@ -7,8 +7,8 @@ import clsx from 'clsx'
7
  * Props: { isOpen, onClose, clipId, jobId }
8
  */
9
  function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
10
- const [primaryColor, setPrimaryColor] = useState('#6C63FF')
11
- const [secondaryColor, setSecondaryColor] = useState('#FF6B6B')
12
  const [watermarkPos, setWatermarkPos] = useState('bottom-right')
13
  const [watermarkOpacity, setWatermarkOpacity] = useState(0.8)
14
  const [font, setFont] = useState('Inter')
@@ -107,7 +107,7 @@ function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
107
  value={primaryColor}
108
  onChange={(e) => setPrimaryColor(e.target.value)}
109
  className="input-field flex-1 text-sm font-mono"
110
- placeholder="#6C63FF"
111
  />
112
  </div>
113
  </div>
@@ -127,7 +127,7 @@ function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
127
  value={secondaryColor}
128
  onChange={(e) => setSecondaryColor(e.target.value)}
129
  className="input-field flex-1 text-sm font-mono"
130
- placeholder="#FF6B6B"
131
  />
132
  </div>
133
  </div>
 
7
  * Props: { isOpen, onClose, clipId, jobId }
8
  */
9
  function BrandKitEditor({ isOpen, onClose, clipId, jobId }) {
10
+ const [primaryColor, setPrimaryColor] = useState('#E8A020')
11
+ const [secondaryColor, setSecondaryColor] = useState('#FFB800')
12
  const [watermarkPos, setWatermarkPos] = useState('bottom-right')
13
  const [watermarkOpacity, setWatermarkOpacity] = useState(0.8)
14
  const [font, setFont] = useState('Inter')
 
107
  value={primaryColor}
108
  onChange={(e) => setPrimaryColor(e.target.value)}
109
  className="input-field flex-1 text-sm font-mono"
110
+ placeholder="#E8A020"
111
  />
112
  </div>
113
  </div>
 
127
  value={secondaryColor}
128
  onChange={(e) => setSecondaryColor(e.target.value)}
129
  className="input-field flex-1 text-sm font-mono"
130
+ placeholder="#FFB800"
131
  />
132
  </div>
133
  </div>
frontend/src/components/CaptionEditor.jsx CHANGED
@@ -10,7 +10,7 @@ function CaptionEditor({ isOpen, onClose, clipId, jobId }) {
10
  const [style, setStyle] = useState('highlight')
11
  const [fontSize, setFontSize] = useState(48)
12
  const [textColor, setTextColor] = useState('#FFFFFF')
13
- const [highlightColor, setHighlightColor] = useState('#6C63FF')
14
  const [bgColor, setBgColor] = useState('#000000')
15
  const [bgOpacity, setBgOpacity] = useState(0.5)
16
  const [position, setPosition] = useState('bottom')
 
10
  const [style, setStyle] = useState('highlight')
11
  const [fontSize, setFontSize] = useState(48)
12
  const [textColor, setTextColor] = useState('#FFFFFF')
13
+ const [highlightColor, setHighlightColor] = useState('#E8A020')
14
  const [bgColor, setBgColor] = useState('#000000')
15
  const [bgOpacity, setBgOpacity] = useState(0.5)
16
  const [position, setPosition] = useState('bottom')
frontend/src/components/ClipCard.jsx CHANGED
@@ -122,7 +122,7 @@ function ClipCard({ clip, jobId, index }) {
122
  </div>
123
  <div className="h-2 bg-white/10 rounded-full overflow-hidden">
124
  <div
125
- className="h-full bg-gradient-to-r from-accent-500 to-pink-500"
126
  style={{ width: `${clip.score_breakdown?.retention || 68}%` }}
127
  />
128
  </div>
@@ -148,7 +148,7 @@ function ClipCard({ clip, jobId, index }) {
148
  </div>
149
  <div className="h-2 bg-white/10 rounded-full overflow-hidden">
150
  <div
151
- className="h-full bg-gradient-to-r from-purple-500 to-pink-500"
152
  style={{ width: `${clip.score_breakdown?.topic || 71}%` }}
153
  />
154
  </div>
 
122
  </div>
123
  <div className="h-2 bg-white/10 rounded-full overflow-hidden">
124
  <div
125
+ className="h-full bg-gradient-to-r from-primary-500 to-accent-500"
126
  style={{ width: `${clip.score_breakdown?.retention || 68}%` }}
127
  />
128
  </div>
 
148
  </div>
149
  <div className="h-2 bg-white/10 rounded-full overflow-hidden">
150
  <div
151
+ className="h-full bg-gradient-to-r from-primary-600 to-accent-500"
152
  style={{ width: `${clip.score_breakdown?.topic || 71}%` }}
153
  />
154
  </div>
frontend/src/components/Hero.jsx CHANGED
@@ -33,7 +33,7 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
33
  reelType: 'Financial News Reel',
34
  numPoints: 7,
35
  ttsVoice: 'Guy - News Anchor (Male)',
36
- accentColor: '#6C63FF',
37
  groqApiKey: '',
38
  })
39
  const [showCookiesHelp, setShowCookiesHelp] = useState(false)
@@ -415,7 +415,7 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
415
  <div className="flex items-center gap-3">
416
  <input
417
  type="color"
418
- value={s.brandKit?.primaryColor || '#6C63FF'}
419
  onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, primaryColor: e.target.value } })}
420
  className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
421
  />
@@ -429,7 +429,7 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
429
  <div className="flex items-center gap-3">
430
  <input
431
  type="color"
432
- value={s.brandKit?.secondaryColor || '#FF6B6B'}
433
  onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, secondaryColor: e.target.value } })}
434
  className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
435
  />
@@ -564,10 +564,10 @@ function Hero({ onSubmit, onArticleSubmit, isLoading, error, settings, onSetting
564
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
565
  {[
566
  { icon: <Zap className="w-6 h-6 text-primary-500" />, bg: 'bg-primary-500/10', title: 'Auto-Clip AI', desc: 'Intelligent scene detection and cutting' },
567
- { icon: <BarChart3 className="w-6 h-6 text-pink-500" />, bg: 'bg-pink-500/10', title: 'Virality Score', desc: 'Real-time engagement predictions' },
568
  { icon: <Palette className="w-6 h-6 text-accent-500" />, bg: 'bg-accent-500/10', title: 'Brand Kit', desc: 'Logo watermark, custom colors & fonts' },
569
  { icon: <Languages className="w-6 h-6 text-cyan-500" />, bg: 'bg-cyan-500/10', title: 'Multi-Language', desc: '50+ languages, auto-translate' },
570
- { icon: <Wand2 className="w-6 h-6 text-purple-500" />, bg: 'bg-purple-500/10', title: 'Hook Rewriter', desc: 'AI-powered opening line optimization' },
571
  { icon: <Newspaper className="w-6 h-6 text-amber-500" />, bg: 'bg-amber-500/10', title: 'Article → Reels', desc: 'Turn any article into a viral reel' },
572
  ].map(({ icon, bg, title, desc }) => (
573
  <div key={title} className="glass p-6 rounded-2xl group hover:shadow-glow transition-all">
 
33
  reelType: 'Financial News Reel',
34
  numPoints: 7,
35
  ttsVoice: 'Guy - News Anchor (Male)',
36
+ accentColor: '#E8A020',
37
  groqApiKey: '',
38
  })
39
  const [showCookiesHelp, setShowCookiesHelp] = useState(false)
 
415
  <div className="flex items-center gap-3">
416
  <input
417
  type="color"
418
+ value={s.brandKit?.primaryColor || '#E8A020'}
419
  onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, primaryColor: e.target.value } })}
420
  className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
421
  />
 
429
  <div className="flex items-center gap-3">
430
  <input
431
  type="color"
432
+ value={s.brandKit?.secondaryColor || '#FFB800'}
433
  onChange={(e) => onSettingsChange({ ...s, brandKit: { ...s.brandKit, secondaryColor: e.target.value } })}
434
  className="w-12 h-10 rounded-lg cursor-pointer border border-white/10 bg-transparent"
435
  />
 
564
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
565
  {[
566
  { icon: <Zap className="w-6 h-6 text-primary-500" />, bg: 'bg-primary-500/10', title: 'Auto-Clip AI', desc: 'Intelligent scene detection and cutting' },
567
+ { icon: <BarChart3 className="w-6 h-6 text-accent-500" />, bg: 'bg-accent-500/10', title: 'Virality Score', desc: 'Real-time engagement predictions' },
568
  { icon: <Palette className="w-6 h-6 text-accent-500" />, bg: 'bg-accent-500/10', title: 'Brand Kit', desc: 'Logo watermark, custom colors & fonts' },
569
  { icon: <Languages className="w-6 h-6 text-cyan-500" />, bg: 'bg-cyan-500/10', title: 'Multi-Language', desc: '50+ languages, auto-translate' },
570
+ { icon: <Wand2 className="w-6 h-6 text-primary-400" />, bg: 'bg-primary-500/10', title: 'Hook Rewriter', desc: 'AI-powered opening line optimization' },
571
  { icon: <Newspaper className="w-6 h-6 text-amber-500" />, bg: 'bg-amber-500/10', title: 'Article → Reels', desc: 'Turn any article into a viral reel' },
572
  ].map(({ icon, bg, title, desc }) => (
573
  <div key={title} className="glass p-6 rounded-2xl group hover:shadow-glow transition-all">
frontend/src/index.css CHANGED
@@ -16,7 +16,7 @@ body {
16
  @apply bg-dark-900 text-white antialiased;
17
  }
18
 
19
- /* Scrollbar styling */
20
  ::-webkit-scrollbar {
21
  width: 8px;
22
  height: 8px;
@@ -34,7 +34,7 @@ body {
34
  @apply bg-primary-600;
35
  }
36
 
37
- /* Glass morphism */
38
  .glass {
39
  @apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl;
40
  }
@@ -44,19 +44,22 @@ body {
44
  }
45
 
46
  .glass-lg {
47
- @apply bg-white/10 backdrop-blur-2xl border border-white/20 rounded-3xl;
 
48
  }
49
 
50
- /* Gradient text */
51
  .gradient-text {
52
- @apply bg-gradient-to-r from-primary-500 via-pink-500 to-accent-500 bg-clip-text text-transparent;
 
53
  }
54
 
55
  .gradient-text-sm {
56
- @apply bg-gradient-to-r from-primary-500 to-accent-500 bg-clip-text text-transparent;
 
57
  }
58
 
59
- /* Glow border */
60
  .glow-border {
61
  @apply border border-primary-500/50 shadow-glow;
62
  }
@@ -65,20 +68,22 @@ body {
65
  @apply animate-glow border border-primary-500 shadow-glow;
66
  }
67
 
68
- .glow-border-pink {
69
- @apply border border-accent-500/50 shadow-glow-pink;
70
  }
71
 
72
  /* Upload zone */
73
  .upload-zone {
74
- @apply glass p-8 rounded-3xl border-2 border-dashed border-primary-500/30 hover:border-primary-500 transition-all duration-300 cursor-pointer hover:bg-primary-500/5 hover:shadow-glow;
 
 
75
  }
76
 
77
  .upload-zone.dragover {
78
  @apply border-primary-500 bg-primary-500/10 shadow-glow-lg;
79
  }
80
 
81
- /* Cards with hover effect */
82
  .card-hover {
83
  @apply transition-all duration-300 hover:shadow-glow hover:shadow-lg hover:scale-105;
84
  }
@@ -87,133 +92,108 @@ body {
87
  @apply transition-all duration-200 hover:shadow-lg hover:scale-105;
88
  }
89
 
90
- /* Buttons */
91
  .btn-primary {
92
- @apply bg-gradient-to-r from-primary-500 to-primary-600 text-white px-6 py-2 rounded-lg font-semibold transition-all duration-200 hover:shadow-glow hover:shadow-lg active:scale-95;
 
 
93
  }
94
 
95
  .btn-primary-lg {
96
- @apply bg-gradient-to-r from-primary-500 to-primary-600 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-200 hover:shadow-glow-lg hover:shadow-lg active:scale-95;
 
 
97
  }
98
 
99
  .btn-secondary {
100
- @apply bg-white/10 hover:bg-white/20 text-white px-6 py-2 rounded-lg font-semibold transition-all duration-200 border border-white/20;
 
101
  }
102
 
103
  .btn-secondary-lg {
104
- @apply bg-white/10 hover:bg-white/20 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-200 border border-white/20;
 
105
  }
106
 
107
  .btn-ghost {
108
  @apply text-white/70 hover:text-white transition-colors duration-200;
109
  }
110
 
111
- /* Input styling */
112
  .input-field {
113
- @apply bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:border-primary-500/50 focus:ring-1 focus:ring-primary-500/30 transition-all duration-200;
 
 
114
  }
115
 
116
  .input-field-lg {
117
- @apply bg-white/5 border border-white/10 rounded-xl px-6 py-3 text-white placeholder-white/50 focus:outline-none focus:border-primary-500/50 focus:ring-1 focus:ring-primary-500/30 transition-all duration-200;
 
 
118
  }
119
 
120
- /* Badge styling */
121
  .badge {
122
- @apply inline-block px-3 py-1 rounded-full text-sm font-medium bg-primary-500/20 text-primary-500 border border-primary-500/30;
 
123
  }
124
 
125
- .badge-pink {
126
- @apply inline-block px-3 py-1 rounded-full text-sm font-medium bg-accent-500/20 text-accent-500 border border-accent-500/30;
 
127
  }
128
 
129
  .badge-success {
130
- @apply inline-block px-3 py-1 rounded-full text-sm font-medium bg-green-500/20 text-green-400 border border-green-500/30;
 
131
  }
132
 
133
- /* Progress bar with gradient */
134
  .progress-gradient {
135
- @apply h-1 rounded-full bg-gradient-to-r from-primary-500 via-pink-500 to-accent-500 transition-all duration-300;
 
136
  }
137
 
138
  /* Overlay */
139
  .overlay {
140
- @apply fixed inset-0 bg-black/40 backdrop-blur-sm;
141
  }
142
 
143
- /* Smooth transitions */
144
- .transition-smooth {
145
- @apply transition-all duration-300 ease-out;
146
- }
147
-
148
- /* Text truncation */
149
- .truncate-line {
150
- @apply line-clamp-1;
151
- }
152
-
153
- .truncate-2 {
154
- @apply line-clamp-2;
155
- }
156
-
157
- .truncate-3 {
158
- @apply line-clamp-3;
159
- }
160
 
161
- /* Focus visible for accessibility */
162
  .focus-visible {
163
- @apply focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-dark-900;
 
164
  }
165
 
166
- /* Custom animations */
 
 
 
 
 
 
167
  @keyframes shimmer {
168
- 0% {
169
- background-position: -1000px 0;
170
- }
171
- 100% {
172
- background-position: 1000px 0;
173
- }
174
  }
175
 
176
  .shimmer {
177
  animation: shimmer 2s infinite;
178
  background: linear-gradient(
179
  to right,
180
- rgba(255, 255, 255, 0),
181
- rgba(255, 255, 255, 0.1),
182
- rgba(255, 255, 255, 0)
183
  );
184
  background-size: 1000px 100%;
185
  }
186
 
187
- /* Aspect ratio utilities */
188
- .aspect-9-16 {
189
- @apply aspect-[9/16];
190
- }
191
-
192
- .aspect-4-5 {
193
- @apply aspect-[4/5];
194
- }
195
-
196
- .aspect-16-9 {
197
- @apply aspect-[16/9];
198
- }
199
-
200
- /* Utility classes */
201
- .center {
202
- @apply flex items-center justify-center;
203
- }
204
-
205
- .flex-center {
206
- @apply flex items-center justify-center;
207
- }
208
-
209
- .flex-between {
210
- @apply flex items-center justify-between;
211
- }
212
-
213
- .flex-col-center {
214
- @apply flex flex-col items-center justify-center;
215
- }
216
-
217
- .text-balance {
218
- text-wrap: balance;
219
- }
 
16
  @apply bg-dark-900 text-white antialiased;
17
  }
18
 
19
+ /* Scrollbar gold on dark */
20
  ::-webkit-scrollbar {
21
  width: 8px;
22
  height: 8px;
 
34
  @apply bg-primary-600;
35
  }
36
 
37
+ /* Glass morphism — warm-tinted glass */
38
  .glass {
39
  @apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl;
40
  }
 
44
  }
45
 
46
  .glass-lg {
47
+ background: rgba(20, 16, 8, 0.55);
48
+ @apply backdrop-blur-2xl border border-white/10 rounded-3xl;
49
  }
50
 
51
+ /* Gradient text — gold to amber (matches chainstreet logo palette) */
52
  .gradient-text {
53
+ background: linear-gradient(135deg, #E8A020 0%, #FFB800 60%, #F5C842 100%);
54
+ @apply bg-clip-text text-transparent;
55
  }
56
 
57
  .gradient-text-sm {
58
+ background: linear-gradient(135deg, #E8A020 0%, #FFB800 100%);
59
+ @apply bg-clip-text text-transparent;
60
  }
61
 
62
+ /* Glow borders */
63
  .glow-border {
64
  @apply border border-primary-500/50 shadow-glow;
65
  }
 
68
  @apply animate-glow border border-primary-500 shadow-glow;
69
  }
70
 
71
+ .glow-border-amber {
72
+ @apply border border-accent-500/50 shadow-glow-amber;
73
  }
74
 
75
  /* Upload zone */
76
  .upload-zone {
77
+ @apply glass p-8 rounded-3xl border-2 border-dashed border-primary-500/30
78
+ hover:border-primary-500 transition-all duration-300 cursor-pointer
79
+ hover:bg-primary-500/5 hover:shadow-glow;
80
  }
81
 
82
  .upload-zone.dragover {
83
  @apply border-primary-500 bg-primary-500/10 shadow-glow-lg;
84
  }
85
 
86
+ /* Cards */
87
  .card-hover {
88
  @apply transition-all duration-300 hover:shadow-glow hover:shadow-lg hover:scale-105;
89
  }
 
92
  @apply transition-all duration-200 hover:shadow-lg hover:scale-105;
93
  }
94
 
95
+ /* Buttons — gold gradient */
96
  .btn-primary {
97
+ background: linear-gradient(135deg, #E8A020, #D4901A);
98
+ @apply text-black px-6 py-2 rounded-lg font-semibold transition-all duration-200
99
+ hover:shadow-glow hover:shadow-lg active:scale-95;
100
  }
101
 
102
  .btn-primary-lg {
103
+ background: linear-gradient(135deg, #E8A020, #D4901A);
104
+ @apply text-black px-8 py-3 rounded-xl font-bold transition-all duration-200
105
+ hover:shadow-glow-lg hover:shadow-lg active:scale-95;
106
  }
107
 
108
  .btn-secondary {
109
+ @apply bg-white/10 hover:bg-white/20 text-white px-6 py-2 rounded-lg font-semibold
110
+ transition-all duration-200 border border-white/20;
111
  }
112
 
113
  .btn-secondary-lg {
114
+ @apply bg-white/10 hover:bg-white/20 text-white px-8 py-3 rounded-xl font-semibold
115
+ transition-all duration-200 border border-white/20;
116
  }
117
 
118
  .btn-ghost {
119
  @apply text-white/70 hover:text-white transition-colors duration-200;
120
  }
121
 
122
+ /* Inputs */
123
  .input-field {
124
+ @apply bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-white
125
+ placeholder-white/40 focus:outline-none focus:border-primary-500/60
126
+ focus:ring-1 focus:ring-primary-500/30 transition-all duration-200;
127
  }
128
 
129
  .input-field-lg {
130
+ @apply bg-white/5 border border-white/10 rounded-xl px-6 py-3 text-white
131
+ placeholder-white/40 focus:outline-none focus:border-primary-500/60
132
+ focus:ring-1 focus:ring-primary-500/30 transition-all duration-200;
133
  }
134
 
135
+ /* Badges */
136
  .badge {
137
+ @apply inline-block px-3 py-1 rounded-full text-sm font-medium
138
+ bg-primary-500/20 text-primary-400 border border-primary-500/30;
139
  }
140
 
141
+ .badge-amber {
142
+ @apply inline-block px-3 py-1 rounded-full text-sm font-medium
143
+ bg-accent-500/20 text-accent-400 border border-accent-500/30;
144
  }
145
 
146
  .badge-success {
147
+ @apply inline-block px-3 py-1 rounded-full text-sm font-medium
148
+ bg-green-500/20 text-green-400 border border-green-500/30;
149
  }
150
 
151
+ /* Progress bar gold gradient */
152
  .progress-gradient {
153
+ background: linear-gradient(90deg, #E8A020, #FFB800, #F5C842);
154
+ @apply h-1 rounded-full transition-all duration-300;
155
  }
156
 
157
  /* Overlay */
158
  .overlay {
159
+ @apply fixed inset-0 bg-black/50 backdrop-blur-sm;
160
  }
161
 
162
+ /* Utility */
163
+ .transition-smooth { @apply transition-all duration-300 ease-out; }
164
+ .truncate-line { @apply line-clamp-1; }
165
+ .truncate-2 { @apply line-clamp-2; }
166
+ .truncate-3 { @apply line-clamp-3; }
 
 
 
 
 
 
 
 
 
 
 
 
167
 
 
168
  .focus-visible {
169
+ @apply focus:outline-none focus:ring-2 focus:ring-primary-500/50
170
+ focus:ring-offset-2 focus:ring-offset-dark-900;
171
  }
172
 
173
+ .center { @apply flex items-center justify-center; }
174
+ .flex-center { @apply flex items-center justify-center; }
175
+ .flex-between { @apply flex items-center justify-between; }
176
+ .flex-col-center { @apply flex flex-col items-center justify-center; }
177
+ .text-balance { text-wrap: balance; }
178
+
179
+ /* Shimmer loading effect */
180
  @keyframes shimmer {
181
+ 0% { background-position: -1000px 0; }
182
+ 100% { background-position: 1000px 0; }
 
 
 
 
183
  }
184
 
185
  .shimmer {
186
  animation: shimmer 2s infinite;
187
  background: linear-gradient(
188
  to right,
189
+ rgba(255,255,255,0),
190
+ rgba(232,160,32,0.08),
191
+ rgba(255,255,255,0)
192
  );
193
  background-size: 1000px 100%;
194
  }
195
 
196
+ /* Aspect ratio helpers */
197
+ .aspect-9-16 { @apply aspect-[9/16]; }
198
+ .aspect-4-5 { @apply aspect-[4/5]; }
199
+ .aspect-16-9 { @apply aspect-[16/9]; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/utils/api.js CHANGED
@@ -139,7 +139,7 @@ export async function generateArticleReels(articleUrl, options = {}) {
139
  formData.append('reel_type', options.reelType || 'Financial News Reel')
140
  formData.append('num_points', options.numPoints || 7)
141
  formData.append('tts_voice', options.ttsVoice || 'Guy - News Anchor (Male)')
142
- formData.append('accent_hex', options.accentColor || '#6C63FF')
143
  formData.append('groq_api_key', options.groqApiKey || '')
144
 
145
  const response = await fetch(`${API_BASE}/article-reels`, {
 
139
  formData.append('reel_type', options.reelType || 'Financial News Reel')
140
  formData.append('num_points', options.numPoints || 7)
141
  formData.append('tts_voice', options.ttsVoice || 'Guy - News Anchor (Male)')
142
+ formData.append('accent_hex', options.accentColor || '#E8A020')
143
  formData.append('groq_api_key', options.groqApiKey || '')
144
 
145
  const response = await fetch(`${API_BASE}/article-reels`, {
frontend/tailwind.config.js CHANGED
@@ -7,87 +7,103 @@ export default {
7
  theme: {
8
  extend: {
9
  colors: {
 
10
  primary: {
11
- 50: '#f0ecff',
12
- 100: '#e8e0ff',
13
- 500: '#6C63FF',
14
- 600: '#5e54e6',
15
- 700: '#4d42cc',
16
- 800: '#3a30b3',
17
- 900: '#2a219a',
 
 
 
18
  },
 
19
  accent: {
20
- 50: '#fff5f5',
21
- 100: '#ffe0e0',
22
- 500: '#FF6B6B',
23
- 600: '#ff5252',
24
- 700: '#ff3838',
25
- 800: '#e63946',
 
 
 
 
 
 
 
 
 
 
26
  },
27
  dark: {
28
- 50: '#f0f0f5',
29
- 100: '#e8e8f0',
30
- 900: '#0A0A0F',
31
- 850: '#1a1a22',
32
- 800: '#2a2a35',
33
- 700: '#3a3a47',
34
  },
35
  },
36
  fontFamily: {
37
  inter: ['Inter', 'sans-serif'],
38
  },
39
  backdropBlur: {
40
- xs: '2px',
41
- sm: '4px',
42
- md: '6px',
43
- lg: '8px',
44
- xl: '12px',
45
- '2xl': '16px',
46
  },
47
  boxShadow: {
48
- glow: '0 0 20px rgba(108, 99, 255, 0.3)',
49
- 'glow-lg': '0 0 40px rgba(108, 99, 255, 0.4)',
50
- 'glow-pink': '0 0 20px rgba(255, 107, 107, 0.3)',
51
- glass: '0 8px 32px rgba(31, 38, 135, 0.37)',
 
52
  },
53
  animation: {
54
- float: 'float 3s ease-in-out infinite',
55
- 'float-delayed': 'float 3s ease-in-out infinite 0.5s',
56
- glow: 'glow 2s ease-in-out infinite',
57
- 'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
58
- 'slide-up': 'slide-up 0.5s ease-out',
59
- 'slide-down': 'slide-down 0.5s ease-out',
60
- 'fade-in': 'fade-in 0.5s ease-out',
61
- 'scale-in': 'scale-in 0.3s ease-out',
62
  },
63
  keyframes: {
64
  float: {
65
  '0%, 100%': { transform: 'translateY(0px)' },
66
- '50%': { transform: 'translateY(-10px)' },
67
  },
68
  glow: {
69
- '0%, 100%': { boxShadow: '0 0 20px rgba(108, 99, 255, 0.3)' },
70
- '50%': { boxShadow: '0 0 40px rgba(108, 99, 255, 0.5)' },
71
  },
72
  'pulse-glow': {
73
  '0%, 100%': { opacity: '1' },
74
- '50%': { opacity: '0.7' },
75
  },
76
  'slide-up': {
77
  from: { opacity: '0', transform: 'translateY(10px)' },
78
- to: { opacity: '1', transform: 'translateY(0)' },
79
  },
80
  'slide-down': {
81
  from: { opacity: '0', transform: 'translateY(-10px)' },
82
- to: { opacity: '1', transform: 'translateY(0)' },
83
  },
84
  'fade-in': {
85
  from: { opacity: '0' },
86
- to: { opacity: '1' },
87
  },
88
  'scale-in': {
89
  from: { opacity: '0', transform: 'scale(0.95)' },
90
- to: { opacity: '1', transform: 'scale(1)' },
91
  },
92
  },
93
  transitionProperty: {
 
7
  theme: {
8
  extend: {
9
  colors: {
10
+ // Chainstreet gold — from the {} logo mark
11
  primary: {
12
+ 50: '#fef9ec',
13
+ 100: '#fdf0cc',
14
+ 200: '#fbe099',
15
+ 300: '#f8c866',
16
+ 400: '#f5b033',
17
+ 500: '#E8A020', // Core chainstreet gold
18
+ 600: '#d4901a',
19
+ 700: '#b87a10',
20
+ 800: '#96620a',
21
+ 900: '#7a4e04',
22
  },
23
+ // Amber highlight — lighter, used for gradients and accents
24
  accent: {
25
+ 50: '#fffbf0',
26
+ 100: '#fff3d6',
27
+ 200: '#ffe7ad',
28
+ 300: '#ffd980',
29
+ 400: '#ffca52',
30
+ 500: '#FFB800', // Bright amber complement
31
+ 600: '#e6a600',
32
+ 700: '#cc9300',
33
+ 800: '#b38000',
34
+ 900: '#996d00',
35
+ },
36
+ // Chainstreet gray — from the { side of the logo
37
+ slate: {
38
+ 500: '#7A7A7A',
39
+ 600: '#6B6B6B',
40
+ 700: '#5C5C5C',
41
  },
42
  dark: {
43
+ 50: '#f0f0ec',
44
+ 100: '#e8e8e0',
45
+ 900: '#0A0907', // Near-black with warm tint (matches logo bg)
46
+ 850: '#141210',
47
+ 800: '#1e1b16',
48
+ 700: '#2a2620',
49
  },
50
  },
51
  fontFamily: {
52
  inter: ['Inter', 'sans-serif'],
53
  },
54
  backdropBlur: {
55
+ xs: '2px',
56
+ sm: '4px',
57
+ md: '6px',
58
+ lg: '8px',
59
+ xl: '12px',
60
+ '2xl':'16px',
61
  },
62
  boxShadow: {
63
+ // Gold glows
64
+ glow: '0 0 20px rgba(232, 160, 32, 0.35)',
65
+ 'glow-lg': '0 0 40px rgba(232, 160, 32, 0.45)',
66
+ 'glow-amber':'0 0 20px rgba(255, 184, 0, 0.35)',
67
+ glass: '0 8px 32px rgba(20, 14, 4, 0.6)',
68
  },
69
  animation: {
70
+ float: 'float 3s ease-in-out infinite',
71
+ 'float-delayed': 'float 3s ease-in-out infinite 0.5s',
72
+ glow: 'glow 2s ease-in-out infinite',
73
+ 'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
74
+ 'slide-up': 'slide-up 0.5s ease-out',
75
+ 'slide-down': 'slide-down 0.5s ease-out',
76
+ 'fade-in': 'fade-in 0.5s ease-out',
77
+ 'scale-in': 'scale-in 0.3s ease-out',
78
  },
79
  keyframes: {
80
  float: {
81
  '0%, 100%': { transform: 'translateY(0px)' },
82
+ '50%': { transform: 'translateY(-10px)' },
83
  },
84
  glow: {
85
+ '0%, 100%': { boxShadow: '0 0 20px rgba(232, 160, 32, 0.35)' },
86
+ '50%': { boxShadow: '0 0 40px rgba(232, 160, 32, 0.55)' },
87
  },
88
  'pulse-glow': {
89
  '0%, 100%': { opacity: '1' },
90
+ '50%': { opacity: '0.7' },
91
  },
92
  'slide-up': {
93
  from: { opacity: '0', transform: 'translateY(10px)' },
94
+ to: { opacity: '1', transform: 'translateY(0)' },
95
  },
96
  'slide-down': {
97
  from: { opacity: '0', transform: 'translateY(-10px)' },
98
+ to: { opacity: '1', transform: 'translateY(0)' },
99
  },
100
  'fade-in': {
101
  from: { opacity: '0' },
102
+ to: { opacity: '1' },
103
  },
104
  'scale-in': {
105
  from: { opacity: '0', transform: 'scale(0.95)' },
106
+ to: { opacity: '1', transform: 'scale(1)' },
107
  },
108
  },
109
  transitionProperty: {
services/downloader.py CHANGED
@@ -1,51 +1,184 @@
1
  """
2
- Video downloader using yt-dlp for YouTube, TikTok, Instagram, and direct uploads
 
 
 
 
3
  """
4
 
5
  import asyncio
6
  import logging
 
7
  from pathlib import Path
8
- from typing import Dict, Optional
9
 
10
  import ffmpeg
 
11
  import yt_dlp
12
 
13
  logger = logging.getLogger(__name__)
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  class VideoDownloader:
17
- """Download videos from various sources using yt-dlp"""
18
 
19
  def __init__(self):
20
- """Initialize the video downloader"""
21
  self.logger = logger
22
 
 
 
 
 
23
  async def download(
24
  self, url: str, output_dir: Path, cookies_path: Optional[str] = None
25
  ) -> Dict[str, any]:
26
  """
27
- Download video from URL (YouTube, TikTok, Instagram, etc.)
 
 
 
 
28
 
29
  Args:
30
- url: Video URL
31
  output_dir: Directory to save the video
32
- cookies_path: Optional path to cookies.txt (Netscape format) for
33
- authenticated/unblocked YouTube requests on datacenter IPs
34
 
35
  Returns:
36
- Dictionary with keys: path, title, duration, thumbnail_url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
- Raises:
39
- Exception: If download fails
 
 
 
40
  """
41
- try:
42
- output_dir.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ydl_opts = {
45
  "format": "best[height<=1080]/best",
46
  "quiet": True,
47
  "no_warnings": True,
48
- # Use video ID in filename — avoids special-char title issues
49
  "outtmpl": str(output_dir / "%(id)s.%(ext)s"),
50
  "socket_timeout": 60,
51
  "retries": 10,
@@ -61,52 +194,36 @@ class VideoDownloader:
61
  ),
62
  "Accept-Language": "en-US,en;q=0.9",
63
  },
64
- # tv_embedded + mweb are far less aggressively blocked on datacenter IPs
65
- # than android_embedded (which YouTube actively rate-limits on server IPs)
66
  "extractor_args": {
67
  "youtube": {"player_client": ["tv_embedded", "mweb", "web"]},
68
  },
69
  }
70
-
71
- # Cookies bypass YouTube's datacenter IP restrictions
72
  if cookies_path:
73
  ydl_opts["cookiefile"] = cookies_path
74
  self.logger.info(f"Using cookies file: {cookies_path}")
75
 
76
- # Run yt-dlp in thread pool to avoid blocking
77
  loop = asyncio.get_event_loop()
78
  result = await loop.run_in_executor(
79
  None, self._download_with_ytdlp, url, ydl_opts
80
  )
81
-
82
- self.logger.info(f"Successfully downloaded video: {result['title']}")
83
  return result
84
 
85
  except Exception as e:
86
- self.logger.error(f"Failed to download video from {url}: {str(e)}")
87
- raise Exception(f"Video download failed: {str(e)}")
88
 
89
  def _download_with_ytdlp(self, url: str, opts: Dict) -> Dict[str, any]:
90
- """
91
- Helper method to run yt-dlp download (blocking operation)
92
-
93
- Args:
94
- url: Video URL
95
- opts: yt-dlp options
96
-
97
- Returns:
98
- Dictionary with video metadata
99
- """
100
  with yt_dlp.YoutubeDL(opts) as ydl:
101
  info = ydl.extract_info(url, download=True)
102
 
103
- # Find the downloaded file (outtmpl uses %(id)s now)
104
  video_id = info.get("id", "video")
105
  video_ext = info.get("ext", "mp4")
106
- video_path = Path(opts["outtmpl"].replace("%(id)s", video_id).replace("%(ext)s", video_ext))
 
 
107
 
108
  if not video_path.exists():
109
- # Fallback: scan directory for recently created video files
110
  dl_dir = Path(opts["outtmpl"].split("%(")[0])
111
  files = sorted(dl_dir.glob(f"{video_id}.*"), key=lambda p: p.stat().st_mtime)
112
  if not files:
@@ -123,20 +240,11 @@ class VideoDownloader:
123
  "thumbnail_url": info.get("thumbnail", ""),
124
  }
125
 
126
- async def extract_audio(self, video_path: Path, output_dir: Path) -> Path:
127
- """
128
- Extract audio from video as WAV file using ffmpeg
129
-
130
- Args:
131
- video_path: Path to video file
132
- output_dir: Directory to save audio
133
-
134
- Returns:
135
- Path to extracted WAV file
136
 
137
- Raises:
138
- Exception: If extraction fails
139
- """
140
  try:
141
  output_dir.mkdir(parents=True, exist_ok=True)
142
  audio_path = output_dir / f"{video_path.stem}.wav"
@@ -146,27 +254,19 @@ class VideoDownloader:
146
  None, self._extract_audio_sync, str(video_path), str(audio_path)
147
  )
148
 
149
- self.logger.info(f"Successfully extracted audio to {audio_path}")
150
  return audio_path
151
 
152
  except Exception as e:
153
- self.logger.error(f"Failed to extract audio: {str(e)}")
154
- raise Exception(f"Audio extraction failed: {str(e)}")
155
 
156
  def _extract_audio_sync(self, video_path: str, audio_path: str) -> None:
157
- """
158
- Synchronous audio extraction using ffmpeg-python
159
-
160
- Args:
161
- video_path: Path to input video
162
- audio_path: Path to output audio
163
- """
164
  try:
165
  stream = ffmpeg.input(video_path)
166
  stream = ffmpeg.output(stream, audio_path, acodec="pcm_s16le", ac=1, ar=16000)
167
  ffmpeg.run(stream, capture_stdout=True, capture_stderr=True, quiet=True)
168
- except ffmpeg.Error as e:
169
- # Try alternative approach
170
  import subprocess
171
  subprocess.run(
172
  ["ffmpeg", "-i", video_path, "-acodec", "pcm_s16le", "-ac", "1", "-ar", "16000", audio_path],
 
1
  """
2
+ Video downloader using yt-dlp for YouTube, TikTok, Instagram, and direct uploads.
3
+
4
+ YouTube on HuggingFace Spaces fails with DNS errors because the datacenter IP is
5
+ blocked at the network level by YouTube. Fix: route through Invidious (an open
6
+ YouTube proxy) — no youtube.com DNS lookup needed.
7
  """
8
 
9
  import asyncio
10
  import logging
11
+ import re
12
  from pathlib import Path
13
+ from typing import Dict, List, Optional
14
 
15
  import ffmpeg
16
+ import httpx
17
  import yt_dlp
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
+ # Public Invidious instances — tried in order, first success wins.
22
+ # List sourced from https://docs.invidious.io/instances/ (updated 2025)
23
+ INVIDIOUS_INSTANCES: List[str] = [
24
+ "https://inv.nadeko.net",
25
+ "https://invidious.privacydev.net",
26
+ "https://iv.melmac.space",
27
+ "https://yt.cdaut.de",
28
+ "https://invidious.perennialte.ch",
29
+ "https://invidious.nerdvpn.de",
30
+ "https://yewtu.be",
31
+ ]
32
+
33
+ _YT_ID_RE = re.compile(
34
+ r"(?:youtube\.com/(?:watch\?.*?v=|embed/|shorts/)|youtu\.be/)([a-zA-Z0-9_-]{11})"
35
+ )
36
+
37
+
38
+ def _extract_youtube_id(url: str) -> Optional[str]:
39
+ m = _YT_ID_RE.search(url)
40
+ return m.group(1) if m else None
41
+
42
 
43
  class VideoDownloader:
44
+ """Download videos from various sources using yt-dlp (YouTube via Invidious proxy)"""
45
 
46
  def __init__(self):
 
47
  self.logger = logger
48
 
49
+ # ------------------------------------------------------------------
50
+ # Public API
51
+ # ------------------------------------------------------------------
52
+
53
  async def download(
54
  self, url: str, output_dir: Path, cookies_path: Optional[str] = None
55
  ) -> Dict[str, any]:
56
  """
57
+ Download video from URL.
58
+
59
+ For YouTube URLs the download is routed through an Invidious instance so
60
+ that youtube.com is never contacted directly (HuggingFace datacenter IPs
61
+ are blocked by YouTube at the DNS/network level).
62
 
63
  Args:
64
+ url: YouTube / TikTok / Instagram / direct URL
65
  output_dir: Directory to save the video
66
+ cookies_path: Optional Netscape cookies.txt path (passed to yt-dlp
67
+ for non-YouTube sources or as extra auth)
68
 
69
  Returns:
70
+ Dict with keys: path, title, duration, thumbnail_url
71
+ """
72
+ output_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ yt_id = _extract_youtube_id(url)
75
+ if yt_id:
76
+ self.logger.info(f"YouTube video detected (id={yt_id}), using Invidious proxy")
77
+ try:
78
+ return await asyncio.get_event_loop().run_in_executor(
79
+ None, self._download_via_invidious, yt_id, output_dir
80
+ )
81
+ except Exception as inv_err:
82
+ self.logger.warning(
83
+ f"Invidious download failed ({inv_err}), falling back to yt-dlp"
84
+ )
85
+ # Fall through to yt-dlp as last resort
86
+
87
+ # Non-YouTube or Invidious-failed fallback — use yt-dlp
88
+ return await self._download_ytdlp(url, output_dir, cookies_path)
89
 
90
+ # ------------------------------------------------------------------
91
+ # Invidious path (YouTube only)
92
+ # ------------------------------------------------------------------
93
+
94
+ def _download_via_invidious(self, video_id: str, output_dir: Path) -> Dict[str, any]:
95
  """
96
+ Fetch video info from Invidious API and download the stream through the
97
+ Invidious proxy (local=true), so youtube.com is never contacted.
98
+ """
99
+ headers = {
100
+ "User-Agent": (
101
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
102
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
103
+ "Chrome/124.0.0.0 Safari/537.36"
104
+ )
105
+ }
106
+
107
+ last_error = None
108
+ for instance in INVIDIOUS_INSTANCES:
109
+ try:
110
+ self.logger.info(f"Trying Invidious instance: {instance}")
111
+
112
+ # 1. Fetch video metadata
113
+ api_url = f"{instance}/api/v1/videos/{video_id}?fields=title,lengthSeconds,formatStreams,adaptiveFormats"
114
+ with httpx.Client(timeout=30, follow_redirects=True, headers=headers) as client:
115
+ resp = client.get(api_url)
116
+ if resp.status_code != 200:
117
+ self.logger.debug(f"{instance} returned HTTP {resp.status_code}")
118
+ continue
119
+ data = resp.json()
120
+
121
+ title = data.get("title", "Unknown")
122
+ duration = int(data.get("lengthSeconds", 0))
123
+
124
+ # 2. Pick best combined stream (video+audio, no muxing needed)
125
+ streams = data.get("formatStreams", [])
126
+ if not streams:
127
+ self.logger.debug(f"{instance} returned no formatStreams")
128
+ continue
129
+
130
+ # Sort by height descending, cap at 1080
131
+ def _height(s):
132
+ return int(s.get("resolution", "0p").rstrip("p") or 0)
133
+
134
+ streams_ok = [s for s in streams if _height(s) <= 1080]
135
+ best = max(streams_ok or streams, key=_height)
136
+ itag = best.get("itag", "")
137
+
138
+ # 3. Download through Invidious proxy (avoids youtube.com entirely)
139
+ stream_url = f"{instance}/latest_version?id={video_id}&itag={itag}&local=true"
140
+ output_path = output_dir / f"{video_id}.mp4"
141
+
142
+ self.logger.info(f"Downloading via {instance} (itag={itag}, res={best.get('resolution')})")
143
+ with httpx.Client(timeout=300, follow_redirects=True, headers=headers) as client:
144
+ with client.stream("GET", stream_url) as r:
145
+ r.raise_for_status()
146
+ with open(output_path, "wb") as fh:
147
+ for chunk in r.iter_bytes(chunk_size=1024 * 1024):
148
+ fh.write(chunk)
149
+
150
+ if not output_path.exists() or output_path.stat().st_size < 1024:
151
+ raise Exception("Downloaded file is empty or missing")
152
+
153
+ self.logger.info(f"Invidious download complete: {output_path}")
154
+ return {
155
+ "path": str(output_path),
156
+ "title": title,
157
+ "duration": duration,
158
+ "thumbnail_url": f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg",
159
+ }
160
 
161
+ except Exception as e:
162
+ last_error = e
163
+ self.logger.warning(f"{instance} failed: {e}")
164
+ continue
165
+
166
+ raise Exception(
167
+ f"All Invidious instances failed for video {video_id}. Last error: {last_error}"
168
+ )
169
+
170
+ # ------------------------------------------------------------------
171
+ # yt-dlp path (TikTok, Instagram, non-YouTube, fallback)
172
+ # ------------------------------------------------------------------
173
+
174
+ async def _download_ytdlp(
175
+ self, url: str, output_dir: Path, cookies_path: Optional[str] = None
176
+ ) -> Dict[str, any]:
177
+ try:
178
  ydl_opts = {
179
  "format": "best[height<=1080]/best",
180
  "quiet": True,
181
  "no_warnings": True,
 
182
  "outtmpl": str(output_dir / "%(id)s.%(ext)s"),
183
  "socket_timeout": 60,
184
  "retries": 10,
 
194
  ),
195
  "Accept-Language": "en-US,en;q=0.9",
196
  },
 
 
197
  "extractor_args": {
198
  "youtube": {"player_client": ["tv_embedded", "mweb", "web"]},
199
  },
200
  }
 
 
201
  if cookies_path:
202
  ydl_opts["cookiefile"] = cookies_path
203
  self.logger.info(f"Using cookies file: {cookies_path}")
204
 
 
205
  loop = asyncio.get_event_loop()
206
  result = await loop.run_in_executor(
207
  None, self._download_with_ytdlp, url, ydl_opts
208
  )
209
+ self.logger.info(f"yt-dlp download complete: {result['title']}")
 
210
  return result
211
 
212
  except Exception as e:
213
+ self.logger.error(f"yt-dlp failed for {url}: {e}")
214
+ raise Exception(f"Video download failed: {e}")
215
 
216
  def _download_with_ytdlp(self, url: str, opts: Dict) -> Dict[str, any]:
 
 
 
 
 
 
 
 
 
 
217
  with yt_dlp.YoutubeDL(opts) as ydl:
218
  info = ydl.extract_info(url, download=True)
219
 
 
220
  video_id = info.get("id", "video")
221
  video_ext = info.get("ext", "mp4")
222
+ video_path = Path(
223
+ opts["outtmpl"].replace("%(id)s", video_id).replace("%(ext)s", video_ext)
224
+ )
225
 
226
  if not video_path.exists():
 
227
  dl_dir = Path(opts["outtmpl"].split("%(")[0])
228
  files = sorted(dl_dir.glob(f"{video_id}.*"), key=lambda p: p.stat().st_mtime)
229
  if not files:
 
240
  "thumbnail_url": info.get("thumbnail", ""),
241
  }
242
 
243
+ # ------------------------------------------------------------------
244
+ # Audio extraction (unchanged)
245
+ # ------------------------------------------------------------------
 
 
 
 
 
 
 
246
 
247
+ async def extract_audio(self, video_path: Path, output_dir: Path) -> Path:
 
 
248
  try:
249
  output_dir.mkdir(parents=True, exist_ok=True)
250
  audio_path = output_dir / f"{video_path.stem}.wav"
 
254
  None, self._extract_audio_sync, str(video_path), str(audio_path)
255
  )
256
 
257
+ self.logger.info(f"Audio extracted to {audio_path}")
258
  return audio_path
259
 
260
  except Exception as e:
261
+ self.logger.error(f"Audio extraction failed: {e}")
262
+ raise Exception(f"Audio extraction failed: {e}")
263
 
264
  def _extract_audio_sync(self, video_path: str, audio_path: str) -> None:
 
 
 
 
 
 
 
265
  try:
266
  stream = ffmpeg.input(video_path)
267
  stream = ffmpeg.output(stream, audio_path, acodec="pcm_s16le", ac=1, ar=16000)
268
  ffmpeg.run(stream, capture_stdout=True, capture_stderr=True, quiet=True)
269
+ except ffmpeg.Error:
 
270
  import subprocess
271
  subprocess.run(
272
  ["ffmpeg", "-i", video_path, "-acodec", "pcm_s16le", "-ac", "1", "-ar", "16000", audio_path],