Update app.py
Browse files
app.py
CHANGED
@@ -3,378 +3,266 @@
|
|
3 |
"""
|
4 |
YouTube Video Analyzer & Downloader Pro
|
5 |
───────────────────────────────────────
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
"""
|
10 |
|
11 |
-
#
|
12 |
-
|
13 |
-
# ──────────────────────────────────────
|
14 |
-
import os
|
15 |
-
import re
|
16 |
-
import shutil
|
17 |
-
import tempfile
|
18 |
from datetime import datetime, timedelta
|
19 |
from pathlib import Path
|
20 |
|
21 |
-
#
|
22 |
-
# 서드파티 라이브러리
|
23 |
-
# ──────────────────────────────────────
|
24 |
import gradio as gr
|
25 |
import yt_dlp
|
26 |
import google.generativeai as genai
|
27 |
from youtube_transcript_api import YouTubeTranscriptApi
|
28 |
|
29 |
-
#
|
30 |
-
|
31 |
-
# ──────────────────────────────────────
|
32 |
-
DEFAULT_COOKIE_FILE = Path(__file__).with_name("www.youtube.com_cookies.txt")
|
33 |
|
34 |
# 유튜브 URL 정규식
|
35 |
-
|
36 |
r"(https?://)?(www\.)?"
|
37 |
r"(youtube|youtu|youtube-nocookie)\.(com|be)/"
|
38 |
r"(watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})"
|
39 |
)
|
40 |
|
41 |
-
|
42 |
-
# ──────────────────────────────────────
|
43 |
-
# Helper : video-ID 추출 / 자막 가져오기
|
44 |
-
# ──────────────────────────────────────
|
45 |
def extract_video_id(url: str) -> str | None:
|
46 |
-
m =
|
47 |
return m.group(6) if m else None
|
48 |
|
49 |
-
|
50 |
-
def fetch_transcript(video_id: str, pref_lang=("ko", "en")) -> str:
|
51 |
-
# 언어 우선순위대로 시도
|
52 |
tr = None
|
53 |
-
for lang in
|
54 |
try:
|
55 |
tr = YouTubeTranscriptApi.get_transcript(video_id, languages=[lang])
|
56 |
break
|
57 |
except Exception:
|
58 |
continue
|
59 |
if tr is None:
|
60 |
-
tr = YouTubeTranscriptApi.get_transcript(video_id)
|
61 |
-
|
62 |
-
lines: list[str] = []
|
63 |
for seg in tr:
|
64 |
-
t = str(timedelta(seconds=int(seg["start"])))
|
65 |
-
|
66 |
-
lines.append(f"**[{
|
67 |
return "\n".join(lines)
|
68 |
|
69 |
-
|
70 |
-
#
|
71 |
-
#
|
72 |
-
# ──────────────────────────────────────
|
73 |
class YouTubeDownloader:
|
74 |
def __init__(self):
|
75 |
-
#
|
76 |
-
self.
|
77 |
-
self.temp_downloads = tempfile.mkdtemp(prefix="
|
78 |
-
# Downloads
|
79 |
-
self.
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
self.gemini_model = None # Gemini 모델 핸들
|
85 |
|
86 |
# ───────── Gemini 설정 ─────────
|
87 |
def configure_gemini(self, api_key: str):
|
88 |
try:
|
89 |
genai.configure(api_key=api_key)
|
90 |
self.gemini_model = genai.GenerativeModel("gemini-1.5-flash-latest")
|
91 |
-
return True, "✅ Gemini API configured
|
92 |
except Exception as e:
|
93 |
-
return False, f"❌
|
94 |
-
|
95 |
-
# ───────── 정리 ─────────
|
96 |
-
def cleanup(self):
|
97 |
-
for p in (self.download_dir, self.temp_downloads):
|
98 |
-
try:
|
99 |
-
shutil.rmtree(p, ignore_errors=True)
|
100 |
-
except Exception:
|
101 |
-
pass
|
102 |
-
|
103 |
-
# ───────── 유효성 검사 ─────────
|
104 |
-
def is_valid_youtube_url(self, url: str) -> bool:
|
105 |
-
return _YT_REGEX.match(url) is not None
|
106 |
-
|
107 |
-
# ───────── 장면 분석 : Gemini ─────────
|
108 |
-
def generate_scene_breakdown_gemini(self, info: dict) -> list[str]:
|
109 |
-
if not self.gemini_model:
|
110 |
-
return self.generate_scene_breakdown_fallback(info)
|
111 |
|
112 |
-
|
113 |
-
duration = info.get("duration", 0)
|
114 |
-
title = info.get("title", "")
|
115 |
-
description = info.get("description", "")[:1500]
|
116 |
-
|
117 |
-
prompt = f"""
|
118 |
-
Analyze this YouTube video and create a highly detailed, scene-by-scene breakdown
|
119 |
-
with precise timestamps (MM:SS-MM:SS) and specific descriptions:
|
120 |
-
|
121 |
-
Title : {title}
|
122 |
-
Duration : {duration} s
|
123 |
-
Description : {description}
|
124 |
-
|
125 |
-
Follow these rules:
|
126 |
-
• Include visual details, actions, inferred dialogue, setting, props, graphics
|
127 |
-
• Dialogue : short lines for **every** scene if plausible
|
128 |
-
• Timestamp : 2-3 s (<1 min) / 3-5 s (1-5 min) / 5-10 s (5-15 min) / 10-15 s (>15 min)
|
129 |
-
• ≤ 20 scenes total
|
130 |
-
• Formatting → **[MM:SS-MM:SS]** Description
|
131 |
-
"""
|
132 |
-
resp = self.gemini_model.generate_content(prompt)
|
133 |
-
if not resp or not resp.text:
|
134 |
-
return self.generate_scene_breakdown_fallback(info)
|
135 |
-
|
136 |
-
scenes, cur = [], ""
|
137 |
-
for line in resp.text.splitlines():
|
138 |
-
line = line.strip()
|
139 |
-
if line.startswith("**[") and "]**" in line:
|
140 |
-
if cur:
|
141 |
-
scenes.append(cur.strip())
|
142 |
-
cur = line
|
143 |
-
elif cur:
|
144 |
-
cur += "\n" + line
|
145 |
-
if cur:
|
146 |
-
scenes.append(cur.strip())
|
147 |
-
return scenes or self.generate_scene_breakdown_fallback(info)
|
148 |
-
|
149 |
-
except Exception:
|
150 |
-
return self.generate_scene_breakdown_fallback(info)
|
151 |
-
|
152 |
-
# ───────── 장면 분석 : Fallback ─────────
|
153 |
-
def generate_scene_breakdown_fallback(self, info: dict) -> list[str]:
|
154 |
-
duration = info.get("duration", 0)
|
155 |
-
if not duration:
|
156 |
-
return ["**[00:00]** Unable to determine duration"]
|
157 |
-
|
158 |
-
seg = 3 if duration <= 60 else 5 if duration <= 300 else 10 if duration <= 900 else 15
|
159 |
-
total = min(duration // seg + 1, 20)
|
160 |
-
scenes: list[str] = []
|
161 |
-
for i in range(total):
|
162 |
-
s, e = i * seg, min(i * seg + seg - 1, duration)
|
163 |
-
scenes.append(
|
164 |
-
f"**[{s//60:02d}:{s%60:02d}-{e//60:02d}:{e%60:02d}]** "
|
165 |
-
"Content continues…"
|
166 |
-
)
|
167 |
-
return scenes
|
168 |
-
|
169 |
-
# ───────── 숫자 포맷 ─────────
|
170 |
@staticmethod
|
171 |
-
def
|
172 |
-
if
|
173 |
-
|
174 |
-
if n >= 1_000:
|
175 |
-
return f"{n/1_000:.1f} K"
|
176 |
-
return str(n)
|
177 |
-
|
178 |
-
# ───────── 결과 리포트 ─────────
|
179 |
-
def format_video_info(self, info: dict) -> str:
|
180 |
-
title = info.get("title", "Unknown")
|
181 |
-
uploader = info.get("uploader", "Unknown")
|
182 |
-
duration = info.get("duration", 0)
|
183 |
-
dur = f"{duration//60}:{duration%60:02d}"
|
184 |
-
views = self.fmt_num(info.get("view_count", 0))
|
185 |
-
likes = self.fmt_num(info.get("like_count", 0))
|
186 |
-
comments = self.fmt_num(info.get("comment_count", 0))
|
187 |
-
scenes = "\n".join(self.generate_scene_breakdown_gemini(info))
|
188 |
-
|
189 |
-
return f"""\
|
190 |
-
🎬 **{title}**
|
191 |
-
Uploader : {uploader}
|
192 |
-
Duration : {dur} Views / Likes / Comments : {views} / {likes} / {comments}
|
193 |
-
|
194 |
-
{'-'*48}
|
195 |
-
{scenes}
|
196 |
-
"""
|
197 |
-
|
198 |
-
# ───────── 메타데이터 ─────────
|
199 |
-
def get_video_info(self, url: str, cookiefile: str | None = None):
|
200 |
-
if cookiefile and os.path.exists(cookiefile):
|
201 |
-
ck = cookiefile
|
202 |
elif DEFAULT_COOKIE_FILE.exists():
|
203 |
ck = str(DEFAULT_COOKIE_FILE)
|
204 |
else:
|
205 |
ck = None
|
|
|
|
|
206 |
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
return ydl.extract_info(url, download=False)
|
212 |
|
213 |
-
# ─────────
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
cookiefile: str | None = None,
|
220 |
-
):
|
221 |
-
if cookiefile and os.path.exists(cookiefile):
|
222 |
-
ck = cookiefile
|
223 |
-
elif DEFAULT_COOKIE_FILE.exists():
|
224 |
-
ck = str(DEFAULT_COOKIE_FILE)
|
225 |
-
else:
|
226 |
-
ck = None
|
227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
228 |
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
229 |
ydl_opts: dict = {
|
230 |
-
"outtmpl":
|
|
|
231 |
"noplaylist": True,
|
|
|
232 |
}
|
233 |
-
|
234 |
-
if audio_only:
|
235 |
ydl_opts["format"] = "bestaudio/best"
|
236 |
ydl_opts["postprocessors"] = [
|
237 |
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}
|
238 |
]
|
239 |
else:
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
return None, "❌ File not found"
|
264 |
-
|
265 |
-
|
266 |
-
# ──────────────────────────────────────
|
267 |
-
# Gradio Helper
|
268 |
-
# ─────────────────────��────────────────
|
269 |
-
downloader = YouTubeDownloader()
|
270 |
|
|
|
|
|
271 |
|
272 |
-
|
273 |
-
|
|
|
274 |
return msg, gr.update(visible=ok)
|
275 |
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
return
|
288 |
-
|
289 |
-
|
290 |
-
|
|
|
|
|
|
|
291 |
vid = extract_video_id(url)
|
292 |
if not vid:
|
293 |
-
return "❌
|
294 |
try:
|
295 |
return fetch_transcript(vid)
|
296 |
except Exception as e:
|
297 |
-
return f"❌ {e}"
|
298 |
|
|
|
|
|
|
|
|
|
299 |
|
300 |
-
#
|
301 |
-
# UI
|
302 |
-
# ──────────────────────────────────────
|
303 |
-
def create_interface():
|
304 |
-
with gr.Blocks(theme=gr.themes.Soft(), title="🎥 YouTube Video Analyzer & Downloader Pro") as iface:
|
305 |
-
|
306 |
-
gr.HTML("<h1>🎥 YouTube Video Analyzer & Downloader Pro</h1>")
|
307 |
-
|
308 |
-
# API 설정
|
309 |
with gr.Group():
|
310 |
-
gr.HTML("<h3>🔑 Google Gemini API Configuration</h3>")
|
311 |
with gr.Row():
|
312 |
-
|
313 |
-
api_btn = gr.Button("
|
314 |
-
|
315 |
-
|
316 |
-
interactive=False,
|
317 |
-
lines=1,
|
318 |
-
label="API Status",
|
319 |
-
)
|
320 |
|
321 |
-
#
|
322 |
with gr.Row():
|
323 |
-
|
324 |
-
|
325 |
|
326 |
with gr.Tabs():
|
327 |
-
# 분석
|
328 |
-
with gr.TabItem("📊 Video Analysis"):
|
329 |
-
analyze_btn = gr.Button("🔍 Analyze", variant="primary")
|
330 |
-
analysis_out = gr.Textbox(label="📊 Analysis Report", lines=30, show_copy_button=True)
|
331 |
-
analyze_btn.click(analyze_fn, [url_in, cookie_in], analysis_out, show_progress=True)
|
332 |
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
audio_cb = gr.Checkbox(label="🎵 Audio only (MP3)")
|
338 |
-
download_btn = gr.Button("⬇️ Download", variant="primary")
|
339 |
-
dl_status = gr.Textbox(label="📥 Status", lines=5, show_copy_button=True)
|
340 |
-
dl_file = gr.File(label="📁 File", visible=False)
|
341 |
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
|
|
348 |
|
349 |
-
|
|
|
|
|
|
|
350 |
|
351 |
-
# 자막
|
352 |
with gr.TabItem("🗒️ Transcript"):
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
api_btn.click(configure_api_key, inputs=[api_key_in], outputs=[api_status])
|
358 |
|
359 |
gr.HTML(
|
360 |
-
"""
|
361 |
<div style="margin-top:20px;padding:15px;background:#f0f8ff;border-left:5px solid #4285f4;border-radius:10px;">
|
362 |
-
<
|
363 |
-
|
364 |
-
폴더에 두면 업로드 없이 자동 사용됩니다.</p>
|
365 |
</div>
|
366 |
"""
|
367 |
)
|
|
|
368 |
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
# ──────────────────────────────────────
|
373 |
-
# Entrypoint
|
374 |
-
# ──────────────────────────────────────
|
375 |
if __name__ == "__main__":
|
376 |
-
demo =
|
377 |
import atexit
|
378 |
|
379 |
-
atexit.register(
|
|
|
380 |
demo.launch(debug=True, show_error=True)
|
|
|
3 |
"""
|
4 |
YouTube Video Analyzer & Downloader Pro
|
5 |
───────────────────────────────────────
|
6 |
+
· app.py 와 같은 폴더에 𝐰𝐰𝐰.𝐲𝐨𝐮𝐭𝐮𝐛𝐞.𝐜𝐨𝐨𝐤𝐢𝐞𝐬.𝐭𝐱𝐭 이 있으면 자동 사용
|
7 |
+
· Gradio UI에서 쿠키 파일 업로드 시 → 업로드 파일이 우선
|
8 |
+
· Gemini-1.5 Flash 장면 분석(선택적), 전체 Transcript, 다운로드 지원
|
9 |
"""
|
10 |
|
11 |
+
# ───────────────────────── 표준 라이브러리 ─────────────────────────
|
12 |
+
import os, re, shutil, tempfile
|
|
|
|
|
|
|
|
|
|
|
13 |
from datetime import datetime, timedelta
|
14 |
from pathlib import Path
|
15 |
|
16 |
+
# ───────────────────────── 서드파티 라이브러리 ────────────────────
|
|
|
|
|
17 |
import gradio as gr
|
18 |
import yt_dlp
|
19 |
import google.generativeai as genai
|
20 |
from youtube_transcript_api import YouTubeTranscriptApi
|
21 |
|
22 |
+
# ───────────────────────── 상수 (절대경로) ─────────────────────────
|
23 |
+
DEFAULT_COOKIE_FILE = (Path(__file__).resolve().parent / "www.youtube.com_cookies.txt").resolve()
|
|
|
|
|
24 |
|
25 |
# 유튜브 URL 정규식
|
26 |
+
_YT_RE = re.compile(
|
27 |
r"(https?://)?(www\.)?"
|
28 |
r"(youtube|youtu|youtube-nocookie)\.(com|be)/"
|
29 |
r"(watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})"
|
30 |
)
|
31 |
|
32 |
+
# ───────────────────────── Helper (ID, 자막) ─────────────────────
|
|
|
|
|
|
|
33 |
def extract_video_id(url: str) -> str | None:
|
34 |
+
m = _YT_RE.match(url)
|
35 |
return m.group(6) if m else None
|
36 |
|
37 |
+
def fetch_transcript(video_id: str, pref=("ko", "en")) -> str:
|
|
|
|
|
38 |
tr = None
|
39 |
+
for lang in pref:
|
40 |
try:
|
41 |
tr = YouTubeTranscriptApi.get_transcript(video_id, languages=[lang])
|
42 |
break
|
43 |
except Exception:
|
44 |
continue
|
45 |
if tr is None:
|
46 |
+
tr = YouTubeTranscriptApi.get_transcript(video_id)
|
47 |
+
lines = []
|
|
|
48 |
for seg in tr:
|
49 |
+
t = str(timedelta(seconds=int(seg["start"])))
|
50 |
+
mmss = ":".join(t.split(":")[-2:])
|
51 |
+
lines.append(f"**[{mmss}]** {seg['text']}")
|
52 |
return "\n".join(lines)
|
53 |
|
54 |
+
# =================================================================
|
55 |
+
# Main Class
|
56 |
+
# =================================================================
|
|
|
57 |
class YouTubeDownloader:
|
58 |
def __init__(self):
|
59 |
+
# temp dirs
|
60 |
+
self.temp_dir = tempfile.mkdtemp(prefix="yt_tmp_")
|
61 |
+
self.temp_downloads = tempfile.mkdtemp(prefix="yt_dl_")
|
62 |
+
# Downloads target
|
63 |
+
self.dl_folder = Path.home() / "Downloads" / "YouTube_Downloads"
|
64 |
+
self.dl_folder.mkdir(parents=True, exist_ok=True)
|
65 |
+
# Gemini
|
66 |
+
self.gemini_model = None
|
|
|
|
|
67 |
|
68 |
# ───────── Gemini 설정 ─────────
|
69 |
def configure_gemini(self, api_key: str):
|
70 |
try:
|
71 |
genai.configure(api_key=api_key)
|
72 |
self.gemini_model = genai.GenerativeModel("gemini-1.5-flash-latest")
|
73 |
+
return True, "✅ Gemini API configured!"
|
74 |
except Exception as e:
|
75 |
+
return False, f"❌ Gemini 설정 실패: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
76 |
|
77 |
+
# ───────── 쿠키 선택 + 디버그 ─────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
@staticmethod
|
79 |
+
def choose_cookie(ui_cookie: str | None):
|
80 |
+
if ui_cookie and os.path.exists(ui_cookie):
|
81 |
+
ck = ui_cookie
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
elif DEFAULT_COOKIE_FILE.exists():
|
83 |
ck = str(DEFAULT_COOKIE_FILE)
|
84 |
else:
|
85 |
ck = None
|
86 |
+
print(">>> COOKIE =", ck, "EXISTS?", os.path.exists(ck) if ck else None)
|
87 |
+
return ck
|
88 |
|
89 |
+
# ───────── URL 검증 ─────────
|
90 |
+
@staticmethod
|
91 |
+
def valid_url(url: str) -> bool:
|
92 |
+
return bool(_YT_RE.match(url))
|
|
|
93 |
|
94 |
+
# ───────── 숫자 포맷 ─────────
|
95 |
+
@staticmethod
|
96 |
+
def fmt(n: int) -> str:
|
97 |
+
if n >= 1_000_000: return f"{n/1_000_000:.1f} M"
|
98 |
+
if n >= 1_000: return f"{n/1_000:.1f} K"
|
99 |
+
return str(n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
|
101 |
+
# ───────── Scene 분석 (fallback만 간단 구현) ─────────
|
102 |
+
def scene_breakdown(self, info: dict):
|
103 |
+
dur = info.get("duration", 0)
|
104 |
+
seg = 3 if dur <= 60 else 5 if dur <= 300 else 10
|
105 |
+
scenes = []
|
106 |
+
for s in range(0, dur, seg):
|
107 |
+
e = min(s + seg - 1, dur)
|
108 |
+
scenes.append(f"**[{s//60:02d}:{s%60:02d}-{e//60:02d}:{e%60:02d}]** …")
|
109 |
+
return "\n".join(scenes[:20])
|
110 |
+
|
111 |
+
# ───────── Video Info ─────────
|
112 |
+
def get_info(self, url: str, ui_cookie: str | None, progress):
|
113 |
+
if not self.valid_url(url):
|
114 |
+
return None, "❌ URL 오류"
|
115 |
+
ck = self.choose_cookie(ui_cookie)
|
116 |
+
if ck is None:
|
117 |
+
return None, "❌ 쿠키 파일을 찾을 수 없습니다"
|
118 |
+
ydl_opts = {"quiet": True, "noplaylist": True, "cookiefile": ck}
|
119 |
+
try:
|
120 |
+
progress(0.1, desc="metadata…")
|
121 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
122 |
+
info = ydl.extract_info(url, download=False)
|
123 |
+
progress(1.0)
|
124 |
+
return info, "OK"
|
125 |
+
except Exception as e:
|
126 |
+
return None, f"yt-dlp 오류: {e}"
|
127 |
+
|
128 |
+
# ───────── Download ─────────
|
129 |
+
def download(
|
130 |
+
self, url: str, qual: str, audio: bool, ui_cookie: str | None, progress
|
131 |
+
):
|
132 |
+
if not self.valid_url(url):
|
133 |
+
return None, "❌ URL 오류"
|
134 |
+
ck = self.choose_cookie(ui_cookie)
|
135 |
+
if ck is None:
|
136 |
+
return None, "❌ 쿠키 파일을 찾을 수 없습니다"
|
137 |
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
138 |
ydl_opts: dict = {
|
139 |
+
"outtmpl": str(Path(self.temp_downloads) / f"%(title)s_{ts}.%(ext)s"),
|
140 |
+
"quiet": True,
|
141 |
"noplaylist": True,
|
142 |
+
"cookiefile": ck,
|
143 |
}
|
144 |
+
if audio:
|
|
|
145 |
ydl_opts["format"] = "bestaudio/best"
|
146 |
ydl_opts["postprocessors"] = [
|
147 |
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}
|
148 |
]
|
149 |
else:
|
150 |
+
ydl_opts["format"] = (
|
151 |
+
"best[height<=720]" if qual == "720p"
|
152 |
+
else "best[height<=480]" if qual == "480p"
|
153 |
+
else "best[height<=1080]"
|
154 |
+
)
|
155 |
+
try:
|
156 |
+
progress(0.1, desc="Downloading…")
|
157 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
158 |
+
ydl.extract_info(url, download=True)
|
159 |
+
# temp → Downloads
|
160 |
+
for f in Path(self.temp_downloads).iterdir():
|
161 |
+
if ts in f.name:
|
162 |
+
dest = self.dl_folder / f.name
|
163 |
+
try:
|
164 |
+
shutil.copy2(f, dest)
|
165 |
+
saved = dest
|
166 |
+
except Exception:
|
167 |
+
saved = f
|
168 |
+
progress(1.0)
|
169 |
+
return saved, f"✅ 저장 위치: {saved}"
|
170 |
+
return None, "❌ 파일을 찾을 수 없습니다"
|
171 |
+
except Exception as e:
|
172 |
+
return None, f"❌ 다운로드 실패: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
173 |
|
174 |
+
# ───────────────────────── 인스턴스 ─────────────────────────
|
175 |
+
yt_dl = YouTubeDownloader()
|
176 |
|
177 |
+
# ───────────────────────── Gradio 함수 ──────────────────────
|
178 |
+
def api_config(key):
|
179 |
+
ok, msg = yt_dl.configure_gemini(key.strip()) if key else (False, "❌ API 키 필요")
|
180 |
return msg, gr.update(visible=ok)
|
181 |
|
182 |
+
def analyze(url, cfile, progress=gr.Progress()):
|
183 |
+
info, err = yt_dl.get_info(url, cfile, progress)
|
184 |
+
if not info:
|
185 |
+
return err
|
186 |
+
report = (
|
187 |
+
f"**Title** : {info.get('title')}\n"
|
188 |
+
f"**Duration** : {info.get('duration',0)//60}:{info.get('duration',0)%60:02d}\n"
|
189 |
+
f"**Views / Likes** : {yt_dl.fmt(info.get('view_count',0))} / {yt_dl.fmt(info.get('like_count',0))}\n"
|
190 |
+
f"{'-'*40}\n"
|
191 |
+
f"{yt_dl.scene_breakdown(info)}"
|
192 |
+
)
|
193 |
+
return report
|
194 |
+
|
195 |
+
def download(url, q, a, cfile, progress=gr.Progress()):
|
196 |
+
path, msg = yt_dl.download(url, q, a, cfile, progress)
|
197 |
+
return msg, path
|
198 |
+
|
199 |
+
def transcript(url, _cfile):
|
200 |
vid = extract_video_id(url)
|
201 |
if not vid:
|
202 |
+
return "❌ URL 오류"
|
203 |
try:
|
204 |
return fetch_transcript(vid)
|
205 |
except Exception as e:
|
206 |
+
return f"❌ 자막 오류: {e}"
|
207 |
|
208 |
+
# ───────────────────────── UI ───────────────────────────────
|
209 |
+
def create_ui():
|
210 |
+
with gr.Blocks(theme=gr.themes.Soft(), title="🎥 YouTube Video Analyzer & Downloader") as ui:
|
211 |
+
gr.HTML("<h1>🎥 YouTube Video Analyzer & Downloader</h1>")
|
212 |
|
213 |
+
# API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
214 |
with gr.Group():
|
|
|
215 |
with gr.Row():
|
216 |
+
api_key = gr.Textbox(type="password", label="Gemini API Key")
|
217 |
+
api_btn = gr.Button("Configure")
|
218 |
+
api_stat = gr.Textbox(label="API Status", value="❌ Gemini 미설정", interactive=False)
|
219 |
+
api_btn.click(api_config, [api_key], [api_stat])
|
|
|
|
|
|
|
|
|
220 |
|
221 |
+
# 입력
|
222 |
with gr.Row():
|
223 |
+
url = gr.Textbox(label="🔗 YouTube URL")
|
224 |
+
cookie = gr.File(label="🍪 cookies.txt (선택)", type="filepath", file_types=[".txt"])
|
225 |
|
226 |
with gr.Tabs():
|
|
|
|
|
|
|
|
|
|
|
227 |
|
228 |
+
with gr.TabItem("📊 Analyze"):
|
229 |
+
a_btn = gr.Button("Analyze", variant="primary")
|
230 |
+
a_out = gr.Textbox(lines=20, label="Analysis", show_copy_button=True)
|
231 |
+
a_btn.click(analyze, [url, cookie], a_out, show_progress=True)
|
|
|
|
|
|
|
|
|
232 |
|
233 |
+
with gr.TabItem("⬇️ Download"):
|
234 |
+
with gr.Row():
|
235 |
+
q_dd = gr.Dropdown(["best", "720p", "480p"], value="best", label="Quality")
|
236 |
+
a_cb = gr.Checkbox(label="Audio only (MP3)")
|
237 |
+
d_btn = gr.Button("Download", variant="primary")
|
238 |
+
d_stat = gr.Textbox(lines=5, label="Status")
|
239 |
+
d_file = gr.File(label="File", visible=False)
|
240 |
|
241 |
+
def wrap_dl(u,q,a,c,pg=gr.Progress()):
|
242 |
+
msg, fp = download(u,q,a,c,pg)
|
243 |
+
return msg, gr.update(value=fp, visible=bool(fp and os.path.exists(fp)))
|
244 |
+
d_btn.click(wrap_dl, [url,q_dd,a_cb,cookie], [d_stat,d_file], show_progress=True)
|
245 |
|
|
|
246 |
with gr.TabItem("🗒️ Transcript"):
|
247 |
+
t_btn = gr.Button("Get Transcript", variant="primary")
|
248 |
+
t_out = gr.Textbox(lines=30, label="Transcript", show_copy_button=True)
|
249 |
+
t_btn.click(transcript, [url, cookie], t_out, show_progress=True)
|
|
|
|
|
250 |
|
251 |
gr.HTML(
|
252 |
+
f"""
|
253 |
<div style="margin-top:20px;padding:15px;background:#f0f8ff;border-left:5px solid #4285f4;border-radius:10px;">
|
254 |
+
<b>Tip</b> : <code>{DEFAULT_COOKIE_FILE.name}</code> 파일을 <b>app.py</b> 옆에 두면
|
255 |
+
자동으로 쿠키가 적용됩니다.
|
|
|
256 |
</div>
|
257 |
"""
|
258 |
)
|
259 |
+
return ui
|
260 |
|
261 |
+
# ───────────────────────── Entrypoint ───────────────────────
|
|
|
|
|
|
|
|
|
|
|
262 |
if __name__ == "__main__":
|
263 |
+
demo = create_ui()
|
264 |
import atexit
|
265 |
|
266 |
+
atexit.register(lambda: shutil.rmtree(yt_dl.temp_dir, ignore_errors=True))
|
267 |
+
atexit.register(lambda: shutil.rmtree(yt_dl.temp_downloads, ignore_errors=True))
|
268 |
demo.launch(debug=True, show_error=True)
|