Files
All-in-one-Financial-Analysis/atlas-terminal/server/services/video_transcript_service.py
T

930 lines
36 KiB
Python

"""Video transcript ingestion, transcription, and analysis service."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import shutil
import tempfile
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any, Literal
from urllib.parse import urlparse
from uuid import uuid4
import httpx
from fastapi import UploadFile
from server.db.unified_repo import repo
from server.services.gemini_service import generate_text
from server.services.text_chunker import _split_into_chunks, smart_chunk
try: # pragma: no cover - optional dependency is exercised in integration use.
import ffmpeg
except ImportError: # pragma: no cover - tests run without multimedia stack installed.
ffmpeg = None
try: # pragma: no cover - optional dependency is exercised in integration use.
import imageio_ffmpeg
except ImportError: # pragma: no cover - tests run without fallback binary installed.
imageio_ffmpeg = None
try: # pragma: no cover - optional dependency is exercised in integration use.
from faster_whisper import WhisperModel
except ImportError: # pragma: no cover - tests run without Whisper installed.
WhisperModel = None
try: # pragma: no cover - optional dependency is exercised in integration use.
import srt
except ImportError: # pragma: no cover - tests run without subtitle stack installed.
srt = None
try: # pragma: no cover - optional dependency is exercised in integration use.
from yt_dlp import YoutubeDL
except ImportError: # pragma: no cover - tests run without downloader installed.
YoutubeDL = None
logger = logging.getLogger(__name__)
_YOUTUBE_CACHE_TTL = 30 * 24 * 60 * 60
_REMOTE_MAX_BYTES = 500 * 1024 * 1024
_PENDING_STATUSES = {"queued", "fetching", "transcribing", "analyzing"}
_WHISPER_MODEL_LOCK = Lock()
_WHISPER_MODEL: Any | None = None
_TASKS: dict[str, asyncio.Task[None]] = {}
_TEXT_STOPWORDS = {
"about", "after", "again", "also", "been", "being", "because", "between", "could", "every",
"from", "have", "into", "just", "like", "more", "most", "only", "other", "over", "should",
"some", "such", "than", "that", "their", "there", "these", "they", "this", "those", "through",
"very", "were", "what", "when", "where", "which", "while", "with", "would", "your", "ourselves",
"ours", "you", "them", "then", "well", "will", "call", "speaker", "question", "answer", "video",
}
_POSITIVE_WORDS = {"growth", "strong", "improve", "upside", "record", "bullish", "accelerate", "opportunity"}
_NEGATIVE_WORDS = {"risk", "weak", "decline", "pressure", "downside", "loss", "uncertain", "headwind"}
_YOUTUBE_HOSTS = {
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"youtu.be",
"www.youtu.be",
}
_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".webm"}
_CAPTION_EXTENSIONS = {".srt", ".vtt"}
@dataclass
class SourceMaterial:
"""Resolved source asset for one transcript job."""
title: str | None = None
duration_sec: int | None = None
language: str | None = None
transcript_text: str | None = None
media_path: Path | None = None
cleanup_paths: list[Path] = field(default_factory=list)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _resolved_gemini_api_key(api_key: str | None = None) -> str | None:
resolved = (api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
return resolved or None
def _cache_key(source_url: str) -> str:
return f"video-transcript:{source_url.strip()}"
def _display_name_from_source(source_url: str) -> str:
parsed = urlparse(source_url)
if parsed.scheme:
if parsed.path:
tail = Path(parsed.path).name
return tail or parsed.netloc or source_url
return parsed.netloc or source_url
return Path(source_url).expanduser().name or source_url
def _detect_source_type(url_or_path: str, source_type: Literal["youtube", "url", "local"]) -> Literal["youtube", "url", "local"]:
candidate = (url_or_path or "").strip()
if source_type == "youtube":
return "youtube"
if source_type == "local":
return "local"
parsed = urlparse(candidate)
if parsed.scheme in {"http", "https"} and parsed.netloc.lower() in _YOUTUBE_HOSTS:
return "youtube"
if parsed.scheme in {"http", "https"}:
return "url"
if Path(candidate).expanduser().exists():
return "local"
return source_type
def _is_caption_file(path: Path) -> bool:
return path.suffix.lower() in _CAPTION_EXTENSIONS
def _requires_audio_extraction(path: Path) -> bool:
return path.suffix.lower() in _VIDEO_EXTENSIONS
def _is_supported_remote_content(content_type: str, suffix: str) -> bool:
if suffix in _CAPTION_EXTENSIONS | _VIDEO_EXTENSIONS | _AUDIO_EXTENSIONS:
return True
if content_type.startswith("video/") or content_type.startswith("audio/"):
return True
return content_type in {"text/plain", "text/vtt", "application/x-subrip", "application/octet-stream"}
def _infer_suffix(source_url: str, content_type: str) -> str:
parsed = urlparse(source_url)
suffix = Path(parsed.path).suffix.lower()
if suffix:
return suffix
mapping = {
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/x-m4a": ".m4a",
"text/plain": ".txt",
"text/vtt": ".vtt",
"application/x-subrip": ".srt",
}
return mapping.get(content_type, ".bin")
def _cleanup_paths(paths: list[Path]) -> None:
seen: set[str] = set()
for path in paths:
raw = str(path)
if not raw or raw in seen:
continue
seen.add(raw)
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
with suppress(FileNotFoundError):
path.unlink()
def _resolve_ffmpeg_binary() -> str:
binary = shutil.which("ffmpeg")
if binary:
return binary
if imageio_ffmpeg is not None:
return imageio_ffmpeg.get_ffmpeg_exe()
raise RuntimeError(
"ffmpeg is not installed. Install Homebrew ffmpeg or add `imageio-ffmpeg` to the Python environment."
)
def _persist_upload(upload: UploadFile, target_path: Path) -> None:
target_path.parent.mkdir(parents=True, exist_ok=True)
upload.file.seek(0)
with target_path.open("wb") as handle:
shutil.copyfileobj(upload.file, handle)
def _pick_caption_file(directory: Path) -> Path | None:
candidates = [path for path in directory.iterdir() if path.suffix.lower() in _CAPTION_EXTENSIONS]
if not candidates:
return None
def _score(path: Path) -> tuple[int, str]:
name = path.name.lower()
if ".ko" in name:
return (0, name)
if ".en" in name:
return (1, name)
return (2, name)
return sorted(candidates, key=_score)[0]
def _language_from_filename(path: Path) -> str | None:
name = path.name.lower()
if ".ko" in name:
return "ko"
if ".en" in name:
return "en"
return None
def _parse_vtt_text(vtt_content: str) -> str:
lines: list[str] = []
for raw_line in vtt_content.splitlines():
line = raw_line.strip()
if not line or line == "WEBVTT" or "-->" in line:
continue
if line.isdigit() or line.startswith(("NOTE", "Kind:", "Language:")):
continue
lines.append(line)
return "\n".join(lines).strip()
def _parse_caption_text(content: str, suffix: str) -> str:
if suffix == ".vtt":
return _parse_vtt_text(content)
return _parse_srt_text(content)
def _extract_json_object(text: str) -> dict[str, Any] | None:
if not text or not text.strip():
return None
cleaned = re.sub(r"^```(?:json)?\s*", "", text.strip())
cleaned = re.sub(r"\s*```$", "", cleaned).strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
if not match:
return None
try:
parsed = json.loads(match.group(0))
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
def _top_terms(text: str, limit: int = 6) -> list[str]:
counter: dict[str, int] = {}
for token in re.findall(r"[A-Za-z][A-Za-z\-]{3,}", text.lower()):
if token in _TEXT_STOPWORDS:
continue
counter[token] = counter.get(token, 0) + 1
ranked = sorted(counter.items(), key=lambda item: (-item[1], item[0]))
return [token for token, _count in ranked[:limit]]
def _fallback_analysis(text: str) -> dict[str, Any]:
excerpt = smart_chunk(text.strip(), max_chars=720)
positives = sum(text.lower().count(word) for word in _POSITIVE_WORDS)
negatives = sum(text.lower().count(word) for word in _NEGATIVE_WORDS)
if positives > negatives:
sentiment = "positive"
elif negatives > positives:
sentiment = "negative"
else:
sentiment = "neutral"
keywords = _top_terms(text, limit=6)
return {
"summary": excerpt or "Transcript extracted, but AI summary is unavailable.",
"keywords": keywords,
"topics": keywords[:4],
"sentiment": sentiment,
"intent": "Review transcript manually or add a Gemini API key for richer analysis.",
}
def _language_display_name(target_language: str) -> str:
return {
"ko": "Korean",
"ja": "Japanese",
"en": "English",
}.get(target_language.lower(), target_language.upper())
def _normalise_analysis(payload: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
keywords = payload.get("keywords")
topics = payload.get("topics")
sentiment = str(payload.get("sentiment") or fallback["sentiment"]).strip().lower()
if sentiment not in {"positive", "neutral", "negative"}:
sentiment = fallback["sentiment"]
return {
"summary": str(payload.get("summary") or fallback["summary"]).strip(),
"keywords": [str(item).strip() for item in (keywords if isinstance(keywords, list) else fallback["keywords"]) if str(item).strip()],
"topics": [str(item).strip() for item in (topics if isinstance(topics, list) else fallback["topics"]) if str(item).strip()],
"sentiment": sentiment,
"intent": str(payload.get("intent") or fallback["intent"]).strip(),
}
def _get_whisper_model() -> Any:
global _WHISPER_MODEL
if _WHISPER_MODEL is not None:
return _WHISPER_MODEL
if WhisperModel is None:
raise RuntimeError("faster-whisper is not installed. Run `pip install -r requirements.txt`.")
with _WHISPER_MODEL_LOCK:
if _WHISPER_MODEL is None:
_WHISPER_MODEL = WhisperModel(
os.getenv("WHISPER_MODEL_SIZE", "base"),
device="cpu",
compute_type=os.getenv("WHISPER_COMPUTE_TYPE", "int8"),
)
return _WHISPER_MODEL
async def submit_job(
url_or_path: str,
source_type: Literal["youtube", "url", "local"],
*,
language: str | None = None,
api_key: str | None = None,
source_label: str | None = None,
cleanup_source: bool = False,
) -> str:
"""Create a new transcript job and start asynchronous processing."""
resolved_source_type = _detect_source_type(url_or_path, source_type)
source_value = url_or_path.strip() if resolved_source_type != "local" else str(Path(url_or_path).expanduser())
job_id = uuid4().hex
await repo.add_video_job(job_id, source_value, resolved_source_type)
if source_label:
await repo.update_video_job(job_id, title=source_label)
task = asyncio.create_task(
_process_job(
job_id,
language=language,
api_key=(api_key or "").strip() or None,
source_label=source_label,
cleanup_source=cleanup_source,
)
)
_TASKS[job_id] = task
task.add_done_callback(lambda _: _TASKS.pop(job_id, None))
return job_id
async def submit_upload(upload: UploadFile, *, language: str | None = None, api_key: str | None = None) -> str:
"""Persist a multipart upload locally and enqueue it as a transcript job."""
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-upload-"))
suffix = Path(upload.filename or "upload.bin").suffix
target_path = temp_dir / f"upload{suffix}"
await asyncio.to_thread(_persist_upload, upload, target_path)
await upload.close()
return await submit_job(
str(target_path),
"local",
language=language,
api_key=api_key,
source_label=upload.filename or target_path.name,
cleanup_source=True,
)
async def list_jobs(limit: int = 50) -> list[dict[str, Any]]:
return await repo.list_video_jobs(limit=limit)
async def get_job_detail(job_id: str) -> dict[str, Any] | None:
row = await repo.get_video_job(job_id)
if row is None:
return None
return {
"job": {
"job_id": row["job_id"],
"status": row["status"],
"source_url": row["source_url"],
"source_type": row["source_type"],
"progress": row.get("progress", 0),
"error": row.get("error"),
"title": row.get("title"),
"duration_sec": row.get("duration_sec"),
"language": row.get("language"),
"created_at": row["created_at"],
"completed_at": row.get("completed_at"),
},
"transcript": None if not row.get("transcript_text") else {
"job_id": row["job_id"],
"text": row.get("transcript_text", ""),
"summary": row.get("summary"),
"keywords": row.get("keywords", []),
"topics": row.get("topics", []),
"sentiment": row.get("sentiment"),
"intent": row.get("intent"),
},
}
async def search_jobs(query: str, limit: int = 20) -> list[dict[str, Any]]:
return await repo.search_videos(query, limit=limit)
async def delete_job(job_id: str) -> bool:
task = _TASKS.pop(job_id, None)
if task is not None and not task.done():
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
return await repo.delete_video_job(job_id)
async def translate_job_content(
job_id: str,
*,
target_language: str = "ko",
api_key: str | None = None,
) -> dict[str, Any] | None:
"""Translate a stored transcript payload on demand."""
row = await repo.get_video_job(job_id)
if row is None:
return None
transcript_text = (row.get("transcript_text") or "").strip()
if not transcript_text:
raise ValueError("Transcript text is not available yet.")
resolved_api_key = _resolved_gemini_api_key(api_key)
if not resolved_api_key:
raise RuntimeError("Set a Gemini API key in Settings to use Korean translation.")
target_label = _language_display_name(target_language)
translated_text = await _translate_text_chunks(
transcript_text,
target_language=target_language,
api_key=resolved_api_key,
)
source_payload = {
"summary": row.get("summary") or "",
"keywords": row.get("keywords", []),
"topics": row.get("topics", []),
"intent": row.get("intent") or "",
}
meta_prompt = (
f"Translate the following analyst metadata into natural {target_label}.\n"
"Return ONLY valid JSON with this exact shape:\n"
'{"summary":"...","keywords":["..."],"topics":["..."],"intent":"..."}\n'
"- Preserve company names, proper nouns, and figures.\n"
"- Translate keywords and topics into concise Korean finance/media terms.\n"
"- Do not add markdown fences or commentary.\n\n"
f"SOURCE_JSON:\n{json.dumps(source_payload, ensure_ascii=False)}"
)
translated_meta = _extract_json_object(
await generate_text(meta_prompt, temperature=0.2, max_tokens=1200, api_key=resolved_api_key)
) or {}
translated_summary = str(translated_meta.get("summary") or "").strip()
if not translated_summary:
translated_summary = await _translate_text_chunks(
source_payload["summary"] or smart_chunk(transcript_text, max_chars=1500),
target_language=target_language,
api_key=resolved_api_key,
max_chars=4_500,
min_chunk=1_200,
)
return {
"job_id": job_id,
"target_language": target_language,
"summary": translated_summary,
"keywords": [str(item).strip() for item in (translated_meta.get("keywords") or source_payload["keywords"]) if str(item).strip()],
"topics": [str(item).strip() for item in (translated_meta.get("topics") or source_payload["topics"]) if str(item).strip()],
"intent": str(translated_meta.get("intent") or source_payload["intent"]).strip(),
"text": translated_text,
}
async def _process_job(
job_id: str,
*,
language: str | None = None,
api_key: str | None = None,
source_label: str | None = None,
cleanup_source: bool = False,
) -> None:
"""Resolve source media, extract transcript text, run analysis, and persist results."""
row = await repo.get_video_job(job_id)
if row is None:
return
cleanup_paths: list[Path] = []
try:
source_url = row["source_url"]
source_type = _detect_source_type(source_url, row["source_type"])
await repo.update_video_job(job_id, source_type=source_type, status="fetching", progress=15, error=None)
if source_type == "youtube":
cached = await repo.cache_get(_cache_key(source_url))
if isinstance(cached, dict) and cached.get("transcript_text"):
await repo.update_video_job(
job_id,
title=source_label or cached.get("title"),
duration_sec=cached.get("duration_sec"),
language=cached.get("language"),
transcript_text=cached.get("transcript_text"),
summary=cached.get("summary"),
keywords=cached.get("keywords", []),
topics=cached.get("topics", []),
sentiment=cached.get("sentiment"),
intent=cached.get("intent"),
status="completed",
progress=100,
completed_at=_now_iso(),
error=None,
)
return
material = await _fetch_youtube_source(source_url, preferred_language=language)
elif source_type == "local":
material = await _prepare_local_source(source_url, owned_source=cleanup_source)
else:
material = await _fetch_remote_source(source_url)
cleanup_paths.extend(material.cleanup_paths)
title = source_label or material.title or row.get("title") or _display_name_from_source(source_url)
detected_language = material.language or language
duration_sec = material.duration_sec
await repo.update_video_job(
job_id,
title=title,
duration_sec=duration_sec,
language=detected_language,
status="transcribing",
progress=45,
)
transcript_text = (material.transcript_text or "").strip()
if not transcript_text:
if material.media_path is None:
raise RuntimeError("No subtitle track or downloadable media was found for this source.")
transcribe_path = material.media_path
if _requires_audio_extraction(material.media_path):
transcribe_path = await asyncio.to_thread(_extract_audio_track, material.media_path)
cleanup_paths.append(transcribe_path)
whisper_result = await asyncio.to_thread(_whisper_transcribe, str(transcribe_path), language)
transcript_text = (whisper_result.get("text") or "").strip()
detected_language = whisper_result.get("language") or detected_language
duration_value = whisper_result.get("duration_sec") or duration_sec
duration_sec = int(round(duration_value)) if duration_value else duration_sec
if not transcript_text:
raise RuntimeError("Transcript extraction finished, but no text was produced.")
await repo.update_video_job(
job_id,
title=title,
duration_sec=duration_sec,
language=detected_language,
transcript_text=transcript_text,
progress=65,
)
await repo.update_video_job(job_id, status="analyzing", progress=80)
analysis = await _analyze_text(transcript_text, api_key=api_key, title=title)
completed_at = _now_iso()
await repo.update_video_job(
job_id,
status="completed",
progress=100,
completed_at=completed_at,
error=None,
title=title,
duration_sec=duration_sec,
language=detected_language,
transcript_text=transcript_text,
summary=analysis["summary"],
keywords=analysis["keywords"],
topics=analysis["topics"],
sentiment=analysis["sentiment"],
intent=analysis["intent"],
)
if source_type == "youtube":
await repo.cache_set(
_cache_key(source_url),
{
"title": title,
"duration_sec": duration_sec,
"language": detected_language,
"transcript_text": transcript_text,
"summary": analysis["summary"],
"keywords": analysis["keywords"],
"topics": analysis["topics"],
"sentiment": analysis["sentiment"],
"intent": analysis["intent"],
"completed_at": completed_at,
},
ttl=_YOUTUBE_CACHE_TTL,
)
except asyncio.CancelledError:
logger.info("Video transcript job %s cancelled.", job_id)
raise
except Exception as exc:
logger.exception("Video transcript job %s failed", job_id)
await repo.update_video_job(
job_id,
status="failed",
progress=100,
error=str(exc),
completed_at=_now_iso(),
)
finally:
await asyncio.to_thread(_cleanup_paths, cleanup_paths)
async def _prepare_local_source(source_path: str, *, owned_source: bool = False) -> SourceMaterial:
path = Path(source_path).expanduser()
if not path.exists():
raise RuntimeError(f"Local file not found: {path}")
cleanup_paths = [path.parent] if owned_source else []
if _is_caption_file(path):
content = await asyncio.to_thread(path.read_text, encoding="utf-8", errors="ignore")
return SourceMaterial(
title=path.name,
language=_language_from_filename(path),
transcript_text=_parse_caption_text(content, path.suffix.lower()),
cleanup_paths=cleanup_paths,
)
return SourceMaterial(
title=path.name,
media_path=path,
cleanup_paths=cleanup_paths,
)
async def _fetch_remote_source(source_url: str) -> SourceMaterial:
if _detect_source_type(source_url, "url") == "youtube":
return await _fetch_youtube_source(source_url)
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-remote-"))
content_type = ""
target_path = temp_dir / "download.bin"
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(60.0, connect=20.0)) as client:
async with client.stream("GET", source_url) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
content_length = int(response.headers.get("content-length") or 0)
if content_length and content_length > _REMOTE_MAX_BYTES:
raise RuntimeError("Remote media is larger than the 500MB safety limit.")
suffix = _infer_suffix(source_url, content_type)
if not _is_supported_remote_content(content_type, suffix):
raise RuntimeError(f"Unsupported remote content type: {content_type or suffix}")
target_path = temp_dir / f"download{suffix}"
total_bytes = 0
with target_path.open("wb") as handle:
async for chunk in response.aiter_bytes():
total_bytes += len(chunk)
if total_bytes > _REMOTE_MAX_BYTES:
raise RuntimeError("Remote media exceeded the 500MB safety limit while downloading.")
handle.write(chunk)
except Exception:
await asyncio.to_thread(_cleanup_paths, [temp_dir])
raise
if _is_caption_file(target_path):
content = await asyncio.to_thread(target_path.read_text, encoding="utf-8", errors="ignore")
return SourceMaterial(
title=_display_name_from_source(source_url),
language=_language_from_filename(target_path),
transcript_text=_parse_caption_text(content, target_path.suffix.lower()),
cleanup_paths=[temp_dir],
)
return SourceMaterial(
title=_display_name_from_source(source_url),
media_path=target_path,
cleanup_paths=[temp_dir],
)
async def _fetch_youtube_source(source_url: str, preferred_language: str | None = None) -> SourceMaterial:
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-youtube-"))
try:
info: dict[str, Any] = {}
try:
info = await asyncio.to_thread(_download_youtube_subtitles, source_url, temp_dir, preferred_language)
except Exception:
logger.warning("YouTube subtitle download failed for %s; falling back to audio transcription.", source_url, exc_info=True)
title = str(info.get("title") or _display_name_from_source(source_url))
duration_sec = int(info.get("duration")) if info.get("duration") else None
caption_path = _pick_caption_file(temp_dir)
if caption_path is not None:
content = await asyncio.to_thread(caption_path.read_text, encoding="utf-8", errors="ignore")
return SourceMaterial(
title=title,
duration_sec=duration_sec,
language=_language_from_filename(caption_path),
transcript_text=_parse_caption_text(content, caption_path.suffix.lower()),
cleanup_paths=[temp_dir],
)
media_path, audio_info = await asyncio.to_thread(_download_youtube_audio, source_url, temp_dir)
return SourceMaterial(
title=str(audio_info.get("title") or title),
duration_sec=int(audio_info.get("duration")) if audio_info.get("duration") else duration_sec,
media_path=media_path,
cleanup_paths=[temp_dir],
)
except Exception:
await asyncio.to_thread(_cleanup_paths, [temp_dir])
raise
def _normalise_subtitle_languages(preferred_language: str | None = None) -> list[str]:
ordered: list[str] = []
for value in [preferred_language, "en", "ko", "en-US", "ko-KR"]:
candidate = (value or "").strip()
if candidate and candidate not in ordered:
ordered.append(candidate)
return ordered
def _download_youtube_subtitles(source_url: str, output_dir: Path, preferred_language: str | None = None) -> dict[str, Any]:
if YoutubeDL is None:
raise RuntimeError("yt-dlp is not installed. Run `pip install -r requirements.txt`.")
last_error: Exception | None = None
for language in _normalise_subtitle_languages(preferred_language):
options = {
"quiet": True,
"no_warnings": True,
"noprogress": True,
"noplaylist": True,
"skip_download": True,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": [language],
"subtitlesformat": "srt/vtt/best",
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
}
try:
with YoutubeDL(options) as ydl:
info = ydl.extract_info(source_url, download=True)
if _pick_caption_file(output_dir) is not None:
return info
except Exception as exc:
last_error = exc
logger.warning("Subtitle attempt failed for language %s on %s", language, source_url, exc_info=True)
if last_error is not None:
raise last_error
return {}
def _download_youtube_audio(source_url: str, output_dir: Path) -> tuple[Path, dict[str, Any]]:
if YoutubeDL is None:
raise RuntimeError("yt-dlp is not installed. Run `pip install -r requirements.txt`.")
options = {
"quiet": True,
"no_warnings": True,
"noprogress": True,
"noplaylist": True,
"format": "bestaudio[ext=m4a]/bestaudio/best",
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
}
with YoutubeDL(options) as ydl:
info = ydl.extract_info(source_url, download=True)
return Path(ydl.prepare_filename(info)), info
def _extract_audio_track(media_path: Path) -> Path:
if ffmpeg is None:
raise RuntimeError("ffmpeg-python is not installed. Run `pip install -r requirements.txt`.")
output_path = media_path.parent / f"{media_path.stem}.atlas.wav"
ffmpeg_binary = _resolve_ffmpeg_binary()
try:
stream = ffmpeg.input(str(media_path))
stream = ffmpeg.output(stream, str(output_path), acodec="pcm_s16le", ac=1, ar="16000", format="wav")
ffmpeg.run(stream.overwrite_output(), cmd=ffmpeg_binary, capture_stdout=True, capture_stderr=True)
except ffmpeg.Error as exc: # type: ignore[attr-defined]
stderr = exc.stderr.decode("utf-8", errors="ignore") if getattr(exc, "stderr", None) else str(exc)
raise RuntimeError(f"ffmpeg failed to extract audio: {stderr.strip()}") from exc
return output_path
def _whisper_transcribe(audio_path: str, language: str | None = None) -> dict[str, Any]:
"""Transcribe audio or video with a lazily loaded faster-whisper model."""
model = _get_whisper_model()
segments, info = model.transcribe(audio_path, language=language, vad_filter=True, beam_size=5)
parsed_segments: list[dict[str, Any]] = []
for segment in segments:
text = (segment.text or "").strip()
if not text:
continue
parsed_segments.append(
{
"start": round(float(segment.start), 2),
"end": round(float(segment.end), 2),
"text": text,
}
)
full_text = " ".join(segment["text"] for segment in parsed_segments).strip()
duration_sec = getattr(info, "duration", None) or (parsed_segments[-1]["end"] if parsed_segments else 0)
return {
"text": full_text,
"language": getattr(info, "language", None) or language,
"duration_sec": duration_sec,
"segments": parsed_segments,
}
def _parse_srt_text(srt_content: str) -> str:
"""Strip timing metadata and join subtitle text into a clean transcript."""
if not srt_content.strip():
return ""
if srt is None:
cleaned = re.sub(r"\d+\s+\d{2}:\d{2}:\d{2},\d{3}\s+-->\s+\d{2}:\d{2}:\d{2},\d{3}", "", srt_content)
cleaned = re.sub(r"^\d+\s*$", "", cleaned, flags=re.MULTILINE)
return "\n".join(line.strip() for line in cleaned.splitlines() if line.strip())
subtitles = []
for item in srt.parse(srt_content):
text = " ".join(line.strip() for line in item.content.splitlines() if line.strip()).strip()
if text:
subtitles.append(text)
return "\n".join(subtitles)
async def _analyze_text(text: str, *, api_key: str | None = None, title: str | None = None) -> dict[str, Any]:
"""Generate a summary, topics, and sentiment for a transcript."""
fallback = _fallback_analysis(text)
chunk_candidates = _split_into_chunks(text, max_chars=10_000, min_chunk=3_000)
chunks = [smart_chunk(chunk, max_chars=10_000) for chunk in chunk_candidates if chunk.strip()] or [smart_chunk(text, max_chars=10_000)]
try:
partial_summaries: list[str] = []
for index, chunk in enumerate(chunks, start=1):
prompt = (
"You are preparing analyst notes from one transcript chunk.\n"
f"Chunk {index} of {len(chunks)} for: {title or 'Untitled media'}\n\n"
"Return 4-6 concise bullet points covering factual takeaways, recurring themes, risks, and speaker tone.\n"
"Do not invent facts. Keep names, products, and figures exact when present.\n\n"
f"TRANSCRIPT CHUNK:\n{chunk}"
)
partial_summaries.append(
await generate_text(prompt, temperature=0.2, max_tokens=700, api_key=api_key)
)
summary_blob = "\n\n".join(f"Chunk {idx} notes:\n{summary.strip()}" for idx, summary in enumerate(partial_summaries, start=1))
head_sample = smart_chunk(text[:10_000], max_chars=2_000)
tail_sample = smart_chunk(text[-10_000:], max_chars=2_000)
final_prompt = (
"You are an expert media and transcript analyst.\n"
"Use the chunk notes and transcript samples below.\n"
"Return ONLY valid JSON with this exact shape:\n"
'{"summary":"...","keywords":["..."],"topics":["..."],"sentiment":"positive|neutral|negative","intent":"..."}\n'
"Rules:\n"
"- summary: 3-5 sentences, specific and factual\n"
"- keywords: 5-8 concise phrases\n"
"- topics: 3-5 broader themes\n"
"- sentiment: choose only positive, neutral, or negative\n"
"- intent: one concise sentence describing what the speaker or content is trying to achieve\n"
"- Do not add markdown fences or commentary.\n\n"
f"TITLE: {title or 'Untitled media'}\n\n"
f"CHUNK NOTES:\n{summary_blob}\n\n"
f"HEAD SAMPLE:\n{head_sample}\n\n"
f"TAIL SAMPLE:\n{tail_sample}"
)
parsed = _extract_json_object(
await generate_text(final_prompt, temperature=0.2, max_tokens=1200, api_key=api_key)
)
if parsed is None:
return fallback
return _normalise_analysis(parsed, fallback)
except Exception:
logger.warning("Falling back to heuristic transcript analysis.", exc_info=True)
return fallback
async def _translate_text_chunks(
text: str,
*,
target_language: str,
api_key: str,
max_chars: int = 7_000,
min_chunk: int = 2_000,
) -> str:
"""Translate long text in chunks while preserving structure."""
source = text.strip()
if not source:
return ""
target_label = _language_display_name(target_language)
chunks = _split_into_chunks(source, max_chars=max_chars, min_chunk=min_chunk) or [source]
translated_chunks: list[str] = []
for index, chunk in enumerate(chunks, start=1):
prompt = (
f"Translate the following transcript chunk into natural {target_label}.\n"
"- Preserve meaning, numbers, names, and paragraph breaks.\n"
"- Keep speaker turns and emphasis where obvious.\n"
"- Return ONLY the translated text.\n"
f"- This is chunk {index} of {len(chunks)}.\n\n"
f"TRANSCRIPT CHUNK:\n{chunk}"
)
translated_chunks.append(
(await generate_text(prompt, temperature=0.2, max_tokens=2200, api_key=api_key)).strip()
)
return "\n\n".join(chunk for chunk in translated_chunks if chunk)