← Commit history

v0.8.27: youtube upload (resumable) + honest privacy reporting

John Lauer ·7dd0762301 ·22d ago ·parent 3abc3fd
2 files changed +286−8
bin/adom-google+282−5
@@ -23,7 +23,7 @@ See `adom-google help`. """ import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time, subprocess, shutil -ADOM_GOOGLE_VERSION = "0.8.26"+ADOM_GOOGLE_VERSION = "0.8.27" if sys.argv[1:2] == ["--version"]:     print(f"adom-google {ADOM_GOOGLE_VERSION}")     sys.exit(0)@@ -188,10 +188,14 @@ ONBOARD_APIS = [     ("calendar-json.googleapis.com", "Google Calendar API"),     ("tasks.googleapis.com",         "Google Tasks API"),     ("chat.googleapis.com",          "Google Chat API"),+    # YouTube is enabled up front even though youtube scopes are NOT in the default set: enabling an+    # API costs nothing, and the alternative is what bit us (issue #11) — the first upload fails with+    # SERVICE_DISABLED long after onboarding, when nobody remembers where the project lives.+    ("youtube.googleapis.com",       "YouTube Data API v3 (only needed for `youtube upload`)"), ]  def _enable_apis_url(project=None):-    # One URL that enables ALL nine APIs in one click (the GCP "enable multiple APIs" flow).+    # One URL that enables ALL of them in one click (the GCP "enable multiple APIs" flow).     u = "https://console.cloud.google.com/flows/enableapi?apiid=" + ",".join(a for a, _ in ONBOARD_APIS)     return u + ("&project=" + project if project else "") @@ -233,7 +237,7 @@ Hint: fallback) and AUTO-FILLS the Cloud Console fields. Get the exact values to Hint:     adom-google onboard --json        # redirect_uri, the 9 APIs, scopes, consent settings Hint: Steps the skill performs (screenshotting + pausing for the user's approval clicks): Hint:   1. Create/select a Google Cloud project.-Hint:   2. Enable all nine APIs in one click (the `enable_apis_url`).+Hint:   2. Enable all the APIs in one click (the `enable_apis_url`; includes YouTube Data API v3). Hint:   3. Configure the OAuth consent screen (User type = Internal; app name from config). Hint:   4. Create an OAuth client (type = Web application); AUTO-FILL Authorized redirect URI Hint:      with the gateway `redirect_uri` (the error-prone field — never make the user type it).@@ -256,6 +260,12 @@ SHEETS_API = "https://sheets.googleapis.com/v4" DOCS_API = "https://docs.googleapis.com/v1" SLIDES_API = "https://slides.googleapis.com/v1" TASKS_API = "https://tasks.googleapis.com/tasks/v1"+YT_API = "https://www.googleapis.com/youtube/v3"+YT_UPLOAD = "https://www.googleapis.com/upload/youtube/v3/videos"+# YouTube is deliberately NOT in safe/full/personal: most users never need it, and youtube.upload is+# a publishing capability. Opt in with `scopes --require youtube.upload youtube --auto-widen`.+SCOPES_YOUTUBE = ["https://www.googleapis.com/auth/youtube.upload",+                  "https://www.googleapis.com/auth/youtube"]  # --- OAuth PROVIDER (gateway URL + client) — deliberately NOT hardcoded ----------------------- # This open-source CLI ships with ZERO provider baked in. The provider (which OAuth gateway to@@ -523,6 +533,17 @@ def _request(cfg, method, url, **kw):     return r.json() if r.content else {}  +def _request_raw(cfg, method, url, **kw):+    """Like _request but returns the RESPONSE and never exits.++    Needed wherever the caller must read status/headers itself or handle a non-2xx on purpose: the+    YouTube resumable upload lives on 308 + the Range/Location headers, which _request throws away."""+    headers = kw.pop("headers", {})+    headers["Authorization"] = "Bearer " + _access_token(cfg)+    kw.setdefault("timeout", 120)+    return requests.request(method, url, headers=headers, **kw)++ def _gmail(cfg, path):     return _request(cfg, "GET", f"{GMAIL_API}/{path}") @@ -926,6 +947,7 @@ _EXCLUDED_HOSTS = { } # Best-effort host → the scope tail most likely missing, for the actionable hint. _HOST_SCOPE = {+    "www.googleapis.com/youtube": "youtube.upload youtube",     "gmail.googleapis.com": "gmail.modify", "chat.googleapis.com": "chat.messages",     "sheets.googleapis.com": "spreadsheets", "docs.googleapis.com": "documents",     "slides.googleapis.com": "presentations", "www.googleapis.com": "drive",@@ -968,6 +990,23 @@ def _scope_wall_hint(url, resp):                 "that project must enable the API once:\n")         if proj:             e.write(f"  https://console.developers.google.com/apis/api/{host}/overview?project={proj}\n")+        # WHOSE project is it? The fix lands on a different person depending on the answer, and+        # guessing wrong sends someone to a console they cannot open (issue #11).+        pr = _provider()+        src = ""+        try:+            src = json.load(open(PROVIDER_PATH)).get("_source", "") if os.path.exists(PROVIDER_PATH) else ""+        except Exception:+            pass+        if pr.get("app") == DEFAULT_APP and not src.startswith("org-settings:"):+            e.write("This is ADOM'S SHARED OAuth client, so an Adom admin enables it ONCE and every\n"+                    "adom-google user is fixed. Ask in #kel rather than making your own client.\n")+        elif src.startswith("org-settings:"):+            org = src.split(":", 1)[1]+            e.write(f"This is {org}'s OWN OAuth client (from {org}/org-settings), so {org}'s Google Cloud\n"+                    f"admin enables it once and every {org} member is fixed.\n")+        else:+            e.write("This is your own OAuth client, so whoever owns that Cloud project enables it.\n")         e.write("Then wait a minute for it to propagate and retry.\n")         e.write("────────────────────────────────────────────────────────────\n")         return@@ -1668,7 +1707,8 @@ def cmd_onboard(a):     R = cfg["redirect_uri"]     print("┌─ adom-google · onboard your org to Google Workspace ─────────────────────────┐")     print("│  One-time setup: your team gets its OWN Google OAuth client so the AI can     │")-    print("│  read mail, post Chat on your behalf, build Slides/Sheets/Docs, search Drive. │")+    print("│  read mail, post Chat on your behalf, build Slides/Sheets/Docs, search Drive, │")+    print("│  and upload to YouTube.                                                       │")     print("│  Done ONCE by one person: Adom's gateway verifies you are in the org, checks  │")     print("│  the client with Google and holds the secret, so every member just signs in. │")     print("└──────────────────────────────────────────────────────────────────────────────┘\n")@@ -1676,7 +1716,7 @@ def cmd_onboard(a):     print("fields (recommended) — or you can do it by hand. Either way, the exact values:\n")     print("1) Create / pick a Google Cloud project:")     print("     " + cfg["console"]["project_create"])-    print("\n2) Enable all nine APIs in ONE click:")+    print("\n2) Enable all the APIs in ONE click:")     print("     " + cfg["enable_apis_url"])     for ap in cfg["apis"]:         print(f"        • {ap['label']}")@@ -2056,6 +2096,11 @@ AUTH (one time; one consent → long-lived refresh token, stored 0600):   adom-google auth-code '<code>'   finish the --manual flow   adom-google init <id> <secret>   set an explicit desktop OAuth client (for --manual)   adom-google auth --json       tooling: guidance on stderr, ONE JSON line on stdout when authorized+  adom-google youtube upload <file> [--privacy unlisted|private|public] [--playlist id]+                                resumable upload; prints the watch URL + the privacy YouTube really+                                applied. Needs an opt-in widen:+                                  adom-google scopes --require youtube.upload youtube --auto-widen+  adom-google youtube channels | list | delete <id> --yes   adom-google status [--json]   show access + what was REQUESTED at auth (never prints secrets).                                 Exit 1 when NOT authorized; --json gives authorized:true|false.   adom-google scopes [--json]   authoritative GRANTED scopes (live tokeninfo) + drift vs requested@@ -2259,6 +2304,224 @@ def cmd_tasks_add(a):     sys.stderr.write(f"→ added task '{a.title}'\n")  ++# ---------------------------------------------------------------- youtube+def _yt_privacy_warn(requested, actual, vid):+    """Say plainly when YouTube overrode the requested privacy.++    An API project that has not passed YouTube's API compliance audit has videos.insert LOCKED TO+    PRIVATE: the request succeeds, the response says private, and nothing explains why. Silently+    returning "uploaded" there is how a video meant to be shared sits invisible (issue #11)."""+    if actual == requested:+        return+    sys.stderr.write(+        f"\n⚠ YouTube applied privacyStatus={actual}, NOT the {requested} you asked for.\n"+        "  Almost always this project has not passed YouTube's API compliance audit: uploads from an\n"+        "  unaudited project are forced to private, whatever you send. The upload itself is fine.\n"+        "  Fix it either way:\n"+        f"    • flip it by hand now:  https://studio.youtube.com/video/{vid}/edit\n"+        "    • or submit the project for the YouTube API audit (one time, then the API honours it):\n"+        "      https://support.google.com/youtube/contact/yt_api_form\n")+++def cmd_youtube_upload(a):+    path = os.path.expanduser(a.file)+    if not os.path.isfile(path):+        sys.exit(f"ERROR: no such file: {path}")+    size = os.path.getsize(path)+    if size == 0:+        sys.exit(f"ERROR: {path} is empty")+    cfg = _load()+    title = a.title or os.path.splitext(os.path.basename(path))[0]+    body = {"snippet": {"title": title, "description": a.description or "",+                        "categoryId": a.category or "22"},+            "status": {"privacyStatus": a.privacy, "selfDeclaredMadeForKids": False}}+    if a.tags:+        body["snippet"]["tags"] = [t.strip() for t in a.tags.split(",") if t.strip()]+    mime = "video/mp4"+    ext = os.path.splitext(path)[1].lower()+    if ext in (".mov", ".qt"): mime = "video/quicktime"+    elif ext == ".webm":       mime = "video/webm"+    elif ext in (".mkv",):     mime = "video/x-matroska"+    elif ext in (".avi",):     mime = "video/x-msvideo"++    # 1. Start a RESUMABLE session. The video bytes cannot ride the JSON passthrough: YouTube wants+    #    a resumable (or multipart) upload, and resumable is the only sane option for large files+    #    because a dropped connection can be continued instead of restarted.+    r = _request_raw(cfg, "POST", YT_UPLOAD, params=[("uploadType", "resumable"), ("part", "snippet,status")],+                 json=body, headers={"X-Upload-Content-Length": str(size),+                                     "X-Upload-Content-Type": mime})+    if r.status_code not in (200, 201):+        _scope_wall_hint(YT_UPLOAD, r)+        sys.exit(f"ERROR: could not start the upload: HTTP {r.status_code} {r.text[:400]}")+    session = r.headers.get("Location")+    if not session:+        sys.exit("ERROR: YouTube did not return a resumable session URL.")++    # 2. Send the bytes in chunks, resuming on a dropped connection rather than starting over.+    chunk = max(256 * 1024, (a.chunk_mb or 8) * 1024 * 1024)+    chunk -= chunk % (256 * 1024)            # YouTube requires a multiple of 256 KiB+    tok = _access_token(cfg)+    sent, tries, video = 0, 0, None+    t0 = time.time()+    with open(path, "rb") as f:+        while sent < size:+            f.seek(sent)+            data = f.read(chunk)+            end = sent + len(data) - 1+            try:+                pr = requests.put(session, data=data, timeout=600, headers={+                    "Authorization": "Bearer " + tok, "Content-Type": mime,+                    "Content-Length": str(len(data)),+                    "Content-Range": f"bytes {sent}-{end}/{size}"})+            except requests.RequestException as e:+                tries += 1+                if tries > 5:+                    sys.exit(f"ERROR: upload failed after {tries} attempts: {e}\n"+                             f"Hint: rerun the same command; YouTube keeps the session briefly and it resumes.")+                time.sleep(min(30, 2 ** tries))+                sent = _yt_resume_offset(session, tok, size, sent)+                continue+            if pr.status_code in (200, 201):+                video = pr.json()+                sent = size+                break+            if pr.status_code == 308:                       # chunk accepted, keep going+                tries = 0+                rng = pr.headers.get("Range")                # e.g. bytes=0-8388607+                sent = int(rng.split("-")[-1]) + 1 if rng else sent + len(data)+            elif pr.status_code in (500, 502, 503, 504):+                tries += 1+                if tries > 5:+                    sys.exit(f"ERROR: YouTube kept failing ({pr.status_code}). Rerun to resume.")+                time.sleep(min(30, 2 ** tries))+                sent = _yt_resume_offset(session, tok, size, sent)+            else:+                _scope_wall_hint(YT_UPLOAD, pr)+                sys.exit(f"ERROR: upload rejected: HTTP {pr.status_code} {pr.text[:400]}")+            if not a.quiet:+                pct = 100.0 * sent / size+                sys.stderr.write(f"\r   uploading {pct:5.1f}%  ({sent // 1048576}/{size // 1048576} MiB)")+                sys.stderr.flush()+    if not a.quiet:+        sys.stderr.write(f"\r   uploaded {size // 1048576} MiB in {int(time.time() - t0)}s\n")+    if not video:+        sys.exit("ERROR: upload finished but YouTube returned no video resource.")++    vid = video["id"]+    # 3. Read the privacy YouTube ACTUALLY applied, from a fresh GET rather than trusting the insert+    #    response, and say so.+    actual = (video.get("status") or {}).get("privacyStatus")+    g = _request_raw(cfg, "GET", f"{YT_API}/videos", params=[("part", "status,snippet,processingDetails"), ("id", vid)])+    if g.status_code == 200 and (g.json().get("items") or []):+        it = g.json()["items"][0]+        actual = (it.get("status") or {}).get("privacyStatus", actual)+    watch = f"https://www.youtube.com/watch?v={vid}"+    if a.thumbnail:+        tp = os.path.expanduser(a.thumbnail)+        if os.path.isfile(tp):+            with open(tp, "rb") as tf:+                tr = _request_raw(cfg, "POST", "https://www.googleapis.com/upload/youtube/v3/thumbnails/set",+                              params=[("videoId", vid)], data=tf.read(),+                              headers={"Content-Type": "image/png" if tp.lower().endswith(".png") else "image/jpeg"})+            print(f"   thumbnail: {'set' if tr.status_code in (200, 201) else 'FAILED ' + str(tr.status_code)}")+        else:+            print(f"   thumbnail: SKIPPED (no such file: {tp})", file=sys.stderr)+    if a.playlist:+        pr2 = _request_raw(cfg, "POST", f"{YT_API}/playlistItems", params=[("part", "snippet")],+                       json={"snippet": {"playlistId": a.playlist,+                                         "resourceId": {"kind": "youtube#video", "videoId": vid}}})+        print(f"   playlist:  {'added' if pr2.status_code in (200, 201) else 'FAILED ' + str(pr2.status_code) + ' ' + pr2.text[:120]}")+    if a.json:+        print(json.dumps({"id": vid, "watch_url": watch, "title": title,+                          "privacy_requested": a.privacy, "privacy_applied": actual,+                          "privacy_honored": actual == a.privacy, "bytes": size,+                          "studio_url": f"https://studio.youtube.com/video/{vid}/edit"}))+    else:+        print(f"✅ uploaded: {title}")+        print(f"   video id:  {vid}")+        print(f"   watch:     {watch}")+        print(f"   privacy:   {actual}" + ("" if actual == a.privacy else f"  (you asked for {a.privacy})"))+        print(f"   studio:    https://studio.youtube.com/video/{vid}/edit")+    _yt_privacy_warn(a.privacy, actual, vid)+++def _yt_resume_offset(session, tok, size, fallback):+    """Ask YouTube how much it already has, so a retry continues instead of re-sending."""+    try:+        q = requests.put(session, timeout=60, headers={+            "Authorization": "Bearer " + tok, "Content-Length": "0",+            "Content-Range": f"bytes */{size}"})+        if q.status_code == 308:+            rng = q.headers.get("Range")+            return int(rng.split("-")[-1]) + 1 if rng else 0+        if q.status_code in (200, 201):+            return size+    except requests.RequestException:+        pass+    return fallback+++def cmd_youtube_channels(a):+    cfg = _load()+    r = _request_raw(cfg, "GET", f"{YT_API}/channels", params=[("part", "snippet,contentDetails,statistics"), ("mine", "true")])+    if r.status_code != 200:+        _scope_wall_hint(f"{YT_API}/channels", r)+        sys.exit(f"ERROR: HTTP {r.status_code} {r.text[:300]}")+    items = r.json().get("items") or []+    if a.json:+        print(json.dumps(items)); return+    if not items:+        print("No YouTube channel on this Google account."); return+    for it in items:+        up = (it.get("contentDetails") or {}).get("relatedPlaylists", {}).get("uploads")+        st = it.get("statistics") or {}+        print(f"{it['snippet']['title']}  ·  {it['id']}")+        print(f"   uploads playlist: {up}   videos: {st.get('videoCount', '?')}   subscribers: {st.get('subscriberCount', '?')}")+++def cmd_youtube_list(a):+    cfg = _load()+    r = _request_raw(cfg, "GET", f"{YT_API}/search", params=[("part", "snippet"), ("forMine", "true"),+                                                        ("type", "video"), ("order", "date"),+                                                        ("maxResults", str(a.max))])+    if r.status_code != 200:+        _scope_wall_hint(f"{YT_API}/search", r)+        sys.exit(f"ERROR: HTTP {r.status_code} {r.text[:300]}")+    items = r.json().get("items") or []+    ids = [i["id"]["videoId"] for i in items]+    priv = {}+    if ids:+        g = _request_raw(cfg, "GET", f"{YT_API}/videos", params=[("part", "status"), ("id", ",".join(ids))])+        if g.status_code == 200:+            priv = {i["id"]: (i.get("status") or {}).get("privacyStatus") for i in g.json().get("items", [])}+    if a.json:+        print(json.dumps([{"id": i["id"]["videoId"], "title": i["snippet"]["title"],+                           "published": i["snippet"]["publishedAt"],+                           "privacy": priv.get(i["id"]["videoId"])} for i in items])); return+    for i in items:+        v = i["id"]["videoId"]+        print(f"{i['snippet']['publishedAt'][:10]}  {priv.get(v, '?'):8}  {v}  {i['snippet']['title'][:60]}")+++def cmd_youtube_delete(a):+    """Delete ONE video by id. Deliberately single-id only: no query, no bulk."""+    cfg = _load()+    g = _request_raw(cfg, "GET", f"{YT_API}/videos", params=[("part", "snippet"), ("id", a.video_id)])+    title = "(unknown)"+    if g.status_code == 200 and (g.json().get("items") or []):+        title = g.json()["items"][0]["snippet"]["title"]+    elif g.status_code == 200:+        sys.exit(f"ERROR: no video with id {a.video_id} on this account.")+    if not a.yes:+        sys.exit(f"Refusing to delete {a.video_id} ({title}) without --yes.")+    r = _request_raw(cfg, "DELETE", f"{YT_API}/videos", params=[("id", a.video_id)])+    if r.status_code not in (200, 204):+        _scope_wall_hint(f"{YT_API}/videos", r)+        sys.exit(f"ERROR: HTTP {r.status_code} {r.text[:300]}")+    print(f"Deleted {a.video_id} ({title}).")++ def main():     p = argparse.ArgumentParser(add_help=False)     sub = p.add_subparsers(dest="cmd")@@ -2299,6 +2562,20 @@ def main():     y.add_argument("--relay", action="store_true", help="old single-box mode: keep the secret 0600 on THIS box only (teammates cannot use it)")     y.set_defaults(fn=cmd_onboard_finish) +    yt = sub.add_parser("youtube", help="YouTube: upload a video (resumable), list, channels")+    yts = yt.add_subparsers(dest="sub")+    u = yts.add_parser("upload", help="resumable upload; prints the watch URL and the privacy YouTube actually applied")+    u.add_argument("file"); u.add_argument("--title"); u.add_argument("--description", default="")+    u.add_argument("--privacy", choices=["private", "unlisted", "public"], default="unlisted")+    u.add_argument("--tags", help="comma separated"); u.add_argument("--category", help="YouTube categoryId (default 22, People & Blogs)")+    u.add_argument("--playlist", metavar="PLAYLIST_ID"); u.add_argument("--thumbnail", metavar="PATH")+    u.add_argument("--chunk-mb", dest="chunk_mb", type=int, default=8)+    u.add_argument("--quiet", action="store_true", help="no progress line"); u.add_argument("--json", action="store_true")+    u.set_defaults(fn=cmd_youtube_upload)+    u = yts.add_parser("channels"); u.add_argument("--json", action="store_true"); u.set_defaults(fn=cmd_youtube_channels)+    u = yts.add_parser("list"); u.add_argument("--max", type=int, default=10); u.add_argument("--json", action="store_true"); u.set_defaults(fn=cmd_youtube_list)+    u = yts.add_parser("delete"); u.add_argument("video_id"); u.add_argument("--yes", action="store_true"); u.set_defaults(fn=cmd_youtube_delete)+     og = sub.add_parser("org", help="set adom-google up ONCE for a whole wiki org (gateway holds the secret; members auto-discover)")     ogs = og.add_subparsers(dest="sub")     z = ogs.add_parser("register", help="hand your org's Google OAuth client to the gateway + publish the non-secret half to <org>/org-settings")
page.json+4−3
@@ -94,7 +94,7 @@   "skills": [],   "hero_path": "screenshots/hero-google-v5.png",   "hero_type": "image",-  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Connect a personal @gmail (or another org's Google account) with ZERO setup via Adom's hosted Connector (`adom-google connect-personal`), or bring your own token-broker gateway (`provider set`): Adom staff use the private adom-google-adom provider. One-click OAuth, no client secret on your machine.",+  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Open source, ships with NO OAuth provider baked in: point it at your own token-broker gateway (`provider set`), buy a managed one, or: Adom staff: install the private adom-google-adom provider. One-click OAuth, no client secret on your machine. First run asks safe mode (no delete) vs full mode (incl. delete).",   "keywords": [     "adom-google",     "google-workspace",@@ -118,6 +118,8 @@     "uninstall": "./uninstall.sh"   },   "files": [+    "bin/adom-google",+    "bin/adom-gmail",     "SKILL.md",     "skills/adom-google-onboarding/SKILL.md",     "README.md",@@ -126,8 +128,7 @@     "provider.example.json",     "install.sh",     "uninstall.sh",-    "package.json",-    "VERSION"+    "package.json"   ],   "org": "adom" }\ No newline at end of file