From 314d4fed76160e0586eb09d5b3f1fb36d811dd13 Mon Sep 17 00:00:00 2001 From: Robert Strobl Date: Tue, 9 Jun 2026 20:24:06 +0200 Subject: [PATCH 1/2] feat: add YouTube upload pipeline (tools/youtube_upload.py + /publish) Adds the toolkit's first publishing capability: upload a rendered video to YouTube via the Data API v3. - tools/youtube_upload.py: OAuth 2.0 installed-app flow with silent token refresh (per-account cache), resumable upload with exponential-backoff retry, optional thumbnail/captions/playlist, and private/unlisted/public or scheduled (publishAt) visibility. Flags for --auth, --dry-run, --json-out. - /publish command: guided workflow that auto-fills title/description/tags from a project's project.json, dry-runs, uploads, and writes back the URL. - config.py: get_youtube_client_secrets_file(), get_youtube_token_dir(). - docs/youtube-upload.md: Google Cloud Console walkthrough, quota math, and the unverified-app private-lock / 7-day token gotchas. - Wiring: requirements (google-api-python-client/auth-oauthlib/auth-httplib2), registry entries, CLAUDE.md publishing section, .env.example, and a .gitignore rule for the _internal/.youtube/ token cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/publish.md | 165 ++++++++ .env.example | 18 + .gitignore | 3 + CLAUDE.md | 31 ++ _internal/toolkit-registry.json | 41 ++ docs/youtube-upload.md | 151 ++++++++ tools/config.py | 28 ++ tools/requirements.txt | 9 + tools/youtube_upload.py | 657 ++++++++++++++++++++++++++++++++ 9 files changed, 1103 insertions(+) create mode 100644 .claude/commands/publish.md create mode 100644 docs/youtube-upload.md create mode 100644 tools/youtube_upload.py diff --git a/.claude/commands/publish.md b/.claude/commands/publish.md new file mode 100644 index 0000000..4d688d3 --- /dev/null +++ b/.claude/commands/publish.md @@ -0,0 +1,165 @@ +--- +description: Publish a finished video to YouTube +--- + +# Publish to YouTube + +Upload a rendered project to YouTube, auto-filling the metadata from `project.json`. +Wraps `tools/youtube_upload.py` (OAuth 2.0 + Data API v3, resumable upload). + +``` +project.json + rendered MP4 → metadata draft → dry-run → upload → write back videoId/URL +``` + +> **One-time setup required.** YouTube uploads need OAuth (not an API key). If the user +> hasn't set this up, point them at `docs/youtube-upload.md` and stop until +> `YOUTUBE_CLIENT_SECRETS_FILE` is in `.env` and `python3 tools/youtube_upload.py --auth` +> has been run once. Don't attempt an upload without a cached token. + +## Entry Point + +### Step 1: Locate the project and its rendered video + +If the user named a project, use it. Otherwise scan for candidates: + +```bash +cd /path/to/claude-code-video-toolkit && ls projects/*/project.json +``` + +Read the chosen `project.json` **defensively** — real files carry `render`, `format`, +and `publish` blocks beyond the `lib/project/types.ts` schema. Resolve the video file: +1. `render.file` if present (e.g. `out/ai-agent-short.mp4`), relative to the project dir. +2. Else scan the project's `out/*.mp4` and pick the most recent. +3. Confirm the resolved path exists and is non-empty before continuing. + +If the project's `phase` isn't `complete`, warn the user and confirm they still want to publish. + +### Step 2: Assemble metadata (into a `publish` block) + +Build a `publish` object and write it back into `project.json` so it's reviewable, +editable, and re-runnable. If a `publish` block already exists, use it as defaults. + +| Field | How to derive | +|-------|---------------| +| `title` | Existing `publish.title`, else the hook/title scene's `title`, else the project `name` (humanized). Keep ≤100 chars. | +| `description` | Existing `publish.description`, else auto-draft: a 1–2 line summary from the scene narration/titles + a channel footer (links, hashtags). Keep ≤5000 chars. | +| `tags` | Existing `publish.tags`, else derive 5–12 topical tags from scene titles + the brand. Comma-joined when passed to the tool. | +| `category` | Default `"28"` (Science & Tech) for the SUPERINTELLIGENCE channel; `"22"` (People & Blogs) otherwise. | +| `thumbnail` | Look for `out/thumbnail.*` or `public/thumbnail.*`. If none and the user wants one, offer to generate via `tools/ideogram4.py` (see the `ideogram4` skill). | +| `privacy` | Default **scheduled** — derive a `publishAt` (next morning ~09:00 local in UTC) or ask the user. Fall back to `private` if they decline a schedule. | +| `playlist` | Optional; only if the user has one. | + +**Show the assembled metadata to the user and let them edit before uploading.** + +### Step 3: Dry-run first (no upload) + +```bash +cd /path/to/claude-code-video-toolkit && python3 tools/youtube_upload.py \ + --video "projects/NAME/out/video.mp4" \ + --title "TITLE" \ + --description-file "projects/NAME/.publish-description.txt" \ + --tags "tag1,tag2,tag3" \ + --category "28" \ + --publish-at "2026-06-10T09:00:00Z" \ + --thumbnail "projects/NAME/out/thumbnail.png" \ + --dry-run --json-out +``` + +Write the description to a temp file (`--description-file`) rather than passing a long +`--description` on the command line. Parse the JSON: confirm `requestBody` looks right and +`authOk` is `true`. If `authOk` is `false`, surface the auth error and have the user run +`python3 tools/youtube_upload.py --auth` first — do not proceed. + +### Step 4: Upload + +Re-run the same command **without** `--dry-run`, keeping `--json-out`. Parse the result. + +### Step 5: Write back and report + +On `success`, merge into the project's `publish` block: +```json +"publish": { + "platform": "youtube", + "videoId": "", + "url": "https://www.youtube.com/watch?v=", + "privacyStatus": "", + "publishAt": "", + "uploadedAt": "" +} +``` +Append a `sessions[]` entry summarizing the upload, then report to the user: + +``` +Published to YouTube + +Title: +URL: https://www.youtube.com/watch?v=<id> +Privacy: <actual> (requested: <requested>) +Schedule: <publishAt or "—"> +``` + +**If `privacyStatus` came back `private` but you requested public/scheduled**, tell the +user plainly: this is the unverified-app lock — the video uploaded but won't go public +until their Google Cloud OAuth app is verified. They can publish manually in YouTube Studio. + +--- + +## Quick Mode + +Direct invocation for experienced users: +``` +/publish ai-agent-short +/publish ai-agent-short --privacy unlisted +``` +Parse the project name and any privacy/schedule overrides, still show the metadata and +run a dry-run before the real upload. + +--- + +## Tool Reference (`tools/youtube_upload.py`) + +| Option | Description | +|--------|-------------| +| `--video, --input` | Path to the video file | +| `--title` | Title (≤100 chars) | +| `--description` / `--description-file` | Description text, or a file (`-` = stdin) | +| `--tags` | Comma-separated tags (combined ≤500 chars) | +| `--category` | Numeric category ID string (default `22`; `28` = Science & Tech) | +| `--privacy` | `private` (default) / `unlisted` / `public` | +| `--publish-at` | ISO8601 UTC schedule, e.g. `2026-06-10T09:00:00Z` (forces private at insert) | +| `--thumbnail` | Custom thumbnail (≤2MB, 1280×720) | +| `--captions` + `--captions-language` | Caption file + language code | +| `--playlist` | Playlist ID | +| `--account` | Channel name namespacing the cached token (default `default`) | +| `--auth` | Interactive login only — cache a token and exit | +| `--dry-run` | Validate + print the request body without uploading | +| `--json-out` | Single machine-readable JSON line on stdout | + +--- + +## Quota & Limits (worth knowing) + +- Default API quota is **10,000 units/day**; each upload costs **~1,600 units → ~6 uploads/day**. +- Hitting quota returns HTTP 403 `quotaExceeded` (the tool reports `errorType: "quota"`). +- Custom thumbnails require a channel with a verified phone number. + +--- + +## Error Handling + +| Symptom (`errorType`) | Solution | +|-----------------------|----------| +| `auth` | Run `python3 tools/youtube_upload.py --auth` once; if the refresh token expired (7-day Testing limit), re-run `--auth`. | +| `validation` | Fix the flagged field (missing video/title, bad `--publish-at`). | +| `quota` | Daily quota exhausted — wait, or request more in Google Cloud. | +| `upload` / `http` | Transient network/server issue; the tool already retried — try again later. | +| Video uploaded but stuck `private` | Unverified OAuth app lock — verify the app, or publish manually. | + +--- + +## Evolution + +This command evolves through use. If something's awkward or missing: +1. Say "improve this" → Claude captures it in `_internal/BACKLOG.md` +2. Edit `.claude/commands/publish.md` → update `_internal/CHANGELOG.md` +- Issues/PRs: `github.com/digitalsamba/claude-code-video-toolkit` diff --git a/.env.example b/.env.example index e1269f6..1bf5d6a 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,21 @@ # ElevenLabs: Premium cloud TTS (pay-per-character, optional) # ELEVENLABS_API_KEY=your_api_key_here # ELEVENLABS_VOICE_ID=your_voice_id_here + + +# --- YouTube upload (tools/youtube_upload.py) --- +# YouTube uploads use OAuth 2.0 (acting on behalf of a channel), NOT an API key. +# One-time setup (see docs/youtube-upload.md for the full walkthrough): +# 1. Google Cloud Console -> create a project -> enable "YouTube Data API v3". +# 2. OAuth consent screen -> External -> add your channel's Google account as a Test user. +# 3. Credentials -> Create OAuth client ID -> Application type "Desktop app". +# 4. Download the client_secret_*.json and point the var below at it (absolute path). +# 5. Log in once (opens a browser): python3 tools/youtube_upload.py --auth +# NOTE: until your OAuth app passes Google verification, every uploaded video is +# FORCE-LOCKED to private regardless of --privacy, and "Testing"-mode refresh tokens +# expire after ~7 days. Upload-as-private + publish manually until verified. +# YOUTUBE_CLIENT_SECRETS_FILE=/absolute/path/to/client_secret.json +# +# Optional: override where per-account refresh tokens are cached +# (default: <workspace>/_internal/.youtube/, which is gitignored). +# YOUTUBE_TOKEN_DIR=/absolute/path/to/token/cache/dir diff --git a/.gitignore b/.gitignore index 70ad6cc..903392a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ *.pem *.key +# YouTube OAuth token cache (cached refresh tokens — long-lived channel-upload secrets) +_internal/.youtube/ + # Python .venv/ venv/ diff --git a/CLAUDE.md b/CLAUDE.md index 5c4c482..e7f5e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ This is especially critical for background commands where the working directory | **Project tools** | voiceover, music, music_gen, sfx, sync_timing | During video creation workflow | | **Utility tools** | redub, addmusic, notebooklm_brand, locate_watermark | Quick transformations on existing videos | | **Cloud GPU** | image_edit, upscale, dewatermark, sadtalker, qwen3_tts, music_gen, flux2 | AI processing via RunPod or Modal (`--cloud runpod\|modal`) | +| **Publishing** | youtube_upload | Upload a finished render to YouTube (use `/publish` for the guided workflow) | Utility tools work on any video file without requiring a project structure. @@ -348,6 +349,35 @@ python tools/notebooklm_brand.py \ Trims NotebookLM visuals, keeps full audio, bridges with freeze frame, adds branded outro. +### Publishing to YouTube + +Upload a finished render to YouTube via the Data API v3. Use the `/publish` command for the +guided workflow (it auto-fills title/description/tags from `project.json`), or call the tool directly. + +```bash +# One-time login (opens a browser, caches a refresh token under _internal/.youtube/) +python3 tools/youtube_upload.py --auth + +# Upload privately (the safe default) +python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \ + --description-file DESCRIPTION.md --tags "ai,agents,explainer" --json-out + +# Schedule a public go-live +python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \ + --publish-at 2026-06-10T09:00:00Z --thumbnail out/thumb.png --json-out + +# Validate everything without uploading (also reports auth readiness) +python3 tools/youtube_upload.py --video out/video.mp4 --title "Test" --dry-run --json-out +``` + +**Setup is OAuth, not an API key** (uploads act on behalf of a channel). See +`docs/youtube-upload.md` for the one-time Google Cloud Console walkthrough and +`YOUTUBE_CLIENT_SECRETS_FILE` in `.env`. Key realities: +- Default quota is 10,000 units/day; `videos.insert` costs ~1,600 units → **~6 uploads/day**. +- **Until your OAuth app passes Google verification, uploads are force-locked to private** + regardless of `--privacy`, and "Testing"-mode refresh tokens expire after ~7 days. +- Cached tokens live in `_internal/.youtube/` (gitignored — they grant channel-upload access). + ## Video Production Workflow 1. **Create/resume project** - Run `/video`, choose template and brand (or resume existing) @@ -360,6 +390,7 @@ Trims NotebookLM visuals, keeps full audio, bridges with freeze frame, adds bran 8. **Preview** - `npm run studio` in project directory 9. **Iterate** - Adjust timing, content, styling with Claude Code 10. **Render** - `npm run render` for final MP4 +11. **Publish** - Run `/publish` to upload the render to YouTube (metadata auto-filled from `project.json`) ## Project Lifecycle diff --git a/_internal/toolkit-registry.json b/_internal/toolkit-registry.json index c75065c..1679d4f 100644 --- a/_internal/toolkit-registry.json +++ b/_internal/toolkit-registry.json @@ -186,6 +186,13 @@ "status": "beta", "created": "2026-03-23", "updated": "2026-03-23" + }, + "publish": { + "path": ".claude/commands/publish.md", + "description": "Publish a finished video to YouTube - auto-fills metadata from project.json, then uploads", + "status": "beta", + "created": "2026-06-09", + "updated": "2026-06-09" } }, "tools": { @@ -650,6 +657,40 @@ "estimatedCost": "$0.20-0.25 per scene (delegates to ltx2)", "created": "2026-04-04", "updated": "2026-04-04" + }, + "youtube_upload": { + "path": "tools/youtube_upload.py", + "description": "Upload a rendered video to YouTube via the Data API v3 (OAuth 2.0, resumable upload) — title/description/tags/thumbnail/captions/playlist, private/unlisted/public or scheduled publish", + "usage": "python3 tools/youtube_upload.py --video out/video.mp4 --title \"My video\" --tags \"ai,explainer\" --json-out", + "status": "beta", + "category": "publishing", + "backend": "YouTube Data API v3 (OAuth 2.0 installed-app flow)", + "requires": "Google Cloud OAuth client (Desktop app); YOUTUBE_CLIENT_SECRETS_FILE", + "options": { + "auth": "--auth (interactive login, caches a refresh token per --account)", + "privacy": [ + "private", + "unlisted", + "public" + ], + "schedule": "--publish-at ISO8601 UTC (forces private at insert)", + "extras": [ + "--thumbnail", + "--captions", + "--playlist" + ], + "dryRun": true, + "jsonOut": true, + "multiChannel": "--account NAME" + }, + "envVars": [ + "YOUTUBE_CLIENT_SECRETS_FILE", + "YOUTUBE_TOKEN_DIR" + ], + "note": "Uploads require OAuth, not an API key. Default quota 10,000 units/day; videos.insert ~1600 units (~6 uploads/day). Unverified OAuth apps force uploads to private until Google-verified. Cached tokens live in _internal/.youtube/ (gitignored).", + "documentation": "docs/youtube-upload.md", + "created": "2026-06-09", + "updated": "2026-06-09" } }, "optionalComponents": { diff --git a/docs/youtube-upload.md b/docs/youtube-upload.md new file mode 100644 index 0000000..5bc4e42 --- /dev/null +++ b/docs/youtube-upload.md @@ -0,0 +1,151 @@ +# Uploading to YouTube + +`tools/youtube_upload.py` uploads a rendered video to YouTube using the **YouTube Data API +v3**. The `/publish` command wraps it with metadata auto-filled from a project's `project.json`. + +> **The one thing to know up front:** YouTube uploads act *on behalf of a channel*, so they +> require **OAuth 2.0**, not an API key. There is no key-only path. The setup below is a +> one-time ~10-minute click-through in Google Cloud Console. + +--- + +## One-time setup + +### 1. Create a Google Cloud project + enable the API +1. Go to [console.cloud.google.com](https://console.cloud.google.com/) and create (or pick) a project. +2. **APIs & Services → Library → "YouTube Data API v3" → Enable.** + +### 2. Configure the OAuth consent screen +1. **APIs & Services → OAuth consent screen.** +2. User type **External** → fill in app name + your email. +3. On **Test users**, add the Google account that owns the YouTube channel. + - While the app is in "Testing", only listed test users can authorize it, and refresh + tokens expire after ~7 days (see [Gotchas](#gotchas)). + +### 3. Create the OAuth client +1. **APIs & Services → Credentials → Create Credentials → OAuth client ID.** +2. Application type: **Desktop app**. +3. Download the `client_secret_*.json`. + +### 4. Point the toolkit at it +```bash +echo 'YOUTUBE_CLIENT_SECRETS_FILE=/absolute/path/to/client_secret.json' >> .env +``` + +### 5. Install dependencies +```bash +pip install -r tools/requirements.txt +# or just the three: +pip install google-api-python-client google-auth-oauthlib google-auth-httplib2 +``` + +### 6. Log in once +```bash +python3 tools/youtube_upload.py --auth +``` +A browser opens for consent. On success the refresh token is cached at +`_internal/.youtube/token_default.json` (chmod 600, gitignored). Every later upload reuses it +silently — no browser needed. + +--- + +## Usage + +```bash +# Upload privately (the safe default) +python3 tools/youtube_upload.py \ + --video out/video.mp4 \ + --title "My video title" \ + --description-file DESCRIPTION.md \ + --tags "ai,agents,explainer" \ + --category 28 \ + --json-out + +# Schedule a public go-live (effective only once the app is verified — see Gotchas) +python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \ + --publish-at 2026-06-10T09:00:00Z --thumbnail out/thumb.png + +# Validate everything without uploading (also reports whether auth is ready) +python3 tools/youtube_upload.py --video out/video.mp4 --title "Test" --dry-run --json-out +``` + +Prefer **`/publish`** for finished projects — it derives the title/description/tags from +`project.json`, runs a dry-run, then uploads and writes the resulting video URL back. + +### Key flags + +| Flag | Notes | +|------|-------| +| `--video`, `--input` | The file to upload (required) | +| `--title` | Required, ≤100 chars | +| `--description` / `--description-file` | Inline text, or a file (`-` = stdin); ≤5000 chars | +| `--tags` | Comma-separated; combined length ≤500 chars (overflow is dropped with a warning) | +| `--category` | Numeric ID string. `22` = People & Blogs, `28` = Science & Tech | +| `--privacy` | `private` (default) / `unlisted` / `public` | +| `--publish-at` | ISO8601 UTC, e.g. `2026-06-10T09:00:00Z`. Forces `private` at insert, flips public at the time. | +| `--thumbnail` | Custom thumbnail (≤2MB, 1280×720). Requires a phone-verified channel. | +| `--captions` + `--captions-language` | Caption file (.srt/.vtt) + language code | +| `--playlist` | Playlist ID to add the video to | +| `--account` | Namespaces the cached token — use for multiple channels (e.g. `--account work`) | +| `--auth` | Interactive login only; caches a token and exits | +| `--dry-run` | Builds + prints the request body and checks auth, but uploads nothing | +| `--json-out` | Emits one machine-readable JSON line on stdout | + +### JSON output (`--json-out`) + +Success: +```json +{"success": true, "videoId": "abc123", "url": "https://www.youtube.com/watch?v=abc123", + "privacyStatus": "private", "requestedPrivacy": "public", "publishAt": "2026-06-10T09:00:00Z", + "account": "default", "thumbnailSet": true, "captionsAdded": false, "playlistId": null, + "uploadStatus": "uploaded"} +``` +Failure: +```json +{"success": false, "error": "...", "errorType": "auth", "videoId": null} +``` +`errorType` ∈ `auth | quota | validation | upload | http | thumbnail | captions`. +Compare `requestedPrivacy` vs `privacyStatus` to detect the unverified-app force-to-private case. + +--- + +## Quota + +- Default quota: **10,000 units/day** per Google Cloud project. +- `videos.insert` ≈ **1,600 units** → about **6 uploads/day**. +- `thumbnails.set` ≈ 50, `captions.insert` ≈ 400, `playlistItems.insert` ≈ 50. +- Exceeding it returns HTTP 403 `quotaExceeded` (`errorType: "quota"`). Request more via the + Google Cloud quota form if you need higher volume. + +--- + +## Gotchas + +- **Unverified-app private lock.** Until your OAuth app passes Google verification, YouTube + force-sets every uploaded video to `private` regardless of `--privacy`/`--publish-at`. The + tool warns and reports the *actual* returned privacy. Workaround until verified: upload as + private and publish manually in YouTube Studio. +- **7-day refresh-token expiry in "Testing".** Consent screens left in Testing mode expire + refresh tokens after ~7 days. If an unattended run fails with `errorType: "auth"`, just re-run + `python3 tools/youtube_upload.py --auth`. Publishing the consent screen removes this (but + keeps the verification private-lock until verified). +- **Headless servers.** `--auth` needs a browser + reachable localhost. Run it once on a + machine with a browser, then copy `_internal/.youtube/token_<account>.json` to the server. +- **`publishAt` requires private at insert** — the tool enforces this automatically. +- **Custom thumbnails** require the channel to have a verified phone number, else a 403 + (non-fatal — the video still uploads; the tool logs a warning). +- **The token file is a secret.** `_internal/.youtube/` is gitignored; don't commit it. + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `Missing dependency: ...` | `pip install google-api-python-client google-auth-oauthlib google-auth-httplib2` | +| `OAuth client secrets file not found` | Set `YOUTUBE_CLIENT_SECRETS_FILE` in `.env` (absolute path) or pass `--client-secrets` | +| `No cached credentials` | Run `python3 tools/youtube_upload.py --auth` once | +| `Refresh token ... is invalid` | Re-run `--auth` (7-day Testing expiry or revoked token) | +| `errorType: "quota"` | Daily quota hit — wait or request more quota | +| Video uploaded but stays `private` | Unverified-app lock — verify your OAuth app, or publish manually | +| `access_denied` in the browser | The Google account isn't a Test user on the consent screen — add it | diff --git a/tools/config.py b/tools/config.py index 0186964..f5f3257 100644 --- a/tools/config.py +++ b/tools/config.py @@ -72,6 +72,34 @@ def get_ideogram_api_key() -> str | None: return os.getenv("IDEOGRAM_API_KEY") +def get_youtube_client_secrets_file() -> str | None: + """Path to the OAuth 2.0 'Desktop app' client_secret JSON used for YouTube uploads. + + Returns None if unset or still the placeholder. YouTube uploads use OAuth (acting + on behalf of a channel), not an API key — see docs/youtube-upload.md. + """ + from dotenv import load_dotenv + load_dotenv() + path = os.getenv("YOUTUBE_CLIENT_SECRETS_FILE") + if path and path != "/absolute/path/to/client_secret.json": + return path + return None + + +def get_youtube_token_dir() -> Path: + """Directory holding per-account cached OAuth tokens for YouTube. + + Override via YOUTUBE_TOKEN_DIR. Default: <workspace>/_internal/.youtube/ + (must be gitignored — refresh tokens are long-lived secrets). + """ + from dotenv import load_dotenv + load_dotenv() + override = os.getenv("YOUTUBE_TOKEN_DIR") + if override: + return Path(override) + return find_workspace_root() / "_internal" / ".youtube" + + def get_runpod_api_key() -> str | None: """Get RunPod API key from environment.""" from dotenv import load_dotenv diff --git a/tools/requirements.txt b/tools/requirements.txt index 092324a..0f428fb 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -13,3 +13,12 @@ Pillow>=10.0 # short-form ad spots, lower thirds, and data-driven scenes moviepy>=2.0 matplotlib>=3.7 + +# YouTube upload (tools/youtube_upload.py) — OAuth 2.0 + Data API v3 resumable upload. +# NOTE: pip package names differ from their import names, e.g. +# google-api-python-client -> import googleapiclient +# google-auth-oauthlib -> import google_auth_oauthlib +# google-auth-httplib2 -> (transport glue, pulled in transitively) +google-api-python-client>=2.100.0 +google-auth-oauthlib>=1.2.0 +google-auth-httplib2>=0.2.0 diff --git a/tools/youtube_upload.py b/tools/youtube_upload.py new file mode 100644 index 0000000..4bed9c8 --- /dev/null +++ b/tools/youtube_upload.py @@ -0,0 +1,657 @@ +#!/usr/bin/env python3 +""" +Upload a rendered video to YouTube via the YouTube Data API v3. + +YouTube uploads act on behalf of a channel, so they require OAuth 2.0 (NOT a plain +API key). The one-time setup is in Google Cloud Console; see docs/youtube-upload.md +and .env.example for the click-path. In short: + 1. Enable "YouTube Data API v3" on a Google Cloud project. + 2. Create an OAuth client ID of type "Desktop app", download client_secret_*.json. + 3. Point YOUTUBE_CLIENT_SECRETS_FILE at it (or pass --client-secrets). + 4. Log in once (opens a browser): python3 tools/youtube_upload.py --auth + +After the first login the refresh token is cached under _internal/.youtube/ and +reused silently, so subsequent uploads need no browser. + +Examples: + # One-time login (interactive, opens a browser) + python3 tools/youtube_upload.py --auth + + # Upload privately (the safe default) + python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \\ + --description-file DESCRIPTION.md --tags "ai,agents,explainer" + + # Schedule a public go-live (only effective once the OAuth app is verified) + python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \\ + --publish-at 2026-06-10T15:00:00Z --thumbnail out/thumb.png + + # Validate everything without uploading (also reports auth readiness) + python3 tools/youtube_upload.py --video out/video.mp4 --title "Test" --dry-run --json-out + + # A second channel keeps its own cached token + python3 tools/youtube_upload.py --auth --account work + +# --------------------------------------------------------------------------- +# QUOTA & VERIFICATION REALITIES (YouTube Data API v3) +# * Default quota: 10,000 units/day/project. +# * videos.insert ~1600 units -> ~6 uploads/day on the default quota. +# * thumbnails.set ~50 units +# * captions.insert ~400 units +# * playlistItems.insert ~50 units +# Hitting quota returns HTTP 403 'quotaExceeded' (NOT retriable). Request more +# via the Google Cloud quota form if you need volume. +# +# UNVERIFIED OAUTH APP LOCK: until the OAuth app passes Google verification, +# every uploaded video is force-set to PRIVATE regardless of the requested +# privacyStatus, and cannot be made public via the API. 'Testing'-mode consent +# screens additionally expire refresh tokens after ~7 days. This tool warns at +# runtime and reports the *actual* returned privacyStatus, not just the request. +# --------------------------------------------------------------------------- +""" +from __future__ import annotations + +import argparse +import http.client +import json +import mimetypes +import os +import random +import re +import socket +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +try: + import httplib2 + from google_auth_oauthlib.flow import InstalledAppFlow + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import Request + from google.auth.exceptions import RefreshError + from googleapiclient.discovery import build + from googleapiclient.http import MediaFileUpload + from googleapiclient.errors import HttpError + from dotenv import load_dotenv +except ImportError as e: + print(f"Missing dependency: {e}") + print( + "Install with: pip install google-api-python-client google-auth-oauthlib " + "google-auth-httplib2 python-dotenv" + ) + sys.exit(1) + +load_dotenv() + +sys.path.insert(0, str(Path(__file__).parent)) + +# OAuth scopes. youtube.upload covers videos.insert / thumbnails.set / captions.insert. +# The broader youtube scope is needed for playlistItems.insert; request both up front +# so adding a video to a playlist later never forces a re-consent. +SCOPES = [ + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/youtube", +] + +VALID_PRIVACY = ("private", "unlisted", "public") +RETRIABLE_STATUS = (500, 502, 503, 504) +MAX_RETRIES = 10 +DEFAULT_CHUNK_MIB = 8 + +# Transport-layer errors worth retrying during a resumable upload. +RETRIABLE_EXCEPTIONS = ( + httplib2.HttpLib2Error, + IOError, + http.client.NotConnected, + http.client.IncompleteRead, + http.client.ImproperConnectionState, + http.client.CannotSendRequest, + http.client.CannotSendHeader, + http.client.ResponseNotReady, + http.client.BadStatusLine, + socket.error, + socket.timeout, + ConnectionResetError, + TimeoutError, +) + +# Limits enforced by the API. +TITLE_MAX = 100 +DESCRIPTION_MAX = 5000 +TAGS_COMBINED_MAX = 500 + + +class AuthError(Exception): + """Authentication could not be established (no/invalid token, missing secrets).""" + + +class UploadError(Exception): + """The resumable upload failed permanently.""" + + +def log(msg: str, level: str = "info"): + """Print a formatted log message to stderr (stdout is reserved for --json-out).""" + colors = { + "info": "\033[94m", + "success": "\033[92m", + "error": "\033[91m", + "warn": "\033[93m", + "dim": "\033[90m", + } + reset = "\033[0m" + prefix = {"info": "->", "success": "OK", "error": "!!", "warn": "??", "dim": " "} + color = colors.get(level, "") + print(f"{color}{prefix.get(level, '->')} {msg}{reset}", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- +def token_path_for(account: str) -> Path: + """Per-account cached-token path. Account name is sanitized to avoid traversal.""" + from config import get_youtube_token_dir + + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", account) or "default" + return get_youtube_token_dir() / f"token_{safe}.json" + + +def _save_token(token_file: Path, creds: "Credentials") -> None: + token_file.parent.mkdir(parents=True, exist_ok=True) + token_file.write_text(creds.to_json()) + try: + os.chmod(token_file, 0o600) # refresh token is a long-lived secret + except OSError: + pass + + +def get_credentials( + account: str, + client_secrets: Optional[str], + *, + allow_interactive: bool, +) -> "Credentials": + """Load cached creds for `account`, refreshing silently. Run the browser flow only + when allow_interactive=True (i.e. --auth, or an upload with --login-if-needed). + + Raises AuthError with an actionable message on any failure. + """ + from config import get_youtube_client_secrets_file + + token_file = token_path_for(account) + creds: Optional[Credentials] = None + + if token_file.exists(): + try: + creds = Credentials.from_authorized_user_file(str(token_file), SCOPES) + except (ValueError, json.JSONDecodeError) as e: + log(f"Cached token for '{account}' is unreadable ({e}); re-authing.", "warn") + creds = None + + # Valid cached token -> zero interaction. + if creds and creds.valid: + return creds + + # Expired but refreshable -> refresh silently (the unattended path). + if creds and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + _save_token(token_file, creds) + return creds + except RefreshError as e: + if not allow_interactive: + raise AuthError( + f"Refresh token for account '{account}' is invalid ({e}). " + f"Re-run interactively: python3 tools/youtube_upload.py --auth --account {account}" + ) + creds = None # fall through to a fresh consent flow + + # No usable token. Only the interactive path may open a browser. + if not allow_interactive: + raise AuthError( + f"No cached credentials for account '{account}'. " + f"Log in once interactively: python3 tools/youtube_upload.py --auth --account {account}" + ) + + secrets = client_secrets or get_youtube_client_secrets_file() + if not secrets or not Path(secrets).exists(): + raise AuthError( + "OAuth client secrets file not found. Set YOUTUBE_CLIENT_SECRETS_FILE in .env " + "or pass --client-secrets PATH. See docs/youtube-upload.md for the Cloud Console steps." + ) + + flow = InstalledAppFlow.from_client_secrets_file(secrets, SCOPES) + # port=0 -> OS-assigned free localhost port (matches the "Desktop app" client's + # http://localhost redirect). access_type=offline + prompt=consent guarantee a + # refresh_token is issued so future runs are silent. + creds = flow.run_local_server( + port=0, + access_type="offline", + prompt="consent", + authorization_prompt_message=( + f"Authorizing YouTube account '{account}'. If a browser doesn't open automatically, " + f"open this URL in any browser:\n\n{{url}}\n" + ), + success_message="Authorization complete. You can close this tab and return to the terminal.", + open_browser=True, + ) + _save_token(token_file, creds) + return creds + + +def build_service(creds: "Credentials"): + # cache_discovery=False silences the oauth2client cache warning and avoids disk writes. + return build("youtube", "v3", credentials=creds, cache_discovery=False) + + +# --------------------------------------------------------------------------- +# Request body +# --------------------------------------------------------------------------- +def parse_tags(raw: Optional[str]) -> list[str]: + if not raw: + return [] + tags = [t.strip() for t in raw.split(",") if t.strip()] + # The API caps the combined length of all tags at 500 chars (plus quoting overhead + # for multi-word tags). Drop overflow rather than letting the insert 400. + kept: list[str] = [] + total = 0 + for t in tags: + cost = len(t) + (2 if " " in t else 0) + if total + cost > TAGS_COMBINED_MAX: + log(f"Dropping tag '{t}' — exceeds the 500-char combined tag limit.", "warn") + continue + kept.append(t) + total += cost + return kept + + +def build_request_body(args, description: str, tags: list[str]) -> dict: + title = args.title or "" + if len(title) > TITLE_MAX: + log(f"Title is {len(title)} chars; truncating to {TITLE_MAX}.", "warn") + title = title[:TITLE_MAX] + if len(description) > DESCRIPTION_MAX: + log(f"Description is {len(description)} chars; truncating to {DESCRIPTION_MAX}.", "warn") + description = description[:DESCRIPTION_MAX] + + snippet = { + "title": title, + "description": description, + "categoryId": str(args.category), + } + if tags: + snippet["tags"] = tags + if args.default_language: + snippet["defaultLanguage"] = args.default_language + + status = { + "privacyStatus": args.privacy, + "selfDeclaredMadeForKids": bool(args.made_for_kids), + "license": args.license, + "embeddable": True, + } + if args.publish_at: + # Scheduled publish REQUIRES privacyStatus == private at insert time. + status["privacyStatus"] = "private" + status["publishAt"] = args.publish_at + + return {"snippet": snippet, "status": status} + + +# --------------------------------------------------------------------------- +# Upload +# --------------------------------------------------------------------------- +def resumable_upload(request) -> dict: + """Drive request.next_chunk() to completion with capped exponential backoff. + + Returns the inserted video resource dict. Raises UploadError on permanent failure + and re-raises non-retriable HttpError (4xx) immediately. + """ + response = None + error: Optional[str] = None + retry = 0 + last_pct = -1 + + while response is None: + try: + status, response = request.next_chunk() + if status is not None: + pct = int(status.progress() * 100) + if pct != last_pct: + log(f"Uploading... {pct}%", "dim") + last_pct = pct + if response is not None: + if "id" in response: + return response + raise UploadError(f"Upload finished with an unexpected response: {response}") + except HttpError as e: + if e.resp.status in RETRIABLE_STATUS: + error = f"Retriable HTTP {e.resp.status}: {e}" + else: + raise # 4xx (bad body / quota / auth) is permanent — surface it now. + except RETRIABLE_EXCEPTIONS as e: + error = f"Retriable transport error: {e}" + + if error is not None: + retry += 1 + if retry > MAX_RETRIES: + raise UploadError(f"Giving up after {MAX_RETRIES} retries. Last error: {error}") + sleep_secs = min(2 ** retry, 64) + random.random() + log(f"{error} — retry {retry}/{MAX_RETRIES} in {sleep_secs:.1f}s", "warn") + time.sleep(sleep_secs) + error = None + + return response + + +def set_thumbnail(youtube, video_id: str, thumb_path: str) -> bool: + """Attach a custom thumbnail. Non-fatal: returns True/False, never raises out.""" + try: + mime = mimetypes.guess_type(thumb_path)[0] or "image/png" + media = MediaFileUpload(thumb_path, mimetype=mime, resumable=False) + youtube.thumbnails().set(videoId=video_id, media_body=media).execute() + log(f"Thumbnail set ({thumb_path})", "success") + return True + except HttpError as e: + log( + f"Thumbnail not set ({e}). Custom thumbnails require a verified-phone channel; " + "the video itself uploaded fine.", + "warn", + ) + return False + + +def add_caption(youtube, video_id: str, caption_path: str, language: str) -> bool: + """Insert a caption track from an .srt/.vtt file. Non-fatal.""" + try: + media = MediaFileUpload(caption_path, mimetype="application/octet-stream", resumable=False) + body = { + "snippet": { + "videoId": video_id, + "language": language, + "name": "", + "isDraft": False, + } + } + youtube.captions().insert(part="snippet", body=body, media_body=media).execute() + log(f"Caption track added ({language})", "success") + return True + except HttpError as e: + log(f"Caption not added ({e}); the video itself uploaded fine.", "warn") + return False + + +def add_to_playlist(youtube, video_id: str, playlist_id: str) -> bool: + """Add the uploaded video to a playlist. Non-fatal.""" + try: + youtube.playlistItems().insert( + part="snippet", + body={ + "snippet": { + "playlistId": playlist_id, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + } + }, + ).execute() + log(f"Added to playlist {playlist_id}", "success") + return True + except HttpError as e: + log(f"Playlist add failed ({e}); the video itself uploaded fine.", "warn") + return False + + +# --------------------------------------------------------------------------- +# Helpers / validation +# --------------------------------------------------------------------------- +def read_description(args) -> str: + if args.description_file: + if args.description_file == "-": + return sys.stdin.read() + return Path(args.description_file).read_text() + return args.description or "" + + +def parse_publish_at(value: str) -> str: + """Validate an ISO8601/RFC3339 timestamp and normalize to UTC 'Z' form. + + Accepts a trailing 'Z' or an explicit offset. Naive timestamps are assumed UTC. + """ + raw = value.strip() + parseable = raw[:-1] + "+00:00" if raw.endswith("Z") else raw + dt = datetime.fromisoformat(parseable) # raises ValueError on bad input + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + dt = dt.astimezone(timezone.utc) + if dt <= datetime.now(timezone.utc): + log("--publish-at is in the past; YouTube may publish immediately.", "warn") + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def validate_args(args) -> Optional[str]: + """Return an error string if invalid, else None. Mutates args.publish_at to the + normalized form. Runs before any network call so we never burn quota on bad input.""" + if not args.video: + return "Missing --video (the file to upload)." + vpath = Path(args.video) + if not vpath.exists(): + return f"Video file not found: {args.video}" + if vpath.stat().st_size == 0: + return f"Video file is empty: {args.video}" + if not args.title or not args.title.strip(): + return "Missing --title." + if args.privacy not in VALID_PRIVACY: + return f"--privacy must be one of {VALID_PRIVACY}" + if args.publish_at: + try: + args.publish_at = parse_publish_at(args.publish_at) + except ValueError: + return f"--publish-at is not a valid ISO8601 timestamp: {args.publish_at}" + for label, p in (("--thumbnail", args.thumbnail), ("--captions", args.captions)): + if p and not Path(p).exists(): + return f"{label} file not found: {p}" + return None + + +def emit_json(payload: dict): + print(json.dumps(payload)) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Upload a rendered video to YouTube (Data API v3, OAuth 2.0, resumable).", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --auth # one-time browser login, caches a token + %(prog)s --video out/v.mp4 --title "T" --tags "a,b" # upload private (default) + %(prog)s --video out/v.mp4 --title "T" --publish-at 2026-06-10T15:00:00Z --thumbnail t.png + %(prog)s --video out/v.mp4 --title "T" --dry-run --json-out # validate, no upload + +Headless servers: run --auth once on a machine with a browser, then copy the cached +token (default _internal/.youtube/token_<account>.json) to the server. + +First-time setup (Google Cloud Console) is documented in docs/youtube-upload.md and .env.example. +Note: until your OAuth app is Google-verified, uploads are force-locked to private. + """, + ) + + parser.add_argument("--video", "--input", dest="video", help="Path to the video file to upload") + parser.add_argument("--title", help="Video title (max 100 chars)") + + desc_group = parser.add_mutually_exclusive_group() + desc_group.add_argument("--description", help="Video description text") + desc_group.add_argument( + "--description-file", help="Read description from a file ('-' for stdin)" + ) + + parser.add_argument("--tags", help="Comma-separated tags (combined <= 500 chars)") + parser.add_argument( + "--category", default="22", + help='Numeric category ID as a string (default "22" = People & Blogs; "28" = Science & Tech)', + ) + parser.add_argument( + "--privacy", choices=VALID_PRIVACY, default="private", + help="Visibility at insert (default: private). --publish-at forces private at insert.", + ) + parser.add_argument( + "--publish-at", + help="Schedule public go-live, ISO8601/RFC3339 UTC e.g. 2026-06-10T15:00:00Z (forces private at insert)", + ) + parser.add_argument("--thumbnail", help="Path to a custom thumbnail image (<=2MB, 1280x720)") + parser.add_argument("--captions", help="Path to a caption file (.srt/.vtt)") + parser.add_argument("--captions-language", default="en", help="Caption language code (default: en)") + parser.add_argument("--playlist", help="Playlist ID to add the video to") + parser.add_argument("--made-for-kids", action="store_true", help="Set selfDeclaredMadeForKids") + parser.add_argument("--default-language", help="snippet.defaultLanguage code, e.g. en") + parser.add_argument( + "--license", choices=("youtube", "creativeCommon"), default="youtube", + help="Video license (default: youtube)", + ) + + parser.add_argument("--account", default="default", help="Channel/account name to namespace the cached token") + parser.add_argument("--client-secrets", help="Path to the OAuth client_secret JSON (overrides env var)") + parser.add_argument("--auth", action="store_true", help="Interactive login only: cache a token and exit") + parser.add_argument( + "--login-if-needed", action="store_true", + help="Allow an upload run to open a browser for first-time consent (default: fail with instructions)", + ) + parser.add_argument("--dry-run", action="store_true", help="Validate + print the request body without uploading") + parser.add_argument("--json-out", action="store_true", help="Emit a single machine-readable JSON line to stdout") + parser.add_argument( + "--chunk-size", type=int, default=DEFAULT_CHUNK_MIB, help=f"Upload chunk size in MiB (default {DEFAULT_CHUNK_MIB})" + ) + return parser + + +def main(): + args = build_parser().parse_args() + + # --- Interactive login only ------------------------------------------------- + if args.auth: + try: + get_credentials(args.account, args.client_secrets, allow_interactive=True) + except AuthError as e: + log(str(e), "error") + if args.json_out: + emit_json({"success": False, "error": str(e), "errorType": "auth"}) + sys.exit(1) + log(f"Authorized account '{args.account}' — token cached at {token_path_for(args.account)}", "success") + if args.json_out: + emit_json({"success": True, "account": args.account, "action": "auth"}) + sys.exit(0) + + # --- Validate ---------------------------------------------------------------- + err = validate_args(args) + if err: + log(err, "error") + if args.json_out: + emit_json({"success": False, "error": err, "errorType": "validation"}) + sys.exit(1) + + description = read_description(args) + tags = parse_tags(args.tags) + body = build_request_body(args, description, tags) + + # --- Dry run ----------------------------------------------------------------- + if args.dry_run: + log("Dry run — request body (no upload):", "info") + print(json.dumps(body, indent=2), file=sys.stderr) + auth_ok = False + auth_msg = None + try: + get_credentials(args.account, args.client_secrets, allow_interactive=False) + auth_ok = True + log("Auth: cached credentials ready.", "success") + except AuthError as e: + auth_msg = str(e) + log(f"Auth: not ready — {e}", "warn") + if args.json_out: + emit_json({ + "success": True, "dryRun": True, "authOk": auth_ok, + "authError": auth_msg, "requestBody": body, "account": args.account, + }) + sys.exit(0) + + # --- Warn about the unverified-app private lock ------------------------------ + if args.privacy == "public" or args.publish_at: + log( + "Requested public/scheduled visibility. Unverified OAuth apps force uploads " + "to PRIVATE regardless; the video stays private until your app is Google-verified.", + "warn", + ) + + # --- Auth -------------------------------------------------------------------- + try: + creds = get_credentials(args.account, args.client_secrets, allow_interactive=args.login_if_needed) + except AuthError as e: + log(str(e), "error") + if args.json_out: + emit_json({"success": False, "error": str(e), "errorType": "auth"}) + sys.exit(1) + + youtube = build_service(creds) + + # --- Upload ------------------------------------------------------------------ + media = MediaFileUpload(args.video, chunksize=args.chunk_size * 1024 * 1024, resumable=True) + request = youtube.videos().insert(part="snippet,status", body=body, media_body=media) + + log(f"Uploading '{Path(args.video).name}' to account '{args.account}'...", "info") + try: + response = resumable_upload(request) + except HttpError as e: + status = getattr(e.resp, "status", None) + etype = "quota" if status == 403 else "http" + msg = f"HTTP {status}: {e}" + log(msg, "error") + if args.json_out: + emit_json({"success": False, "error": msg, "errorType": etype, "videoId": None}) + sys.exit(1) + except UploadError as e: + log(str(e), "error") + if args.json_out: + emit_json({"success": False, "error": str(e), "errorType": "upload", "videoId": None}) + sys.exit(1) + + video_id = response["id"] + actual_privacy = response.get("status", {}).get("privacyStatus", "unknown") + url = f"https://www.youtube.com/watch?v={video_id}" + log(f"Uploaded: {url} (privacy: {actual_privacy})", "success") + if args.privacy != "private" or args.publish_at: + if actual_privacy == "private" and (args.publish_at or args.privacy == "public"): + log( + "YouTube returned privacy=private despite the request — this is the " + "unverified-app lock. Verify your OAuth app to enable public/scheduled.", + "warn", + ) + + # --- Post-upload extras (each non-fatal) ------------------------------------- + thumbnail_set = set_thumbnail(youtube, video_id, args.thumbnail) if args.thumbnail else False + captions_added = ( + add_caption(youtube, video_id, args.captions, args.captions_language) if args.captions else False + ) + if args.playlist: + add_to_playlist(youtube, video_id, args.playlist) + + if args.json_out: + emit_json({ + "success": True, + "videoId": video_id, + "url": url, + "privacyStatus": actual_privacy, + "requestedPrivacy": args.privacy, + "publishAt": args.publish_at, + "account": args.account, + "thumbnailSet": thumbnail_set, + "captionsAdded": captions_added, + "playlistId": args.playlist, + "uploadStatus": "uploaded", + }) + + sys.exit(0) + + +if __name__ == "__main__": + main() From 443226defca587a4426e614c0f51eb3195219482 Mon Sep 17 00:00:00 2001 From: Robert Strobl <rs@digitalsamba.com> Date: Tue, 9 Jun 2026 20:40:36 +0200 Subject: [PATCH 2/2] docs: soften the unaudited-project private-lock warning to match reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-upload warning and docs asserted that unverified apps force every upload to private. Verified empirically that first-party uploads (own Cloud project + own channel + own account) publish public/scheduled fine without an audit — the lock mainly targets third-party apps uploading to other channels. Rewords the runtime warning, module comment, docs/youtube-upload.md, CLAUDE.md, and .env.example to say public uploads MAY be locked (chiefly cross-account), and to treat the tool's actual returned privacyStatus as the source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .env.example | 7 ++++--- CLAUDE.md | 6 ++++-- docs/youtube-upload.md | 15 ++++++++++----- tools/youtube_upload.py | 27 +++++++++++++++++---------- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 1bf5d6a..54da33c 100644 --- a/.env.example +++ b/.env.example @@ -62,9 +62,10 @@ # 3. Credentials -> Create OAuth client ID -> Application type "Desktop app". # 4. Download the client_secret_*.json and point the var below at it (absolute path). # 5. Log in once (opens a browser): python3 tools/youtube_upload.py --auth -# NOTE: until your OAuth app passes Google verification, every uploaded video is -# FORCE-LOCKED to private regardless of --privacy, and "Testing"-mode refresh tokens -# expire after ~7 days. Upload-as-private + publish manually until verified. +# NOTE: unaudited API projects MAY have public uploads force-locked to private, but +# this mainly affects uploads to OTHER channels — first-party uploads to your own +# channel generally publish public/scheduled fine. "Testing"-mode refresh tokens +# expire after ~7 days (re-run --auth). The tool reports the actual returned privacy. # YOUTUBE_CLIENT_SECRETS_FILE=/absolute/path/to/client_secret.json # # Optional: override where per-account refresh tokens are cached diff --git a/CLAUDE.md b/CLAUDE.md index e7f5e69..39ebe30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -374,8 +374,10 @@ python3 tools/youtube_upload.py --video out/video.mp4 --title "Test" --dry-run - `docs/youtube-upload.md` for the one-time Google Cloud Console walkthrough and `YOUTUBE_CLIENT_SECRETS_FILE` in `.env`. Key realities: - Default quota is 10,000 units/day; `videos.insert` costs ~1,600 units → **~6 uploads/day**. -- **Until your OAuth app passes Google verification, uploads are force-locked to private** - regardless of `--privacy`, and "Testing"-mode refresh tokens expire after ~7 days. +- **Unaudited API projects *may* have public uploads force-locked to private** — but this + mainly affects uploads to *other* people's channels. First-party uploads (your own project + + channel + account) generally publish public/scheduled fine without the audit. The tool reports + the actual returned privacy. "Testing"-mode refresh tokens expire after ~7 days (re-run `--auth`). - Cached tokens live in `_internal/.youtube/` (gitignored — they grant channel-upload access). ## Video Production Workflow diff --git a/docs/youtube-upload.md b/docs/youtube-upload.md index 5bc4e42..98520cb 100644 --- a/docs/youtube-upload.md +++ b/docs/youtube-upload.md @@ -61,7 +61,7 @@ python3 tools/youtube_upload.py \ --category 28 \ --json-out -# Schedule a public go-live (effective only once the app is verified — see Gotchas) +# Schedule a public go-live (works for first-party uploads — see Gotchas re: the audit) python3 tools/youtube_upload.py --video out/video.mp4 --title "My video" \ --publish-at 2026-06-10T09:00:00Z --thumbnail out/thumb.png @@ -121,10 +121,15 @@ Compare `requestedPrivacy` vs `privacyStatus` to detect the unverified-app force ## Gotchas -- **Unverified-app private lock.** Until your OAuth app passes Google verification, YouTube - force-sets every uploaded video to `private` regardless of `--privacy`/`--publish-at`. The - tool warns and reports the *actual* returned privacy. Workaround until verified: upload as - private and publish manually in YouTube Studio. +- **Unaudited-project private lock (often a non-issue for first-party uploads).** Google's + docs say videos uploaded via `videos.insert` from unaudited API projects (created after + 2020-07-28) are restricted to `private`. In practice this targets *third-party* apps + uploading to *other people's* channels — when the Cloud project owner, the channel, and the + authenticated account are all **you** (a first-party upload), it generally isn't enforced and + public/scheduled uploads go live fine (verified empirically on this toolkit). If it does bite + (e.g. uploading to a different channel via `--account`), lift it by completing the **YouTube + API compliance audit** (publish the app to production, then submit the audit form). Either way + the tool reports the *actual* returned privacy — treat that as the source of truth. - **7-day refresh-token expiry in "Testing".** Consent screens left in Testing mode expire refresh tokens after ~7 days. If an unattended run fails with `errorType: "auth"`, just re-run `python3 tools/youtube_upload.py --auth`. Publishing the consent screen removes this (but diff --git a/tools/youtube_upload.py b/tools/youtube_upload.py index 4bed9c8..0cc25f4 100644 --- a/tools/youtube_upload.py +++ b/tools/youtube_upload.py @@ -41,11 +41,15 @@ # Hitting quota returns HTTP 403 'quotaExceeded' (NOT retriable). Request more # via the Google Cloud quota form if you need volume. # -# UNVERIFIED OAUTH APP LOCK: until the OAuth app passes Google verification, -# every uploaded video is force-set to PRIVATE regardless of the requested -# privacyStatus, and cannot be made public via the API. 'Testing'-mode consent -# screens additionally expire refresh tokens after ~7 days. This tool warns at -# runtime and reports the *actual* returned privacyStatus, not just the request. +# UNAUDITED-PROJECT PRIVATE LOCK: Google's docs state that videos uploaded via +# videos.insert from unaudited API projects (created after 2020-07-28) are +# restricted to private. In practice this targets THIRD-PARTY apps uploading to +# OTHER users' channels — first-party uploads (your own Cloud project + your own +# channel + your own account) generally are NOT affected and go public fine +# (verified empirically). If it does bite, lift it via the YouTube API compliance +# audit. 'Testing'-mode consent screens also expire refresh tokens after ~7 days. +# This tool always reports the *actual* returned privacyStatus (the source of +# truth), not just what was requested. # --------------------------------------------------------------------------- """ from __future__ import annotations @@ -575,11 +579,13 @@ def main(): }) sys.exit(0) - # --- Warn about the unverified-app private lock ------------------------------ + # --- Note the possible unaudited-project private lock ------------------------ if args.privacy == "public" or args.publish_at: log( - "Requested public/scheduled visibility. Unverified OAuth apps force uploads " - "to PRIVATE regardless; the video stays private until your app is Google-verified.", + "Requested public/scheduled visibility. Unaudited API projects MAY have public " + "uploads force-locked to private (mainly affects uploads to OTHER users' channels; " + "first-party uploads to your own channel usually go public). Check the actual " + "privacy reported below.", "warn", ) @@ -622,8 +628,9 @@ def main(): if args.privacy != "private" or args.publish_at: if actual_privacy == "private" and (args.publish_at or args.privacy == "public"): log( - "YouTube returned privacy=private despite the request — this is the " - "unverified-app lock. Verify your OAuth app to enable public/scheduled.", + "YouTube returned privacy=private despite the request — likely the " + "unaudited-project lock. For your own channel this usually doesn't apply; " + "otherwise lift it via the YouTube API compliance audit.", "warn", )