Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,16 @@ jobs:
strategy:
fail-fast: false
matrix:
# Declared as conflicting in pyproject.toml, so resolve one at a time.
# The two backends are declared as conflicting in pyproject.toml, so
# resolve one at a time. drive is independent of both.
include:
- extra: local
import: ytscript.transcribers.faster_whisper
- extra: openai
import: ytscript.transcribers.openai_api
- extra: drive
# The connector imports the client lazily, so name it too.
import: "ytscript.drive, googleapiclient.discovery, google_auth_oauthlib.flow"
steps:
- uses: actions/checkout@v5

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,8 @@ scripts/
ytscript.toml
# A signed-in YouTube session, for members-only and age-restricted videos.
cookies.txt
# Google Drive: the OAuth client secrets, the cached sign-in, a service account key.
drive-credentials.json
drive-service-account.json
.ytscript-drive-token.json
*.ytscript-drive-token.json
122 changes: 119 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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
uv sync --extra local --extra drive # ... plus uploads to Google Drive
```

Commands then run as `uv run ytscript …`, or through `.venv/bin/ytscript` directly. To
Expand All @@ -31,7 +32,9 @@ The `local` extra pulls in [faster-whisper]; the model itself (about 3 GB for th
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. They are declared as conflicting extras — two
transcription stacks, nothing needs both — so sync one at a time.
transcription stacks, nothing needs both — so sync one at a time. The `drive` extra
is independent of both and adds the Google client libraries for [saving the scripts to
Google Drive](#google-drive).

**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. In a checkout:
Expand Down Expand Up @@ -70,6 +73,7 @@ ytscript init # write a starter ytscript.toml
$EDITOR ytscript.toml # set the channel and the language

ytscript list # newest videos on the channel
ytscript drive-auth # optional: sign in to Google Drive once
ytscript run --dry-run # what would be transcribed
ytscript run # first run: the latest 30 videos
ytscript run # later runs: only what is new
Expand All @@ -93,6 +97,8 @@ 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 |
| `--drive` / `--no-drive` | Also copy each script into Google Drive, or not |
| `--drive-folder ID` | The Drive folder they go into |
| `--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 |
Expand Down Expand Up @@ -129,6 +135,15 @@ state_file = ".ytscript-state.json"
keep_audio = false
audio_format = "bestaudio[ext=m4a]/bestaudio/best"

# Optional: copy every finished script into Google Drive as well.
drive_upload = false
drive_folder_name = "ytscript" # folder made in My Drive; "" uploads to the root
# drive_folder_id = "1AbC..." # an existing folder instead (id or its URL)
# drive_credentials_file = "drive-credentials.json" # OAuth client secrets
drive_token_file = ".ytscript-drive-token.json" # written by `drive-auth`
# drive_service_account_file = "drive-service-account.json" # unattended runs
drive_scope = "drive.file" # or "drive", for a folder ytscript did not create

# 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]
Expand Down Expand Up @@ -271,6 +286,105 @@ ytscript run --batch-size 8 # try a larger batch for one run
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.

## Google Drive

Optional: with `drive_upload` on, every script ytscript writes is also copied into
Google Drive. The local files under `output_dir` are written either way — Drive is a
copy, not a destination — so turning this off later changes nothing about the scripts
already on disk.

```bash
uv sync --extra drive # or: uv sync --extra local --extra drive
```

Google needs to know which application is asking, which is a one-time setup in the
[Google Cloud console]:

1. Create a project, then enable the **Google Drive API** for it.
2. Under **APIs & Services → Credentials**, create an **OAuth client ID** of type
**Desktop app** and download its JSON.
3. On the **OAuth consent screen**, add your own Google account as a test user — an app
in testing mode refuses everyone else.

Save that JSON in the checkout (`drive-credentials.json` is in `.gitignore`), point the
setting at it, and sign in once:

```toml
drive_upload = true
drive_credentials_file = "drive-credentials.json"
drive_folder_name = "ytscript"
```

```bash
ytscript drive-auth # opens a browser, then caches the token
ytscript run
```

