#!/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()
