feat: initial Tauri desktop shell for BatchTube v1.0
This commit is contained in:
749
backend/extractors.py
Normal file
749
backend/extractors.py
Normal file
@@ -0,0 +1,749 @@
|
||||
"""
|
||||
extractors.py - Per-artifact extraction logic for YouTube SIPOC Tool
|
||||
Uses yt-dlp, youtube-transcript-api, and OpenRouter LLM.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
|
||||
import yt_dlp
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
try:
|
||||
from youtube_transcript_api import TranscriptsDisabled, NoTranscriptFound
|
||||
except ImportError:
|
||||
# v1.x moved/renamed exceptions
|
||||
TranscriptsDisabled = Exception
|
||||
NoTranscriptFound = Exception
|
||||
from openai import OpenAI
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger("extractors")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Utilities
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def sanitize_filename(name: str, max_length: int = config.MAX_TITLE_LENGTH) -> str:
|
||||
"""Make a string safe to use in a file name."""
|
||||
name = re.sub(r'[\\/:*?"<>|]', "_", name)
|
||||
name = re.sub(r'\s+', "_", name.strip())
|
||||
name = re.sub(r'_+', "_", name)
|
||||
return name[:max_length].rstrip("._")
|
||||
|
||||
|
||||
def make_base_name(title: str, video_id: str) -> str:
|
||||
"""Return the standard base filename (no extension)."""
|
||||
safe_title = sanitize_filename(title) if title else "untitled"
|
||||
return f"{safe_title}__{video_id}"
|
||||
|
||||
|
||||
def ensure_dir(path: str):
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def video_url(video_id: str) -> str:
|
||||
return f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Input Resolution: URL → list of video IDs
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def resolve_input(input_type: str, input_value: str) -> List[Dict]:
|
||||
"""
|
||||
Resolve any input type to a list of dicts: {video_id, url, title, position}
|
||||
Supported input_types: url, playlist, csv, handle
|
||||
"""
|
||||
if input_type == "url":
|
||||
return _resolve_single_url(input_value)
|
||||
elif input_type == "playlist":
|
||||
return _resolve_playlist(input_value)
|
||||
elif input_type == "csv":
|
||||
return _resolve_csv(input_value)
|
||||
elif input_type == "handle":
|
||||
return _resolve_handle(input_value)
|
||||
else:
|
||||
raise ValueError(f"Unknown input_type: {input_type}")
|
||||
|
||||
|
||||
def _extract_video_id(url: str) -> Optional[str]:
|
||||
"""Extract YouTube video ID from various URL formats."""
|
||||
patterns = [
|
||||
r'(?:v=|youtu\.be/|embed/|shorts/)([A-Za-z0-9_-]{11})',
|
||||
]
|
||||
for pat in patterns:
|
||||
m = re.search(pat, url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_single_url(url: str) -> List[Dict]:
|
||||
"""Handle single YouTube URL (video or short)."""
|
||||
url = url.strip()
|
||||
vid = _extract_video_id(url)
|
||||
if vid:
|
||||
return [{"video_id": vid, "url": video_url(vid), "title": "", "position": 0}]
|
||||
# Fall through to yt-dlp for unusual URLs
|
||||
return _resolve_with_ytdlp(url)
|
||||
|
||||
|
||||
def _resolve_playlist(playlist_url: str) -> List[Dict]:
|
||||
return _resolve_with_ytdlp(playlist_url.strip())
|
||||
|
||||
|
||||
def _resolve_handle(handle: str) -> List[Dict]:
|
||||
"""Resolve @handle or channel URL to video list."""
|
||||
handle = handle.strip()
|
||||
if not handle.startswith("http"):
|
||||
if handle.startswith("@"):
|
||||
url = f"https://www.youtube.com/{handle}/videos"
|
||||
else:
|
||||
url = f"https://www.youtube.com/@{handle}/videos"
|
||||
else:
|
||||
url = handle
|
||||
return _resolve_with_ytdlp(url)
|
||||
|
||||
|
||||
def _resolve_csv(csv_content: str) -> List[Dict]:
|
||||
"""Parse CSV content: each line may be a URL or video_id."""
|
||||
lines = []
|
||||
for line in csv_content.splitlines():
|
||||
for part in line.split(","):
|
||||
part = part.strip().strip('"').strip("'")
|
||||
if part:
|
||||
lines.append(part)
|
||||
results = []
|
||||
for i, entry in enumerate(lines):
|
||||
if entry.startswith("http"):
|
||||
items = _resolve_single_url(entry)
|
||||
for item in items:
|
||||
item["position"] = len(results)
|
||||
results.append(item)
|
||||
else:
|
||||
# Treat as raw video_id
|
||||
vid = entry[:11]
|
||||
results.append({"video_id": vid, "url": video_url(vid), "title": "", "position": len(results)})
|
||||
return results
|
||||
|
||||
|
||||
def _resolve_with_ytdlp(url: str) -> List[Dict]:
|
||||
"""Use yt-dlp to extract video list from any URL (playlist, channel, etc.)."""
|
||||
captured_errors = []
|
||||
|
||||
class ErrorCollector:
|
||||
"""Custom yt-dlp logger to capture warning/error messages."""
|
||||
def debug(self, msg): pass
|
||||
def info(self, msg): pass
|
||||
def warning(self, msg):
|
||||
captured_errors.append(f"WARNING: {msg}")
|
||||
def error(self, msg):
|
||||
captured_errors.append(f"ERROR: {msg}")
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"extract_flat": True,
|
||||
"skip_download": True,
|
||||
"ignoreerrors": False, # Don't swallow errors silently
|
||||
"logger": ErrorCollector(),
|
||||
}
|
||||
results = []
|
||||
info = None
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
except Exception as e:
|
||||
captured_errors.append(str(e))
|
||||
|
||||
if not info:
|
||||
# Build a meaningful error message
|
||||
err_summary = "; ".join(captured_errors) if captured_errors else "No information returned by yt-dlp."
|
||||
# Surface private/login-required as a clear user-facing error
|
||||
lower = err_summary.lower()
|
||||
if "private" in lower:
|
||||
raise ValueError(
|
||||
f"This playlist or channel is PRIVATE. "
|
||||
f"YouTube requires you to be logged in to access it. "
|
||||
f"To process private content, add your YouTube cookies file path in Settings. "
|
||||
f"Details: {err_summary}"
|
||||
)
|
||||
elif "sign in" in lower or "login" in lower or "confirm your age" in lower:
|
||||
raise ValueError(
|
||||
f"YouTube requires authentication (sign-in or age verification). "
|
||||
f"Add your YouTube cookies file path in Settings. "
|
||||
f"Details: {err_summary}"
|
||||
)
|
||||
elif "not exist" in lower or "removed" in lower or "unavailable" in lower:
|
||||
raise ValueError(f"Content not available or removed. Details: {err_summary}")
|
||||
else:
|
||||
raise ValueError(f"Could not resolve URL '{url}'. Details: {err_summary}")
|
||||
|
||||
entries = info.get("entries") or [info]
|
||||
for i, entry in enumerate(entries):
|
||||
if not entry:
|
||||
continue
|
||||
vid = entry.get("id") or entry.get("video_id")
|
||||
if not vid:
|
||||
continue
|
||||
title = entry.get("title", "")
|
||||
results.append({
|
||||
"video_id": vid,
|
||||
"url": video_url(vid),
|
||||
"title": title,
|
||||
"position": i,
|
||||
})
|
||||
|
||||
if not results:
|
||||
raise ValueError(
|
||||
f"URL resolved successfully but contained 0 videos. "
|
||||
f"The playlist/channel may be empty. URL: {url}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Metadata Fetch
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_metadata(video_id: str) -> Dict:
|
||||
"""Fetch full video metadata using yt-dlp."""
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"skip_download": True,
|
||||
"ignoreerrors": True,
|
||||
}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url(video_id), download=False)
|
||||
return info or {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Title
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_title(video_id: str, meta: Dict, output_folder: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Save title as a .txt file.
|
||||
Returns (file_path, title_string)
|
||||
"""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__title.txt")
|
||||
ensure_dir(output_folder)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"Title: {title}\n")
|
||||
f.write(f"Video ID: {video_id}\n")
|
||||
f.write(f"URL: {video_url(video_id)}\n")
|
||||
return file_path, title
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Stats
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_stats(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""Save video stats + full metadata as a human-readable .txt file."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__stats.txt")
|
||||
ensure_dir(output_folder)
|
||||
|
||||
# Format helpers
|
||||
def fmt_num(n) -> str:
|
||||
if n is None:
|
||||
return "N/A"
|
||||
try:
|
||||
return f"{int(n):,}"
|
||||
except Exception:
|
||||
return str(n)
|
||||
|
||||
def fmt_duration(secs) -> str:
|
||||
if not secs:
|
||||
return "N/A"
|
||||
try:
|
||||
secs = int(secs)
|
||||
h, rem = divmod(secs, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}h {m}m {s}s" if h else f"{m}m {s}s"
|
||||
except Exception:
|
||||
return str(secs)
|
||||
|
||||
def fmt_date(d) -> str:
|
||||
if not d:
|
||||
return "N/A"
|
||||
try:
|
||||
d = str(d)
|
||||
return f"{d[:4]}-{d[4:6]}-{d[6:8]}"
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
tags = meta.get("tags") or []
|
||||
categories = meta.get("categories") or []
|
||||
chapters = meta.get("chapters") or []
|
||||
description = meta.get("description", "") or ""
|
||||
formats = meta.get("formats") or []
|
||||
subtitles = meta.get("subtitles") or {}
|
||||
automatic_captions = meta.get("automatic_captions") or {}
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write("=" * 70 + "\n")
|
||||
f.write(f" VIDEO STATS & METADATA\n")
|
||||
f.write("=" * 70 + "\n\n")
|
||||
|
||||
f.write(f"Title : {meta.get('title', '')}\n")
|
||||
f.write(f"Video ID : {video_id}\n")
|
||||
f.write(f"URL : {video_url(video_id)}\n")
|
||||
f.write(f"Webpage URL : {meta.get('webpage_url', '')}\n\n")
|
||||
|
||||
f.write("── Channel ──────────────────────────────────────────────────\n")
|
||||
f.write(f"Channel : {meta.get('channel', meta.get('uploader', ''))}\n")
|
||||
f.write(f"Channel ID : {meta.get('channel_id', '')}\n")
|
||||
f.write(f"Channel URL : {meta.get('channel_url', meta.get('uploader_url', ''))}\n")
|
||||
f.write(f"Subscribers : {fmt_num(meta.get('channel_follower_count'))}\n\n")
|
||||
|
||||
f.write("── Content ──────────────────────────────────────────────────\n")
|
||||
f.write(f"Upload Date : {fmt_date(meta.get('upload_date'))}\n")
|
||||
f.write(f"Duration : {fmt_duration(meta.get('duration'))} ({meta.get('duration_string', '')})\n")
|
||||
f.write(f"Age Limit : {meta.get('age_limit', 0)}\n")
|
||||
f.write(f"Is Live : {meta.get('is_live', False)}\n")
|
||||
f.write(f"Was Live : {meta.get('was_live', False)}\n")
|
||||
f.write(f"Live Status : {meta.get('live_status', '')}\n\n")
|
||||
|
||||
f.write("── Engagement ───────────────────────────────────────────────\n")
|
||||
f.write(f"Views : {fmt_num(meta.get('view_count'))}\n")
|
||||
f.write(f"Likes : {fmt_num(meta.get('like_count'))}\n")
|
||||
f.write(f"Comments : {fmt_num(meta.get('comment_count'))}\n")
|
||||
f.write(f"Average Rating : {meta.get('average_rating', 'N/A')}\n\n")
|
||||
|
||||
f.write("── Media Info ───────────────────────────────────────────────\n")
|
||||
f.write(f"Thumbnail URL : {meta.get('thumbnail', '')}\n")
|
||||
# Best format info
|
||||
best_fmt = next((fmt for fmt in reversed(formats) if fmt.get("vcodec") not in (None, "none")), None)
|
||||
if best_fmt:
|
||||
f.write(f"Best Resolution : {best_fmt.get('width', '?')}x{best_fmt.get('height', '?')} "
|
||||
f"@ {best_fmt.get('fps', '?')}fps\n")
|
||||
f.write(f"Best Video Codec : {best_fmt.get('vcodec', 'N/A')}\n")
|
||||
f.write(f"Best Audio Codec : {best_fmt.get('acodec', 'N/A')}\n")
|
||||
f.write(f"Available Formats: {len(formats)}\n\n")
|
||||
|
||||
f.write("── Classification ───────────────────────────────────────────\n")
|
||||
f.write(f"Categories : {', '.join(categories) if categories else 'N/A'}\n")
|
||||
if tags:
|
||||
tag_str = ", ".join(tags[:30])
|
||||
if len(tags) > 30:
|
||||
tag_str += f" ... (+{len(tags)-30} more)"
|
||||
f.write(f"Tags : {tag_str}\n")
|
||||
else:
|
||||
f.write(f"Tags : N/A\n")
|
||||
f.write("\n")
|
||||
|
||||
# Subtitles / transcript languages
|
||||
all_sub_langs = sorted(set(list(subtitles.keys()) + list(automatic_captions.keys())))
|
||||
if all_sub_langs:
|
||||
f.write("── Subtitles / Captions ─────────────────────────────────────\n")
|
||||
f.write(f"Languages ({len(all_sub_langs)}): {', '.join(all_sub_langs[:20])}")
|
||||
if len(all_sub_langs) > 20:
|
||||
f.write(f" ... (+{len(all_sub_langs)-20} more)")
|
||||
f.write("\n\n")
|
||||
|
||||
# Chapters
|
||||
if chapters:
|
||||
f.write("── Chapters ─────────────────────────────────────────────────\n")
|
||||
for ch in chapters:
|
||||
start = ch.get("start_time", 0)
|
||||
m, s = divmod(int(start), 60)
|
||||
h, m = divmod(m, 60)
|
||||
ts = f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
f.write(f" [{ts}] {ch.get('title', '')}\n")
|
||||
f.write("\n")
|
||||
|
||||
# Description
|
||||
if description:
|
||||
f.write("── Description ──────────────────────────────────────────────\n")
|
||||
# Limit description to 3000 chars to keep the file manageable
|
||||
if len(description) > 3000:
|
||||
f.write(description[:3000] + "\n... [truncated]\n")
|
||||
else:
|
||||
f.write(description + "\n")
|
||||
f.write("\n")
|
||||
|
||||
f.write("── Extraction Info ──────────────────────────────────────────\n")
|
||||
f.write(f"Extracted At : {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n")
|
||||
f.write(f"Extractor : {meta.get('extractor', 'youtube')}\n")
|
||||
f.write(f"Extractor Key : {meta.get('extractor_key', 'Youtube')}\n")
|
||||
f.write("=" * 70 + "\n")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Transcript
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_snippet_text(snippet) -> str:
|
||||
"""Safely extract text from a transcript snippet (dict or object)."""
|
||||
if isinstance(snippet, dict):
|
||||
return snippet.get("text", "")
|
||||
return getattr(snippet, "text", "") or ""
|
||||
|
||||
|
||||
def _get_snippet_start(snippet) -> float:
|
||||
"""Safely extract start time from a transcript snippet (dict or object)."""
|
||||
if isinstance(snippet, dict):
|
||||
return snippet.get("start", 0.0)
|
||||
return getattr(snippet, "start", 0.0) or 0.0
|
||||
|
||||
|
||||
def _fetch_transcript(video_id: str):
|
||||
"""
|
||||
Fetch transcript snippets using youtube-transcript-api v1.x.
|
||||
v1.x API: YouTubeTranscriptApi().list(video_id) and .fetch(video_id, languages=[...])
|
||||
Returns (FetchedTranscript iterable, errors_list) — iterable may be None on failure.
|
||||
"""
|
||||
errors = []
|
||||
api = YouTubeTranscriptApi()
|
||||
|
||||
# Fast path: try direct fetch with preferred languages
|
||||
for lang_group in [["en", "en-US", "en-GB"], ["en-US"], ["en-GB"]]:
|
||||
try:
|
||||
fetched = api.fetch(video_id, languages=lang_group)
|
||||
return fetched, errors
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Slower path: list all available transcripts and pick the best
|
||||
try:
|
||||
transcript_list = api.list(video_id)
|
||||
except Exception as e:
|
||||
errors.append(f"list error: {e}")
|
||||
# Last-ditch: fetch with no language preference
|
||||
try:
|
||||
fetched = api.fetch(video_id)
|
||||
return fetched, errors
|
||||
except Exception as e2:
|
||||
errors.append(f"fetch error: {e2}")
|
||||
return None, errors
|
||||
|
||||
# Prefer any manual transcript
|
||||
all_transcripts = list(transcript_list)
|
||||
manual = [t for t in all_transcripts if not getattr(t, "is_generated", True)]
|
||||
auto = [t for t in all_transcripts if getattr(t, "is_generated", False)]
|
||||
|
||||
for t in (manual + auto):
|
||||
try:
|
||||
# Try fetching via the transcript object's own fetch method
|
||||
fetched = t.fetch()
|
||||
return fetched, errors
|
||||
except Exception as e:
|
||||
errors.append(f"t.fetch() error for {getattr(t, 'language_code','?')}: {e}")
|
||||
continue
|
||||
|
||||
errors.append("No fetchable transcripts found")
|
||||
return None, errors
|
||||
|
||||
|
||||
def extract_transcript(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""
|
||||
Download transcript using youtube-transcript-api.
|
||||
Falls back to auto-generated captions. Saves as .txt with timestamps.
|
||||
"""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__transcript.txt")
|
||||
ensure_dir(output_folder)
|
||||
|
||||
transcript, errors = _fetch_transcript(video_id)
|
||||
|
||||
if not transcript:
|
||||
raise RuntimeError(f"Could not retrieve transcript for {video_id}. Errors: {'; '.join(errors)}")
|
||||
|
||||
# Write formatted transcript
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"Transcript: {meta.get('title', video_id)}\n")
|
||||
f.write(f"Video ID: {video_id}\n")
|
||||
f.write(f"URL: {video_url(video_id)}\n")
|
||||
f.write("=" * 60 + "\n\n")
|
||||
for entry in transcript:
|
||||
start = _get_snippet_start(entry)
|
||||
text = _get_snippet_text(entry).replace("\n", " ")
|
||||
minutes, seconds = divmod(int(start), 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
ts = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]" if hours else f"[{minutes:02d}:{seconds:02d}]"
|
||||
f.write(f"{ts} {text}\n")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def get_transcript_text(video_id: str) -> str:
|
||||
"""Get plain transcript text for summarization."""
|
||||
try:
|
||||
transcript, _ = _fetch_transcript(video_id)
|
||||
if transcript:
|
||||
return " ".join(_get_snippet_text(e) for e in transcript)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_summary(
|
||||
video_id: str,
|
||||
meta: Dict,
|
||||
output_folder: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
) -> str:
|
||||
"""Generate a summary using OpenRouter LLM and save as .txt."""
|
||||
if not api_key:
|
||||
raise RuntimeError("OpenRouter API key is not configured. Set it in the Settings panel.")
|
||||
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
file_path = os.path.join(output_folder, f"{base}__summary.txt")
|
||||
ensure_dir(output_folder)
|
||||
|
||||
# Get transcript text for summarization
|
||||
transcript_text = get_transcript_text(video_id)
|
||||
if not transcript_text:
|
||||
transcript_text = meta.get("description", "")
|
||||
if not transcript_text:
|
||||
raise RuntimeError(f"No transcript or description available for summarization of {video_id}")
|
||||
|
||||
# Truncate very long transcripts to ~12000 chars (token budget)
|
||||
max_chars = 12000
|
||||
if len(transcript_text) > max_chars:
|
||||
transcript_text = transcript_text[:max_chars] + "\n\n[Transcript truncated for length]"
|
||||
|
||||
client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=config.OPENROUTER_BASE_URL,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"Title: {title}\n"
|
||||
f"Video ID: {video_id}\n"
|
||||
f"URL: {video_url(video_id)}\n\n"
|
||||
f"Transcript:\n{transcript_text}"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model or config.OPENROUTER_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt or config.SUMMARY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=config.SUMMARY_MAX_TOKENS,
|
||||
)
|
||||
|
||||
summary_text = response.choices[0].message.content or ""
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"Summary: {title}\n")
|
||||
f.write(f"Video ID: {video_id}\n")
|
||||
f.write(f"URL: {video_url(video_id)}\n")
|
||||
f.write("=" * 60 + "\n\n")
|
||||
f.write(summary_text)
|
||||
f.write("\n")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: Thumbnail
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_thumbnail(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""Download the highest-resolution thumbnail available."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
ensure_dir(output_folder)
|
||||
|
||||
# Build priority list of thumbnail URLs
|
||||
thumbnails = meta.get("thumbnails", [])
|
||||
# Sort by resolution (width * height), highest first
|
||||
thumbnails_sorted = sorted(
|
||||
[t for t in thumbnails if t.get("url")],
|
||||
key=lambda t: (t.get("width", 0) or 0) * (t.get("height", 0) or 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Fallback URLs
|
||||
fallback_urls = [
|
||||
f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/sddefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg",
|
||||
f"https://img.youtube.com/vi/{video_id}/default.jpg",
|
||||
]
|
||||
|
||||
candidate_urls = [t["url"] for t in thumbnails_sorted] + fallback_urls
|
||||
|
||||
for url in candidate_urls:
|
||||
try:
|
||||
response = requests.get(url, timeout=15)
|
||||
if response.status_code == 200 and len(response.content) > 1000:
|
||||
# Determine extension from content-type or URL
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "webp" in ct or url.endswith(".webp"):
|
||||
ext = "webp"
|
||||
elif "png" in ct or url.endswith(".png"):
|
||||
ext = "png"
|
||||
else:
|
||||
ext = "jpg"
|
||||
file_path = os.path.join(output_folder, f"{base}__thumbnail.{ext}")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return file_path
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"Could not download thumbnail for {video_id}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: MP3
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mp3(video_id: str, meta: Dict, output_folder: str) -> str:
|
||||
"""Download audio and convert to MP3 using yt-dlp + ffmpeg."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
ensure_dir(output_folder)
|
||||
|
||||
ffmpeg_dir = _find_ffmpeg()
|
||||
|
||||
# yt-dlp will append extension itself; we use outtmpl without extension
|
||||
out_template = os.path.join(output_folder, f"{base}__audio")
|
||||
expected_path = os.path.join(output_folder, f"{base}__audio.mp3")
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"format": "bestaudio/best",
|
||||
"outtmpl": out_template,
|
||||
"postprocessors": [
|
||||
{
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": "mp3",
|
||||
"preferredquality": "192",
|
||||
}
|
||||
],
|
||||
"ignoreerrors": False,
|
||||
}
|
||||
|
||||
if ffmpeg_dir:
|
||||
ydl_opts["ffmpeg_location"] = ffmpeg_dir
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([video_url(video_id)])
|
||||
|
||||
# Rename to standard naming convention
|
||||
if os.path.exists(expected_path):
|
||||
final_path = os.path.join(output_folder, f"{base}__mp3.mp3")
|
||||
if not os.path.exists(final_path):
|
||||
os.rename(expected_path, final_path)
|
||||
return final_path
|
||||
|
||||
# Search for any mp3 file created in this run
|
||||
for fname in os.listdir(output_folder):
|
||||
if fname.startswith(base) and fname.endswith(".mp3"):
|
||||
final_path = os.path.join(output_folder, f"{base}__mp3.mp3")
|
||||
src = os.path.join(output_folder, fname)
|
||||
if src != final_path and not os.path.exists(final_path):
|
||||
os.rename(src, final_path)
|
||||
return final_path
|
||||
|
||||
raise RuntimeError(f"MP3 file not found after download for {video_id}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Artifact: MP4
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_ffmpeg() -> Optional[str]:
|
||||
"""Find ffmpeg binary location."""
|
||||
# Common Windows locations
|
||||
candidates = [
|
||||
r"C:\ProgramData\chocolatey\bin",
|
||||
r"C:\ffmpeg\bin",
|
||||
r"C:\Program Files\ffmpeg\bin",
|
||||
r"C:\Program Files (x86)\ffmpeg\bin",
|
||||
]
|
||||
for folder in candidates:
|
||||
if os.path.isfile(os.path.join(folder, "ffmpeg.exe")):
|
||||
return folder
|
||||
# Try PATH
|
||||
import shutil
|
||||
found = shutil.which("ffmpeg")
|
||||
if found:
|
||||
return str(Path(found).parent)
|
||||
return None
|
||||
|
||||
|
||||
def extract_mp4(video_id: str, meta: Dict, output_folder: str, resolution: str = "best") -> str:
|
||||
"""Download video as MP4 using yt-dlp. Explicitly uses ffmpeg for merging."""
|
||||
title = meta.get("title", "")
|
||||
base = make_base_name(title, video_id)
|
||||
ensure_dir(output_folder)
|
||||
|
||||
ffmpeg_dir = _find_ffmpeg()
|
||||
|
||||
# Build format selector — always require vcodec to avoid audio-only streams
|
||||
if resolution == "best":
|
||||
fmt = (
|
||||
"bestvideo[vcodec^=avc1][ext=mp4]+bestaudio[ext=m4a]"
|
||||
"/bestvideo[ext=mp4]+bestaudio[ext=m4a]"
|
||||
"/bestvideo+bestaudio"
|
||||
"/best[vcodec!=none]"
|
||||
"/best"
|
||||
)
|
||||
else:
|
||||
fmt = (
|
||||
f"bestvideo[vcodec^=avc1][height<={resolution}][ext=mp4]+bestaudio[ext=m4a]"
|
||||
f"/bestvideo[height<={resolution}][ext=mp4]+bestaudio[ext=m4a]"
|
||||
f"/bestvideo[height<={resolution}]+bestaudio"
|
||||
f"/best[height<={resolution}][vcodec!=none]"
|
||||
f"/best[height<={resolution}]"
|
||||
f"/best[vcodec!=none]"
|
||||
f"/best"
|
||||
)
|
||||
|
||||
out_template = os.path.join(output_folder, f"{base}__mp4.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"format": fmt,
|
||||
"outtmpl": out_template,
|
||||
"merge_output_format": "mp4",
|
||||
"ignoreerrors": False,
|
||||
"postprocessors": [
|
||||
{"key": "FFmpegVideoConvertor", "preferedformat": "mp4"},
|
||||
],
|
||||
}
|
||||
|
||||
# Explicitly tell yt-dlp where ffmpeg is
|
||||
if ffmpeg_dir:
|
||||
ydl_opts["ffmpeg_location"] = ffmpeg_dir
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([video_url(video_id)])
|
||||
|
||||
# Find the downloaded file
|
||||
for fname in sorted(os.listdir(output_folder)):
|
||||
if fname.startswith(f"{base}__mp4") and fname.endswith(".mp4"):
|
||||
return os.path.join(output_folder, fname)
|
||||
|
||||
raise RuntimeError(f"MP4 file not found after download for {video_id}. ffmpeg_dir={ffmpeg_dir}")
|
||||
Reference in New Issue
Block a user