diff --git a/youtube_to_srt.py b/youtube_to_srt.py new file mode 100755 index 0000000..16776ed --- /dev/null +++ b/youtube_to_srt.py @@ -0,0 +1,86 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["yt-dlp"] +# /// +import argparse +import logging +import re +import subprocess +import tempfile +from pathlib import Path + +import yt_dlp + +VIDEO_TO_SRT = Path(__file__).resolve().with_name("video_to_srt.py") +MAX_FILENAME_BYTES = 240 + +logger = logging.getLogger(__name__) + + +def parse_args() -> tuple[argparse.Namespace, list[str]]: + """Parse command-line arguments; unknown options are returned to be forwarded to video_to_srt.py.""" + parser = argparse.ArgumentParser( + description="Download a YouTube video's audio and transcribe it with video_to_srt.py. " + "Options not listed here (--model, --speed-up, --language, --chunk-minutes, --api-key, --no-diarize, ...) are passed through.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("video", help="YouTube URL or video id") + parser.add_argument("--output-dir", "-d", type=Path, default=Path.cwd(), help="Directory for the SRT file") + return parser.parse_known_args() + + +def sanitize(text: str) -> str: + """Make text safe for a filename: path separators and other reserved characters become '-', whitespace collapses.""" + # [\\/:*?"<>|\x00-\x1f] = characters not allowed on common filesystems, plus control characters + text = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "-", text) + return re.sub(r"\s+", " ", text).strip() + + +def format_date(upload_date: str | None) -> str: + """Convert yt-dlp's YYYYMMDD upload date to yyyy-mm-dd ('unknown-date' if missing).""" + if not upload_date or len(upload_date) != 8: + return "unknown-date" + return f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}" + + +def build_filename(info: dict) -> str: + """Build ' -- @ -- -- <yyyy-mm-dd>.srt' from yt-dlp metadata. + + Author is the channel's display name, channel is its @handle (falls back to the channel id). + The title is shortened so the whole name stays under MAX_FILENAME_BYTES. + """ + author = sanitize(info.get("uploader") or info.get("channel") or "unknown") + handle = (info.get("uploader_id") or info.get("channel_id") or "unknown").lstrip("@") + suffix = f" -- @{sanitize(handle)} -- " + date = f" -- {format_date(info.get('upload_date'))}.srt" + title = sanitize(info.get("title") or info["id"]) + budget = MAX_FILENAME_BYTES - len(f"{author}{suffix}{date}".encode()) + while len(title.encode()) > budget: + title = title[:-1] + return f"{author}{suffix}{title.strip()}{date}" + + +def download_audio(url: str, directory: Path) -> tuple[Path, dict]: + """Download the best audio-only stream into `directory`. Returns (file path, yt-dlp metadata).""" + options = {"format": "bestaudio/best", "outtmpl": str(directory / "%(id)s.%(ext)s"), "noplaylist": True, "quiet": True} + with yt_dlp.YoutubeDL(options) as downloader: + info = downloader.extract_info(url, download=True) + return Path(downloader.prepare_filename(info)), info + + +def main() -> None: + """Download the audio, transcribe it with video_to_srt.py and save the SRT under the metadata-based name.""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + args, forwarded = parse_args() + url = args.video if "/" in args.video else f"https://www.youtube.com/watch?v={args.video}" + args.output_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as temp_directory: + audio, info = download_audio(url=url, directory=Path(temp_directory)) + output = args.output_dir / build_filename(info=info) + logger.info("Downloaded %s, writing %s", audio.name, output) + subprocess.run([str(VIDEO_TO_SRT), str(audio), "--output", str(output), *forwarded], check=True) + + +if __name__ == "__main__": + main()