`drive-auth` opens the Google sign-in in a browser on the machine it runs on and writes
the result to `drive_token_file`. Runs after that need no browser: the token refreshes
itself, which is what makes an unattended `ytscript run` work. It is a live login to
your Drive, so it is kept at mode 600 and is in `.gitignore`. Delete the file and run
`drive-auth` again to sign in as someone else.

A run reports what went up:

```
wrote 2 file(s):
scripts/2024-05-01_Video-title_VIDEOID.txt
scripts/2024-05-02_Another-one_VIDEOID2.txt
uploaded 2 file(s) to Google Drive:
2024-05-01_Video-title_VIDEOID.txt https://drive.google.com/file/d/.../view
2024-05-02_Another-one_VIDEOID2.txt https://drive.google.com/file/d/.../view
```

The Drive file id and link of every upload go into the state file next to the local
paths. Uploads are matched by file name inside the folder, so re-transcribing a video
replaces its copy in Drive instead of leaving a second one called `... (1)`.

Sign-in happens once, before the first download, so a token that has gone stale costs a
second rather than a whole backfill. If an upload itself fails, that video counts as
failed: it is not written to the state file, and the next run transcribes and uploads it
again.

### Where the files land

`drive_folder_name` (default `ytscript`) is a folder ytscript creates in My Drive on the
first upload and reuses afterwards. Set it to `""` to upload straight to the root of My
Drive.

To use a folder that already exists, give its id — or just paste the URL you see when
the folder is open, `https://drive.google.com/drive/folders/1AbC...`, which ytscript
reads the id out of:

```toml
drive_folder_id = "1AbC..."
drive_scope = "drive"
```

`drive_scope` is why that second line is there. The default `drive.file` is per-file
access: ytscript can only see files it uploaded itself, which is the narrowest thing
that works and keeps the rest of your Drive out of reach. A folder made in the Drive web
interface is not one of those files, so reaching it needs the wider `drive` scope.
Changing the scope invalidates the cached sign-in — delete `drive_token_file` and run
`drive-auth` again.

### Unattended, without a browser

A service account signs in with a key file instead of a browser, which suits a server
that has neither. Create one in the same Cloud project, download its JSON key, then
share a Drive folder with the account's `...iam.gserviceaccount.com` address (Editor)
and point ytscript at both:

```toml
drive_upload = true
drive_service_account_file = "drive-service-account.json"
drive_folder_id = "1AbC..." # the folder shared with the service account
```

`drive_folder_id` is required here: a service account has no Drive of its own to write
to, and ytscript refuses the combination up front rather than failing on the first
upload. Set `drive_credentials_file` or `drive_service_account_file`, not both.

## Running it on a schedule

`ytscript run` is idempotent, so a cron entry is enough:
Expand Down Expand Up @@ -320,8 +434,9 @@ 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 — a bare `uv sync` is enough to run it.
The test suite fakes YouTube, the transcriber and Google Drive, so it needs no network,
no model, no API key, no Google credentials and no extra installed — a bare `uv sync` is
enough to run it.

### Hooks

Expand Down Expand Up @@ -349,6 +464,7 @@ demand from the Actions tab:
- **build** — `uv build`, then installs the wheel in a clean environment and runs
`ytscript --help`

