#!/usr/bin/env python3 """youtube_upload.py — 動画を YouTube に「非公開+公開予約」で上げる(2026-11-07 作成). YouTube の公式の仕組み(YouTube Data API v3)で上げる。上げる動画は必ず非公開。公開予約の時刻を付けたときだけ、 その時刻に YouTube が公開する。公開にするのは、エイイチが YouTube Studio で確かめてから(終了画面・カード・ ショートの関連動画は API では付けられないので Studio で付ける)。 注意(審査の前): Google の審査(YouTube API Services の Audit)を通るまでは、API で上げた動画はすべて非公開に固定され、 あとから Studio でも公開にできない。審査の前は、試しの動画(--test)だけを上げる。本番の動画は上げない。 許可の鍵は Mac の中だけに置く(Claude には渡さない。youtube_schedule.py と同じ考え): ~/.config/pixelstomotors/client_secret.json youtube_schedule.py と同じもの(Google Cloud: pixelstomotors-studio) ~/.config/pixelstomotors/youtube_upload_token.json アップロードの許可(このスクリプトが作る。読み取り用とは別のファイル) 使い方(Mac のターミナルで、このファイルがあるフォルダで): python3 youtube_upload.py --login 最初の1回。ブラウザで、チャンネル「Pixels to Motors」のアカウントを選んで許可する python3 youtube_upload.py --test 試しの動画.mp4 審査の申請用。「API のテスト(公開しません)」という題で、非公開のまま上げる python3 youtube_upload.py 動画.mp4 --txt ショート_タイトルと説明文.txt --at "2026-11-20 19:30" [--thumb サムネイル.png] 本番用(審査が通ってから)。題と説明文は txt の【タイトル】【説明文】から読む。--at は日本時間 --title / --desc で直接書いてもよい。--at を付けなければ、非公開のまま上げるだけ --dry-run を付けると、上げずに、何を上げるかだけ表示する """ import argparse, json, os, re, sys, urllib.error, urllib.parse, urllib.request from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import youtube_schedule as ys # ログインの仕組み(ブラウザで許可をもらう所)は youtube_schedule.py のものを使う # openid・email は入れない(2026-11-07: ブランドアカウントのチャンネル Pixels to Motors を選ぶ所で、 # Google が 400 を返した。ブランドアカウントにはメールアドレスがないため) ys.SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly" ys.TOKEN = os.path.join(ys.CONF, "youtube_upload_token.json") LOG = os.path.join(ys.CONF, "youtube_upload.log") JST = timezone(timedelta(hours=9)) UP = "https://www.googleapis.com/upload/youtube/v3" def log(msg): os.makedirs(ys.CONF, exist_ok=True) with open(LOG, "a", encoding="utf-8") as f: f.write(f"{datetime.now(JST):%Y-%m-%d %H:%M:%S} {msg}\n") def read_txt(path): """ショート_タイトルと説明文.txt の【タイトル】【説明文】を読む""" s = open(path, encoding="utf-8").read() parts = dict(re.findall(r"【(.+?)】\n(.*?)(?=\n【|\Z)", s, re.S)) return parts.get("タイトル", "").strip(), parts.get("説明文", "").strip() def check_channel(tok): """許可したアカウントのチャンネルが Pixels to Motors かを確かめる(違うチャンネルに上げないため)""" me = ys.call(f"{ys.API}/channels?part=snippet&mine=true", headers={"Authorization": "Bearer " + tok}) items = me.get("items", []) ids = [i["id"] for i in items] if ys.CHANNEL_ID not in ids: names = "・".join(i["snippet"]["title"] for i in items) or "(なし)" raise SystemExit(f"許可したアカウントのチャンネルが Pixels to Motors ではありません({names})。--login で選び直してください") def upload(path, title, desc, at, dry): size = os.path.getsize(path) status = {"privacyStatus": "private", "selfDeclaredMadeForKids": False} if at: t = datetime.strptime(at, "%Y-%m-%d %H:%M").replace(tzinfo=JST) if t <= datetime.now(JST) + timedelta(minutes=15): raise SystemExit("公開予約の時刻は、いまから15分より先にしてください") status["publishAt"] = t.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") body = {"snippet": {"title": title[:100], "description": desc[:5000], "categoryId": "28", "defaultLanguage": "ja", "defaultAudioLanguage": "ja"}, "status": status} print(f"題: {title}\n大きさ: {size / 1e6:.1f}MB\n公開: 非公開" + (f"({at} に公開予約)" if at else "のまま")) if dry: print("(--dry-run なので上げていません)") return None tok = ys.access_token() check_channel(tok) # 1. 置き場所をもらう(resumable upload) req = urllib.request.Request(f"{UP}/videos?uploadType=resumable&part=snippet,status", data=json.dumps(body).encode(), method="POST", headers={ "Authorization": "Bearer " + tok, "Content-Type": "application/json; charset=UTF-8", "X-Upload-Content-Type": "video/mp4", "X-Upload-Content-Length": str(size)}) try: with urllib.request.urlopen(req, timeout=60) as r: loc = r.headers["Location"] except urllib.error.HTTPError as e: raise SystemExit(f"YouTube に断られました({e.code}): {e.read().decode(errors='ignore')[:400]}") # 2. 中身を送る with open(path, "rb") as f: req = urllib.request.Request(loc, data=f, method="PUT", headers={"Content-Type": "video/mp4", "Content-Length": str(size)}) try: with urllib.request.urlopen(req, timeout=1800) as r: v = json.loads(r.read().decode()) except urllib.error.HTTPError as e: raise SystemExit(f"送っている途中で断られました({e.code}): {e.read().decode(errors='ignore')[:400]}") vid = v["id"] print(f"上げました: https://studio.youtube.com/video/{vid}/edit") log(f"upload {vid} {status} {title}") return vid, tok def thumb(vid, tok, png): with open(png, "rb") as f: data = f.read() ctype = "image/png" if png.lower().endswith(".png") else "image/jpeg" req = urllib.request.Request(f"{UP}/thumbnails/set?videoId={vid}", data=data, method="POST", headers={"Authorization": "Bearer " + tok, "Content-Type": ctype}) try: with urllib.request.urlopen(req, timeout=120): print("サムネイルを付けました") except urllib.error.HTTPError as e: # 付けられなくても動画は上がっているので止めない print(f"サムネイルは付けられませんでした({e.code})。Studio で付けてください") def main(): ap = argparse.ArgumentParser() ap.add_argument("video", nargs="?") ap.add_argument("--login", action="store_true") ap.add_argument("--test", metavar="MP4") ap.add_argument("--txt") ap.add_argument("--title") ap.add_argument("--desc", default="") ap.add_argument("--at", help='日本時間 "YYYY-MM-DD HH:MM"') ap.add_argument("--thumb") ap.add_argument("--dry-run", action="store_true") a = ap.parse_args() if a.login: ys.login() check_channel(ys.access_token()) print("チャンネル Pixels to Motors に上げられる許可です") return if a.test: upload(a.test, "API のテスト(公開しません)", "YouTube Data API のアップロードの試しです。公開しません。", None, a.dry_run) return if not a.video: ap.error("動画のファイルを指定してください(または --login / --test)") title, desc = (read_txt(a.txt) if a.txt else ("", "")) title, desc = a.title or title, a.desc or desc if not title: raise SystemExit("題がありません(--txt か --title で)") r = upload(a.video, title, desc, a.at, a.dry_run) if r and a.thumb: thumb(r[0], r[1], a.thumb) if __name__ == "__main__": main()