second-brain/src/second_brain/adapters/youtube.py
Travis Herbranson 33ae5a6f7d pipeline split: tower transcribe worker + claim queue + faster-whisper
Splits pull+transcribe (now tower-side, eager) from extract+embed
(stays on the dev scheduler). Three machine-coordination pieces land
together because they reference each other:

- v3 migration adds sources.claimed_by + claimed_at — observability +
  stale-claim recovery columns. The actual race-safety primitive is
  `SELECT ... FOR UPDATE SKIP LOCKED` in the new claim helper, so two
  machines can poll the queue without doubling work.

- src/second_brain/claim.py owns the claim dance (claim_next_source,
  release_claim, reap_stale_claims). Both stage gates filter by
  source_type so the tower never grabs articles and the dev side never
  grabs videos.

- src/second_brain/transcribe.py wraps faster-whisper (lazy-imported so
  it stays out of the dev install). resolve_settings() reads env >
  [whisper] block > defaults, falling back to int8 on cpu / float16 on
  cuda when compute_type is unspecified. Default model large-v3.

- src/second_brain/scheduler/transcribe_worker.py is the long-running
  poll loop. Reads pipeline_settings every iteration so the dashboard's
  enable/window/max-items/max-video-length take effect within one
  cycle. Reaps stale claims at startup. SIGTERM-clean. DB-unreachable
  backs off with a log line; never crash-loops.

- adapters/youtube.py drops the torch-whisper transcribe path; pull
  stays. Removes openai-whisper from the default deps and gates
  faster-whisper behind a new `tower` extra (uv sync --extra tower).

- main.py: new `second-brain transcribe-worker` (--once for ad-hoc).
  `process` now article-only on the pull side but still picks up
  TRANSCRIBED of any source_type for the extract step.

Live-verified: migration applies clean, transcribe-worker --once
honors transcription_enabled=false gate.
2026-05-25 10:11:37 -04:00

143 lines
4.8 KiB
Python

"""
YouTube pull adapter (yt-dlp only).
Transcription used to live here on a torch-whisper backend. It moved to
`second_brain.transcribe` (faster-whisper) once the pipeline split so the
tower owns transcription and the dev side never imports the heavy model.
This module is now safe to import from any process — yt-dlp is small, and
no GPU / whisper dependency is loaded at import time.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional
import yt_dlp
from second_brain.config import Config
from second_brain.models import Source, SourceStatus
class VideoDownloader:
"""Download a YouTube video (or playlist) via yt-dlp.
Ported from xtract VideoDownloader — same core yt-dlp options,
adapted to write into config.media_dir.
"""
def __init__(self, config: Config) -> None:
self.config = config
def download(self, url: str) -> list[Path]:
"""Download video(s) and return paths to the downloaded files."""
config = self.config
config.media_dir.mkdir(parents=True, exist_ok=True)
ydl_opts = {
"format": "bestvideo[height<=720]+bestaudio/best[height<=720]",
"outtmpl": str(config.media_dir / "%(id)s.%(ext)s"),
"writeinfojson": True,
"ignoreerrors": True,
"no_warnings": False,
"quiet": False,
}
downloaded: list[Path] = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
print(f"[yt-dlp] Fetching: {url}")
info = ydl.extract_info(url, download=True)
if info is None:
print(f"[yt-dlp] Error: could not extract info from {url}")
return downloaded
if "entries" in info:
for entry in info["entries"]:
if entry:
p = self._find_media_file(entry.get("id", ""))
if p:
downloaded.append(p)
else:
p = self._find_media_file(info.get("id", ""))
if p:
downloaded.append(p)
return downloaded
def _find_media_file(self, video_id: str) -> Optional[Path]:
for f in self.config.media_dir.glob(f"{video_id}.*"):
if f.suffix in {".mp4", ".mkv", ".webm", ".m4a"}:
return f
return None
def extract_metadata(self, url: str) -> dict:
"""Fetch video metadata without downloading the media file."""
ydl_opts = {"quiet": True, "skip_download": True}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) or {}
return {
"title": info.get("title"),
"author": info.get("uploader"),
"duration_seconds": info.get("duration"),
"published_at": _parse_upload_date(info.get("upload_date")),
}
# ---------------------------------------------------------------------------
# High-level adapter
# ---------------------------------------------------------------------------
class YouTubeAdapter:
"""yt-dlp pull side of a video Source. Transcription is handled by
`second_brain.transcribe.Transcriber` on the tower."""
def __init__(self, config: Config) -> None:
self.config = config
self.downloader = VideoDownloader(config)
def pull(self, source: Source) -> bool:
"""Download the video. Updates source in-place, returns success."""
try:
meta = self.downloader.extract_metadata(source.url)
source.title = source.title or meta.get("title")
source.author = meta.get("author")
source.duration_seconds = meta.get("duration_seconds")
source.published_at = meta.get("published_at")
files = self.downloader.download(source.url)
if not files:
source.error_message = "yt-dlp returned no files"
source.status = SourceStatus.FAILED
return False
source.media_path = str(files[0])
source.status = SourceStatus.PULLED
return True
except Exception as exc:
source.error_message = str(exc)
source.status = SourceStatus.FAILED
return False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_upload_date(upload_date: Optional[str]) -> Optional[datetime]:
"""Parse yt-dlp's YYYYMMDD upload_date string."""
if not upload_date or len(upload_date) != 8:
return None
try:
return datetime.strptime(upload_date, "%Y%m%d")
except ValueError:
return None
__all__ = ["VideoDownloader", "YouTubeAdapter"]