[Google Cloud console]: https://console.cloud.google.com/
[uv]: https://docs.astral.sh/uv/
[ruff]: https://docs.astral.sh/ruff/
[faster-whisper]: https://github.com/SYSTRAN/faster-whisper
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ dependencies = [
local = ["faster-whisper>=1.1.0"]
# Hosted speech-to-text via the OpenAI audio transcription endpoint.
openai = ["openai>=1.30.0"]
# Copy the finished scripts into Google Drive. Independent of the two backends.
drive = [
"google-api-python-client>=2.100",
"google-auth>=2.30",
"google-auth-oauthlib>=1.2",
]

[project.scripts]
ytscript = "ytscript.cli:main"
Expand Down
4 changes: 4 additions & 0 deletions src/ytscript/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""ytscript — turn a YouTube channel's videos into plain-text scripts."""

from .config import Config, ConfigError, load_config
from .drive import DriveError, DriveFile, DriveUploader
from .models import RunReport, Segment, Transcript, Video
from .pipeline import Pipeline
from .state import State
Expand All @@ -10,6 +11,9 @@
__all__ = [
"Config",
"ConfigError",
"DriveError",
"DriveFile",
"DriveUploader",
"Pipeline",
"RunReport",
"Segment",
Expand Down
58 changes: 56 additions & 2 deletions src/ytscript/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path

from .config import SAMPLE_CONFIG, Config, ConfigError, load_config
from .drive import DriveError, DriveUploader
from .models import RunReport
from .pipeline import Pipeline
from .youtube import YouTubeError
Expand Down Expand Up @@ -82,6 +83,27 @@ def add_common(target: argparse.ArgumentParser) -> None:
help="comma separated list of txt, md, json",
)
run.add_argument("--timestamps", action="store_true", default=None)
drive = run.add_mutually_exclusive_group()
drive.add_argument(
"--drive",
dest="drive_upload",
action="store_true",
default=None,
help="also copy every script into Google Drive; run 'ytscript drive-auth' first",
)
drive.add_argument(
"--no-drive",
dest="drive_upload",
action="store_false",
default=None,
help="keep the scripts local (the default)",
)
run.add_argument(
"--drive-folder",
dest="drive_folder_id",
metavar="ID_OR_URL",
help="Google Drive folder the scripts go into",
)
run.add_argument("--keep-audio", dest="keep_audio", action="store_true", default=None)
run.add_argument("--state-file", dest="state_file", type=Path)
run.add_argument(
Expand All @@ -94,6 +116,11 @@ def add_common(target: argparse.ArgumentParser) -> None:
add_common(listing)
listing.add_argument("--limit", type=int, default=10)

sub.add_parser(
"drive-auth",
help="sign in to Google Drive once and cache the token for later runs",
)

init = sub.add_parser("init", help="write a starter ytscript.toml")
init.add_argument("--path", type=Path, default=Path("ytscript.toml"))
init.add_argument("--force", action="store_true", help="overwrite an existing file")
Expand All @@ -115,6 +142,8 @@ def add_common(target: argparse.ArgumentParser) -> None:
"cookies_file",
"cookies_from_browser",
"include_members_only",
"drive_upload",
"drive_folder_id",
)


Expand Down Expand Up @@ -145,6 +174,10 @@ def _print_report(report: RunReport, dry_run: bool) -> None:
print(f"wrote {len(report.written)} file(s):")
for item in report.written:
print(f" {item}")
if report.uploaded:
print(f"uploaded {len(report.uploaded)} file(s) to Google Drive:")
for item in report.uploaded:
print(f" {item}")
for video_id, error in report.failed:
print(f" failed: {video_id}: {error}", file=sys.stderr)

Expand Down Expand Up @@ -178,6 +211,22 @@ def cmd_list(args: argparse.Namespace) -> int:
return 0


def cmd_drive_auth(args: argparse.Namespace) -> int:
# No channel is needed to authorise, so this skips the usual validation.
config = load_config(path=args.config)
config.validate_drive()
uploader = DriveUploader.from_config(config)
token = uploader.authorize()
where = uploader.folder or "the root of My Drive"
if token is not None:
print(f"authorised; the token is cached in {token}")
else:
print("the service account key works")
print(f"scripts will be uploaded to {where}")
print("turn uploads on with drive_upload = true, or pass --drive to a run")
return 0


def cmd_init(args: argparse.Namespace) -> int:
path: Path = args.path
if path.exists() and not args.force:
Expand All @@ -199,10 +248,15 @@ def main(argv: list[str] | None = None) -> int:
parser.print_help()
return 2

handlers = {"run": cmd_run, "list": cmd_list, "init": cmd_init}
handlers = {
"run": cmd_run,
"list": cmd_list,
"init": cmd_init,
"drive-auth": cmd_drive_auth,
}
try:
return handlers[args.command](args)
except (ConfigError, YouTubeError) as exc:
except (ConfigError, YouTubeError, DriveError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt: # pragma: no cover
Expand Down
Loading
Loading