From 802d15184f3702833f47b1e3ce79529918d0a471 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:39:39 +0000 Subject: [PATCH 1/3] Support members-only videos behind an opt-in flag A channel's members-only videos sit in its uploads listing, so a run walked into "Join this channel to get access to members-only content" and recorded a failure it could never recover from. They now carry a flag read off the listing's "Members only" badge (yt-dlp's `availability: subscriber_only`) and are passed over by default, counted in the run report rather than failed. `include_members_only = true` takes them instead, which needs cookies from an account that holds the membership: `cookies_from_browser` reads them out of a browser, alongside the existing `cookies_file`. Turning the flag on without either is refused up front, since every such download would fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQGeprPNAu7gzs5JJaDQGQ --- README.md | 64 +++++++++++++++++++++++++++++++++++++++- src/ytscript/cli.py | 39 +++++++++++++++++++++++- src/ytscript/config.py | 18 +++++++++++ src/ytscript/models.py | 4 +++ src/ytscript/pipeline.py | 7 ++++- src/ytscript/youtube.py | 61 ++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 39 ++++++++++++++++++++++++ tests/test_config.py | 21 +++++++++++++ tests/test_pipeline.py | 33 +++++++++++++++++++++ tests/test_youtube.py | 63 ++++++++++++++++++++++++++++++++++++++- 10 files changed, 345 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 35bb867..645d2bc 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ Useful flags on `run`: | `--timestamps` | Prefix each paragraph with `[hh:mm:ss]` | | `--dry-run` | List what is missing without downloading anything | | `--keep-audio` | Keep the downloaded audio next to the scripts | +| `--members-only` | Also transcribe members-only videos (needs cookies) | +| `--no-members-only` | Pass over members-only videos (the default) | +| `--cookies FILE` | Netscape `cookies.txt` from a signed-in session | +| `--cookies-from-browser firefox` | Read those cookies straight out of a browser | + +The cookie and members-only flags work on `list` too. ## Configuration @@ -99,12 +105,68 @@ paragraph_gap = 2.0 # silence in seconds that starts a new paragraph state_file = ".ytscript-state.json" keep_audio = false audio_format = "bestaudio[ext=m4a]/bestaudio/best" -# cookies_file = "cookies.txt" # for age-restricted or member-only videos + +# Signing in — needed for members-only and age-restricted videos. +# cookies_file = "cookies.txt" # Netscape cookies.txt export +# cookies_from_browser = "firefox" # BROWSER[+KEYRING][:PROFILE][::CONTAINER] +include_members_only = false # true also transcribes members-only videos ``` Every key has a matching environment variable: `YTSCRIPT_CHANNEL`, `YTSCRIPT_LANGUAGE`, `YTSCRIPT_BACKEND`, and so on. +### Members-only videos + +A channel's members-only videos show up in its uploads listing, but YouTube refuses the +audio to anyone who is not signed in as a member: + +``` +ERROR: [youtube] 58iGVbvDu9Q: Join this channel to get access to members-only content +like this video, and other exclusive perks. +``` + +So `ytscript` passes over them by default and says how many it left: + +``` +checked 5 video(s); 3 already had a script +skipped 1 members-only video(s); pass --members-only with cookies from a member account +to include them +``` + +They are recognised from the "Members only" badge in the listing, so nothing is +downloaded and nothing is written to the state file — the day the membership starts, +they are picked up like any other unseen video. + +To transcribe them, point ytscript at cookies from an account that holds the membership +and turn the setting on: + +```toml +cookies_from_browser = "firefox" # or cookies_file = "cookies.txt" +include_members_only = true +``` + +or, for one run: + +```bash +ytscript run --cookies-from-browser firefox --members-only +``` + +`cookies_from_browser` takes yt-dlp's `BROWSER[+KEYRING][:PROFILE][::CONTAINER]` syntax +(`chrome`, `firefox:dev-edition`, `chromium+gnomekeyring`). It reads the browser's cookie +store directly, which is the easier of the two: Chromium locks its database while it is +running, so close the browser first, or use `cookies_file` with a `cookies.txt` export +instead. Either way the cookies are a live login — keep the file out of version control. + +Turning `include_members_only` on without either cookie setting is refused up front, +since every one of those downloads would fail. A signed-in run also gets you +age-restricted videos, which fail the same way for the opposite reason. + +`ytscript list` always shows members-only videos, marked, whatever the setting says: + +``` +58iGVbvDu9Q 2024-05-01 Members-only Q&A [members only] +``` + ### Chinese `language` is the one setting worth getting right. `"zh"` skips the detection pass and diff --git a/src/ytscript/cli.py b/src/ytscript/cli.py index 2b8632b..a45113e 100644 --- a/src/ytscript/cli.py +++ b/src/ytscript/cli.py @@ -30,6 +30,34 @@ def add_common(target: argparse.ArgumentParser) -> None: "--language", help="main spoken language as an ISO 639-1 code, or 'auto' to detect it", ) + target.add_argument( + "--cookies", + dest="cookies_file", + type=Path, + metavar="FILE", + help="Netscape cookies.txt from a signed-in browser session", + ) + target.add_argument( + "--cookies-from-browser", + dest="cookies_from_browser", + metavar="BROWSER[+KEYRING][:PROFILE][::CONTAINER]", + help="read cookies straight out of a browser, e.g. firefox or chrome", + ) + members = target.add_mutually_exclusive_group() + members.add_argument( + "--members-only", + dest="include_members_only", + action="store_true", + default=None, + help="also take members-only videos; needs cookies from an account that is a member", + ) + members.add_argument( + "--no-members-only", + dest="include_members_only", + action="store_false", + default=None, + help="pass over members-only videos (the default)", + ) run = sub.add_parser("run", help="transcribe videos that have no script yet") add_common(run) @@ -84,6 +112,9 @@ def add_common(target: argparse.ArgumentParser) -> None: "timestamps", "keep_audio", "state_file", + "cookies_file", + "cookies_from_browser", + "include_members_only", ) @@ -100,6 +131,11 @@ def _config_from_args(args: argparse.Namespace) -> Config: def _print_report(report: RunReport, dry_run: bool) -> None: print(f"checked {report.checked} video(s); {len(report.skipped)} already had a script") + if report.members_only: + print( + f"skipped {len(report.members_only)} members-only video(s); " + "pass --members-only with cookies from a member account to include them" + ) if not report.written and not report.failed: print("nothing new") return @@ -137,7 +173,8 @@ def cmd_list(args: argparse.Namespace) -> int: return 0 for video in videos: published = video.upload_date.isoformat() if video.upload_date else "----------" - print(f"{video.id} {published} {video.title}") + marker = " [members only]" if video.members_only else "" + print(f"{video.id} {published} {video.title}{marker}") return 0 diff --git a/src/ytscript/config.py b/src/ytscript/config.py index 1289ee1..6c6321b 100644 --- a/src/ytscript/config.py +++ b/src/ytscript/config.py @@ -69,6 +69,13 @@ class Config: keep_audio: bool = False audio_format: str = "bestaudio[ext=m4a]/bestaudio/best" cookies_file: Path | None = None + """Netscape-format cookies.txt exported from a signed-in browser session.""" + + cookies_from_browser: str | None = None + """Read cookies straight out of a browser: ``BROWSER[+KEYRING][:PROFILE][::CONTAINER]``.""" + + include_members_only: bool = False + """Also transcribe members-only videos. Needs cookies from an account that is a member.""" extra: dict[str, Any] = field(default_factory=dict, repr=False) @@ -114,6 +121,11 @@ def validate(self) -> None: raise ConfigError("check_limit must be at least 1") if self.whisper_batch_size < 1: raise ConfigError("whisper_batch_size must be at least 1 (1 turns batching off)") + if self.include_members_only and not (self.cookies_file or self.cookies_from_browser): + raise ConfigError( + "include_members_only needs a signed-in session: set cookies_file or " + "cookies_from_browser to an account that holds the channel's membership" + ) _FIELD_TYPES = {f.name: f.type for f in fields(Config)} @@ -232,4 +244,10 @@ def load_config( state_file = ".ytscript-state.json" keep_audio = false + +# Members-only videos are skipped unless you sign in. Point one of the two cookie +# settings at an account that holds the membership, then turn the flag on. +# cookies_file = "cookies.txt" +# cookies_from_browser = "firefox" # BROWSER[+KEYRING][:PROFILE][::CONTAINER] +include_members_only = false """ diff --git a/src/ytscript/models.py b/src/ytscript/models.py index d7e36de..21a3a7f 100644 --- a/src/ytscript/models.py +++ b/src/ytscript/models.py @@ -43,6 +43,8 @@ class Video: upload_date: date | None = None duration: float | None = None description: str | None = None + members_only: bool = False + """Behind the channel's membership; only downloadable with a member's cookies.""" @dataclass(frozen=True) @@ -76,3 +78,5 @@ class RunReport: written: list[str] = field(default_factory=list) skipped: list[str] = field(default_factory=list) failed: list[tuple[str, str]] = field(default_factory=list) + members_only: list[str] = field(default_factory=list) + """Videos passed over because ``include_members_only`` is off.""" diff --git a/src/ytscript/pipeline.py b/src/ytscript/pipeline.py index 4df73e2..240cbff 100644 --- a/src/ytscript/pipeline.py +++ b/src/ytscript/pipeline.py @@ -49,7 +49,9 @@ def __init__( config.validate() self.config = config self.client = client or YouTubeClient( - audio_format=config.audio_format, cookies_file=config.cookies_file + audio_format=config.audio_format, + cookies_file=config.cookies_file, + cookies_from_browser=config.cookies_from_browser, ) self._transcriber = transcriber @@ -80,6 +82,9 @@ def run( videos = self.client.latest_videos(config.channel, limit) report.checked = len(videos) + if not config.include_members_only: + report.members_only = [video.id for video in videos if video.members_only] + videos = [video for video in videos if not video.members_only] pending = select_videos(videos, state) report.skipped = [video.id for video in videos if state.seen(video.id)] diff --git a/src/ytscript/youtube.py b/src/ytscript/youtube.py index 5955bd9..6059c8b 100644 --- a/src/ytscript/youtube.py +++ b/src/ytscript/youtube.py @@ -12,6 +12,28 @@ _CHANNEL_ID = re.compile(r"^UC[\w-]{22}$") _WATCH_URL = "https://www.youtube.com/watch?v={id}" +# yt-dlp's name for "this channel's members only"; it is on flat listing entries too, +# read off the "Members only" badge, so a listing tells us before a download tries. +MEMBERS_ONLY_AVAILABILITY = "subscriber_only" + +# What YouTube says when the request is not signed in as a member of the channel. +_MEMBERS_ONLY_ERRORS = ("members-only", "members only", "join this channel") + +_MEMBERSHIP_HINT = ( + "sign in as a member: set cookies_file (a cookies.txt export) or " + 'cookies_from_browser (e.g. "firefox") to an account that holds the membership' +) + +# yt-dlp's --cookies-from-browser syntax: BROWSER[+KEYRING][:PROFILE][::CONTAINER]. +_BROWSER_SPEC = re.compile( + r"""(?x) + (?P[^+:]+) + (?:\s*\+\s*(?P[^:]+))? + (?:\s*:\s*(?!:)(?P.+?))? + (?:\s*::\s*(?P.+))? + """ +) + class YouTubeError(RuntimeError): """Raised when yt-dlp cannot list a channel or fetch a video.""" @@ -25,6 +47,28 @@ def _load_yt_dlp() -> Any: return yt_dlp +def parse_browser_spec(spec: str) -> tuple[str, str | None, str | None, str | None]: + """Split ``BROWSER[+KEYRING][:PROFILE][::CONTAINER]`` the way the yt-dlp flag does.""" + match = _BROWSER_SPEC.fullmatch(spec.strip()) + if match is None: + raise YouTubeError( + f"could not parse cookies_from_browser {spec!r}; " + "expected BROWSER[+KEYRING][:PROFILE][::CONTAINER]" + ) + name, keyring, profile, container = match.group("name", "keyring", "profile", "container") + # yt-dlp validates the browser and keyring names; the order below is what it expects. + return name.strip().lower(), profile, keyring.strip().upper() if keyring else None, container + + +def is_members_only(info: dict[str, Any]) -> bool: + return info.get("availability") == MEMBERS_ONLY_AVAILABILITY + + +def _mentions_membership(message: str) -> bool: + lowered = message.lower() + return any(hint in lowered for hint in _MEMBERS_ONLY_ERRORS) + + def channel_uploads_url(channel: str) -> str: """Normalise a handle, channel id or URL into the channel's uploads tab.""" value = channel.strip() @@ -77,6 +121,7 @@ def _to_video(info: dict[str, Any]) -> Video: upload_date=_parse_upload_date(info), duration=info.get("duration"), description=info.get("description"), + members_only=is_members_only(info), ) @@ -87,12 +132,19 @@ def __init__( self, audio_format: str = "bestaudio[ext=m4a]/bestaudio/best", cookies_file: Path | None = None, + cookies_from_browser: str | None = None, quiet: bool = True, ) -> None: self.audio_format = audio_format self.cookies_file = cookies_file + self.cookies_from_browser = cookies_from_browser self.quiet = quiet + @property + def signed_in(self) -> bool: + """Whether requests carry cookies — members-only downloads need them.""" + return bool(self.cookies_file or self.cookies_from_browser) + def _base_opts(self) -> dict[str, Any]: opts: dict[str, Any] = { "quiet": self.quiet, @@ -102,6 +154,8 @@ def _base_opts(self) -> dict[str, Any]: } if self.cookies_file: opts["cookiefile"] = str(self.cookies_file) + if self.cookies_from_browser: + opts["cookiesfrombrowser"] = parse_browser_spec(self.cookies_from_browser) return opts def latest_videos(self, channel: str, limit: int) -> list[Video]: @@ -128,6 +182,9 @@ def download_audio(self, video: Video, dest_dir: Path) -> tuple[Path, Video]: yt_dlp = _load_yt_dlp() dest_dir = Path(dest_dir) dest_dir.mkdir(parents=True, exist_ok=True) + if video.members_only and not self.signed_in: + # Saves a request that YouTube would refuse anyway, and says why. + raise YouTubeError(f"{video.id} is members-only; {_MEMBERSHIP_HINT}") opts = self._base_opts() | { "format": self.audio_format, "outtmpl": str(dest_dir / "%(id)s.%(ext)s"), @@ -138,6 +195,9 @@ def download_audio(self, video: Video, dest_dir: Path) -> tuple[Path, Video]: info = ydl.extract_info(video.url, download=True) path = Path(ydl.prepare_filename(info)) except Exception as exc: + if _mentions_membership(str(exc)): + # A listing without badges (or a video made members-only later) lands here. + raise YouTubeError(f"{video.id} is members-only; {_MEMBERSHIP_HINT}") from exc raise YouTubeError(f"could not download audio for {video.id}: {exc}") from exc if not path.is_file(): @@ -156,6 +216,7 @@ def download_audio(self, video: Video, dest_dir: Path) -> tuple[Path, Video]: upload_date=enriched.upload_date or video.upload_date, duration=enriched.duration or video.duration, description=enriched.description or video.description, + members_only=enriched.members_only or video.members_only, ) return path, merged diff --git a/tests/test_cli.py b/tests/test_cli.py index aef6e04..2e06e02 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path import pytest @@ -109,3 +110,41 @@ def test_missing_channel_is_a_clean_error( def test_no_command_prints_help(capsys: pytest.CaptureFixture) -> None: assert cli.main([]) == 2 assert "usage: ytscript" in capsys.readouterr().out + + +def test_members_only_flags_override_the_config_file(project: Path) -> None: + parser = cli.build_parser() + + args = parser.parse_args(["run", "--members-only", "--cookies-from-browser", "firefox"]) + config = cli._config_from_args(args) + assert config.include_members_only is True + assert config.cookies_from_browser == "firefox" + + args = parser.parse_args(["list", "--no-members-only"]) + assert cli._config_from_args(args).include_members_only is False + + +def test_run_says_how_many_members_only_videos_it_passed_over( + project: Path, fake_pipeline, capsys: pytest.CaptureFixture +) -> None: + fake_pipeline.videos = [ + replace(fake_pipeline.videos[0], members_only=True), + *fake_pipeline.videos[1:], + ] + assert cli.main(["run"]) == 0 + out = capsys.readouterr().out + assert "skipped 1 members-only video(s)" in out + assert "--members-only" in out + + +def test_list_marks_members_only_videos( + project: Path, fake_pipeline, capsys: pytest.CaptureFixture +) -> None: + fake_pipeline.videos = [ + replace(fake_pipeline.videos[0], members_only=True), + *fake_pipeline.videos[1:], + ] + assert cli.main(["list", "--limit", "2"]) == 0 + lines = capsys.readouterr().out.splitlines() + assert lines[0].endswith("[members only]") + assert not lines[1].endswith("[members only]") diff --git a/tests/test_config.py b/tests/test_config.py index c57898f..3d15022 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -104,3 +104,24 @@ def test_sample_config_is_loadable_and_matches_the_defaults(tmp_path: Path) -> N assert config.language == "zh" assert config.whisper_model == "large-v3" assert config.whisper_initial_prompt is not None + + +def test_members_only_needs_a_signed_in_session() -> None: + with pytest.raises(ConfigError, match="include_members_only"): + Config(channel="@x", include_members_only=True).validate() + + Config(channel="@x", include_members_only=True, cookies_file="cookies.txt").validate() + Config(channel="@x", include_members_only=True, cookies_from_browser="firefox").validate() + + +def test_members_only_reads_from_file_and_env(tmp_path: Path) -> None: + path = write_config( + tmp_path, + 'channel = "@x"\ncookies_from_browser = "firefox"\ninclude_members_only = true\n', + ) + config = load_config(path=path, env={}) + assert config.include_members_only is True + assert config.cookies_from_browser == "firefox" + + off = load_config(path=path, env={"YTSCRIPT_INCLUDE_MEMBERS_ONLY": "false"}) + assert off.include_members_only is False diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 85ef8ed..df322e1 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from dataclasses import replace from pathlib import Path from fakes import FakeTranscriber, FakeYouTubeClient, make_videos @@ -156,3 +157,35 @@ def test_keep_audio_leaves_the_file_behind(tmp_path: Path) -> None: pipeline, _, _ = build(tmp_path, make_videos(1), initial_backfill=1, keep_audio=True) pipeline.run() assert (tmp_path / "scripts" / "audio" / "vid000.m4a").is_file() + + +def _with_members_only(videos, index: int): + marked = list(videos) + marked[index] = replace(marked[index], members_only=True) + return marked + + +def test_members_only_videos_are_skipped_by_default(tmp_path: Path) -> None: + videos = _with_members_only(make_videos(3), 1) + pipeline, client, _ = build(tmp_path, videos) + report = pipeline.run() + + assert report.checked == 3 + assert report.members_only == ["vid001"] + assert client.downloaded == ["vid002", "vid000"] + # Nothing is recorded for it, so it comes back the day the membership starts. + assert "vid001" not in State.load(tmp_path / "state.json").videos + + +def test_members_only_videos_are_taken_when_signed_in(tmp_path: Path) -> None: + videos = _with_members_only(make_videos(3), 1) + pipeline, client, _ = build( + tmp_path, + videos, + include_members_only=True, + cookies_file=tmp_path / "cookies.txt", + ) + report = pipeline.run() + + assert report.members_only == [] + assert sorted(client.downloaded) == ["vid000", "vid001", "vid002"] diff --git a/tests/test_youtube.py b/tests/test_youtube.py index 0991005..9648c76 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -1,10 +1,20 @@ from __future__ import annotations from datetime import date +from pathlib import Path import pytest -from ytscript.youtube import YouTubeError, _iter_entries, _to_video, channel_uploads_url +from ytscript.models import Video +from ytscript.youtube import ( + YouTubeClient, + YouTubeError, + _iter_entries, + _mentions_membership, + _to_video, + channel_uploads_url, + parse_browser_spec, +) @pytest.mark.parametrize( @@ -51,3 +61,54 @@ def test_iter_entries_flattens_nested_tabs() -> None: ], } assert [entry["id"] for entry in _iter_entries(info)] == ["a", "b", "c"] + + +def test_to_video_flags_members_only_from_the_listing_badge() -> None: + assert _to_video({"id": "a", "availability": "subscriber_only"}).members_only + assert not _to_video({"id": "a", "availability": "public"}).members_only + assert not _to_video({"id": "a"}).members_only + + +def test_membership_error_from_yt_dlp_is_recognised() -> None: + assert _mentions_membership( + "ERROR: [youtube] 58iGVbvDu9Q: Join this channel to get access to " + "members-only content like this video, and other exclusive perks." + ) + assert not _mentions_membership("ERROR: [youtube] abc: Video unavailable") + + +@pytest.mark.parametrize( + ("spec", "expected"), + [ + ("firefox", ("firefox", None, None, None)), + ("Chrome", ("chrome", None, None, None)), + ("chrome:Profile 2", ("chrome", "Profile 2", None, None)), + ("chromium+gnomekeyring", ("chromium", None, "GNOMEKEYRING", None)), + ("firefox:dev-edition::personal", ("firefox", "dev-edition", None, "personal")), + ], +) +def test_parse_browser_spec(spec: str, expected: tuple[str | None, ...]) -> None: + assert parse_browser_spec(spec) == expected + + +def test_parse_browser_spec_rejects_nonsense() -> None: + with pytest.raises(YouTubeError, match="cookies_from_browser"): + parse_browser_spec(":no-browser-name") + + +def test_cookie_settings_reach_yt_dlp(tmp_path: Path) -> None: + client = YouTubeClient( + cookies_file=tmp_path / "cookies.txt", cookies_from_browser="firefox:dev" + ) + opts = client._base_opts() + assert opts["cookiefile"] == str(tmp_path / "cookies.txt") + assert opts["cookiesfrombrowser"] == ("firefox", "dev", None, None) + assert client.signed_in + + assert "cookiefile" not in YouTubeClient()._base_opts() + + +def test_members_only_download_without_cookies_says_what_is_missing(tmp_path: Path) -> None: + video = Video(id="x", title="T", url="https://y/watch?v=x", members_only=True) + with pytest.raises(YouTubeError, match="members-only"): + YouTubeClient().download_audio(video, tmp_path) From add520d7c70727f8419f672646de497bd46d068d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:49:09 +0000 Subject: [PATCH 2/3] Fill the gaps left in the uv, hooks and CI docs The uv and ruff adoption documented the commands but not how a dependency change lands: `uv lock --check` gates the push hook and the CI lint job, so a `pyproject.toml` edit without a regenerated lockfile fails before review. Also aligns the hook install command with the `uv run` form used everywhere else, notes that the hooks take their tool versions from the lockfile, and mentions the manual workflow_dispatch trigger the CI section left out. Ignores cookies.txt, so the "these are a live login" warning next to the new members-only settings is backed by a rule rather than a reader's care. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQGeprPNAu7gzs5JJaDQGQ --- .gitignore | 2 ++ README.md | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b5e6783..f3e4d26 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,5 @@ scripts/ *.ytscript-state.json .ytscript-state.json ytscript.toml +# A signed-in YouTube session, for members-only and age-restricted videos. +cookies.txt diff --git a/README.md b/README.md index 645d2bc..cfa30e5 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,8 @@ ytscript run --cookies-from-browser firefox --members-only (`chrome`, `firefox:dev-edition`, `chromium+gnomekeyring`). It reads the browser's cookie store directly, which is the easier of the two: Chromium locks its database while it is running, so close the browser first, or use `cookies_file` with a `cookies.txt` export -instead. Either way the cookies are a live login — keep the file out of version control. +instead. Either way the cookies are a live login; `cookies.txt` is in `.gitignore`, and +anything you export under another name belongs there too. Turning `include_members_only` on without either cookie setting is refused up front, since every one of those downloads would fail. A signed-in run also gets you @@ -287,6 +288,15 @@ uv run ruff format # format uv run ytscript --help # the CLI from the checkout ``` +Dependencies change through uv, so `uv.lock` moves with `pyproject.toml`: + +```bash +uv add yt-dlp # or edit pyproject.toml, then: uv lock +``` + +`uv lock --check` gates both the push hook and the CI lint job, so a `pyproject.toml` +edit that leaves the lockfile behind fails before it reaches review. + The test suite fakes YouTube and the transcriber, so it needs no network, no model, no API key and neither extra installed. @@ -295,7 +305,7 @@ transcription stacks and nothing needs both, so install one at a time. ### Hooks -`pre-commit install --install-hooks` wires up two stages: +`uv run pre-commit install --install-hooks` wires up two stages: | Stage | Runs | | --- | --- | @@ -303,11 +313,13 @@ transcription stacks and nothing needs both, so install one at a time. | `pre-push` | `ruff check`, `ruff format --check`, `uv lock --check`, `pytest` | The commit hooks fix files; the push hooks only report, so a push fails on the same -things CI would fail on. `git push --no-verify` skips them. +things CI would fail on. Both run ruff and pytest through `uv run --frozen`, which keeps +`uv.lock` the only place a tool version is set. `git push --no-verify` skips them. ### CI -`.github/workflows/ci.yml` runs on every push to `main` and every pull request: +`.github/workflows/ci.yml` runs on every push to `main`, every pull request, and on +demand from the Actions tab: - **lint** — `uv lock --check`, `ruff check`, `ruff format --check` - **test** — pytest on Python 3.11, 3.12 and 3.13 on Linux, plus 3.13 on macOS and From 9e7dda3ca0118e914f3d9bdc927ac9b4ab0d9230 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:56:25 +0000 Subject: [PATCH 3/3] Install with uv from the repository, not pip from PyPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install instructions told the reader to `pip install "ytscript[local]"`, which cannot work: nothing publishes this package to PyPI. Every documented route now goes through uv, which the project already depends on for its pinned lockfile. A checkout is the primary path — clone, `uv sync --extra local`, `uv run ytscript`. For a copy on PATH without a checkout, `uv tool install` takes the package straight from git; both forms were run against this repository to check they resolve. The CUDA libraries follow the same split: `uv add` for a machine that always runs on the GPU, `uv run --with` for a one-off, `--with` again for a tool install. The three "install it with pip" messages in the code move to the matching `uv sync` commands, and the development section no longer repeats what the install section now says about the conflicting extras. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQGeprPNAu7gzs5JJaDQGQ --- README.md | 50 ++++++++++++++------- src/ytscript/transcribers/faster_whisper.py | 4 +- src/ytscript/transcribers/openai_api.py | 4 +- src/ytscript/youtube.py | 4 +- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index cfa30e5..3396462 100644 --- a/README.md +++ b/README.md @@ -9,23 +9,46 @@ and picks up whatever it has not seen before. ## Install +ytscript is not published to PyPI, so it installs from this repository. It needs [uv], +which pins the whole dependency graph through `uv.lock` — same versions here as in CI. +From a checkout, which is what the rest of this README assumes: + ```bash -pip install "ytscript[local]" # local transcription with faster-whisper -pip install "ytscript[openai]" # hosted transcription instead +git clone https://github.com/reecemiao/ytscript +cd ytscript +uv sync --extra local # local transcription with faster-whisper +uv sync --extra openai # hosted transcription instead ``` -From a checkout, with [uv]: `uv sync --extra local` — see [Development](#development). +Commands then run as `uv run ytscript …`, or through `.venv/bin/ytscript` directly. To +put `ytscript` on `PATH` without a checkout, install it from git as a uv tool: + +```bash +uv tool install "ytscript[local] @ git+https://github.com/reecemiao/ytscript" +``` The `local` extra pulls in [faster-whisper]; the model itself (about 3 GB for the default `large-v3`) downloads on first use and is cached afterwards. Nothing leaves the machine. The `openai` extra needs `OPENAI_API_KEY` in the environment and charges per -minute of audio, but needs no local model. +minute of audio, but needs no local model. They are declared as conflicting extras — two +transcription stacks, nothing needs both — so sync one at a time. **On an NVIDIA GPU, install the CUDA libraries too.** faster-whisper runs on -[CTranslate2], which needs cuBLAS and cuDNN 9 and does not bundle them: +[CTranslate2], which needs cuBLAS and cuDNN 9 and does not bundle them. In a checkout: ```bash -pip install nvidia-cublas-cu12 "nvidia-cudnn-cu12>=9,<10" +uv add nvidia-cublas-cu12 "nvidia-cudnn-cu12>=9,<10" +``` + +That writes them into `pyproject.toml` and `uv.lock`, which is what you want for a +machine that always runs on the GPU — leave those edits uncommitted in a contributor +checkout. For a one-off run instead, `uv run --with nvidia-cublas-cu12 --with +"nvidia-cudnn-cu12>=9,<10" ytscript run`. A tool install takes the same libraries +through `--with`: + +```bash +uv tool install "ytscript[local] @ git+https://github.com/reecemiao/ytscript" \ + --with nvidia-cublas-cu12 --with "nvidia-cudnn-cu12>=9,<10" ``` Without them the model load fails on a missing `cudnn_ops64_9.dll` (Windows) or @@ -34,7 +57,7 @@ the virtualenv have to be on `PATH` for the process. Check the setup before star backfill: ```bash -python -c "from faster_whisper import WhisperModel; WhisperModel('large-v3', device='cuda', compute_type='float16'); print('ok')" +uv run python -c "from faster_whisper import WhisperModel; WhisperModel('large-v3', device='cuda', compute_type='float16'); print('ok')" ``` The default `whisper_device = "cuda"` makes a broken CUDA install fail loudly here @@ -245,8 +268,8 @@ getting the batch size right. ytscript run --batch-size 8 # try a larger batch for one run ``` -Batching needs faster-whisper 1.1 or newer, which is what `ytscript[local]` installs. An -older version logs a warning and transcribes sequentially. +Batching needs faster-whisper 1.1 or newer, which is the floor the `local` extra sets and +what `uv.lock` pins. An older version logs a warning and transcribes sequentially. ## Running it on a schedule @@ -272,8 +295,8 @@ speech-to-text engine only has to match the small protocol in ## Development The project is managed with [uv] and linted and formatted with [ruff]. Both tool -versions are pinned in `uv.lock`, so everyone — hooks, CI, a plain `uv run` — gets the -same ones. +versions are pinned in `uv.lock` too, so everyone — hooks, CI, a plain `uv run` — gets +the same ones. ```bash uv sync # dev environment, no transcription backend @@ -298,10 +321,7 @@ uv add yt-dlp # or edit pyproject.toml, then: uv lock edit that leaves the lockfile behind fails before it reaches review. The test suite fakes YouTube and the transcriber, so it needs no network, no model, no -API key and neither extra installed. - -`local` and `openai` are declared as conflicting extras: they are two separate -transcription stacks and nothing needs both, so install one at a time. +API key and neither extra installed — a bare `uv sync` is enough to run it. ### Hooks diff --git a/src/ytscript/transcribers/faster_whisper.py b/src/ytscript/transcribers/faster_whisper.py index 0408463..1fd3762 100644 --- a/src/ytscript/transcribers/faster_whisper.py +++ b/src/ytscript/transcribers/faster_whisper.py @@ -49,8 +49,8 @@ def _load_model(self) -> Any: from faster_whisper import WhisperModel # noqa: PLC0415 - optional dependency except ImportError as exc: raise TranscriptionError( - "faster-whisper is not installed; install it with " - "'pip install \"ytscript[local]\"' or switch backend to 'openai'" + "faster-whisper is not installed; add it with " + "'uv sync --extra local' or switch backend to 'openai'" ) from exc try: self._model = WhisperModel( diff --git a/src/ytscript/transcribers/openai_api.py b/src/ytscript/transcribers/openai_api.py index 0d4c42d..b354552 100644 --- a/src/ytscript/transcribers/openai_api.py +++ b/src/ytscript/transcribers/openai_api.py @@ -36,8 +36,8 @@ def _load_client(self) -> Any: from openai import OpenAI # noqa: PLC0415 - optional dependency except ImportError as exc: raise TranscriptionError( - "the openai package is not installed; install it with " - "'pip install \"ytscript[openai]\"' or switch backend to 'faster-whisper'" + "the openai package is not installed; add it with " + "'uv sync --extra openai' or switch backend to 'faster-whisper'" ) from exc api_key = self._api_key or os.environ.get(self.api_key_env) if not api_key: diff --git a/src/ytscript/youtube.py b/src/ytscript/youtube.py index 6059c8b..4193214 100644 --- a/src/ytscript/youtube.py +++ b/src/ytscript/youtube.py @@ -43,7 +43,9 @@ def _load_yt_dlp() -> Any: try: import yt_dlp # noqa: PLC0415 - optional at import time, required at call time except ImportError as exc: # pragma: no cover - depends on the install - raise YouTubeError("yt-dlp is not installed; install it with 'pip install yt-dlp'") from exc + raise YouTubeError( + "yt-dlp is not installed; it is a required dependency, so 'uv sync' fixes it" + ) from exc return yt_dlp