#!/usr/bin/env python3
"""youtube_schedule.py — 自分のチャンネルの動画を、公開予約中のものまで含めて書き出す.

YouTube の公式の仕組み（YouTube Data API v3）で読む。読むだけで、動画には何も書き込まない。
Python の標準の道具だけで動く（pip で入れるものはない）。

許可の鍵は Mac の中だけに置く（Claude には渡さない。ElevenLabs の鍵と同じ考え）:
  ~/.config/pixelstomotors/client_secret.json   Google Cloud で作った「デスクトップ アプリ」の OAuth クライアント
  ~/.config/pixelstomotors/youtube_token.json   ログインの許可（このスクリプトが作る）

書き出すもの（鍵は入らない）:
  このファイルと同じフォルダの youtube_videos.json
  → status_scan.py が読み、進み具合のページの「予約済み」に使う

使い方（Mac のターミナルで）:
  python3 youtube_schedule.py --login     最初の1回。ブラウザで許可して、一覧を書き出す
  python3 youtube_schedule.py             一覧を書き出し直す
  python3 youtube_schedule.py --install   1時間ごとに自動で書き出すように登録する（launchd・毎時5分）
2026-10-16: 自動の登録を StartInterval（3600秒ごと）から StartCalendarInterval（毎時5分）に替えた。
  StartInterval は Mac が眠っている間は時間を数えないため、6:51 のあと 8:29 まで一度も書き出されなかった。
  StartCalendarInterval は、眠っていて時刻を過ぎたときは、起きたときに1回動く。ログの行に時刻も付けた
  python3 youtube_schedule.py --uninstall 自動の登録を外す
"""
import argparse, base64, hashlib, http.server, json, os, re, secrets, subprocess, sys, threading
import urllib.parse, urllib.request, webbrowser
from datetime import datetime, timezone, timedelta

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "youtube_videos.json")
CONF = os.path.expanduser("~/.config/pixelstomotors")
SECRET = os.path.join(CONF, "client_secret.json")
TOKEN = os.path.join(CONF, "youtube_token.json")
LOG = os.path.join(CONF, "youtube_schedule.log")
SCOPE = "openid email https://www.googleapis.com/auth/youtube.readonly"
API = "https://www.googleapis.com/youtube/v3"
LABEL = "jp.adaptdesign.pixelstomotors.youtube-schedule"
PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
JST = timezone(timedelta(hours=9))
CHANNEL = "Pixels to Motors"
CHANNEL_ID = "UC3vr_5p9kJch9zCMb44_rsQ"   # @PixelstoMotors   # このチャンネル以外を許可したときは書き出さない


def call(url, data=None, headers=None):
    body = urllib.parse.urlencode(data).encode() if isinstance(data, dict) else data
    req = urllib.request.Request(url, data=body, headers=headers or {})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        raise SystemExit(f"YouTube / Google から断られました（{e.code}）: {e.read().decode(errors='ignore')[:400]}")


def client():
    if not os.path.isfile(SECRET):
        raise SystemExit(f"{SECRET} がありません。Google Cloud でダウンロードした JSON を、この名前で置いてください")
    c = json.load(open(SECRET))
    c = c.get("installed") or c.get("web") or c
    return c["client_id"], c["client_secret"]


def save_token(t):
    os.makedirs(CONF, exist_ok=True)
    with open(TOKEN, "w") as f:
        json.dump(t, f)
    os.chmod(TOKEN, 0o600)


def login():
    """ブラウザで許可をもらう（この Mac の中だけで受け取る）"""
    cid, csec = client()
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    got = {}

    class H(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
            got.update({k: v[0] for k, v in q.items()})
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            msg = "許可を受け取りました。この画面は閉じてください。" if "code" in got else "許可されませんでした。"
            self.wfile.write(f"<p style='font:16px sans-serif'>{msg}</p>".encode())

        def log_message(self, *a):
            pass

    srv = http.server.HTTPServer(("127.0.0.1", 0), H)
    redirect = f"http://127.0.0.1:{srv.server_port}/"
    url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({
        "client_id": cid, "redirect_uri": redirect, "response_type": "code", "scope": SCOPE,
        "access_type": "offline", "prompt": "select_account consent",   # 毎回アカウントを選ばせる
        "code_challenge": challenge, "code_challenge_method": "S256",
    })
    print("ブラウザで Google の許可の画面を開きます。チャンネル「Pixels to Motors」のアカウントを選んで許可してください。")
    print("開かないときは、このアドレスをブラウザに貼ってください:\n" + url)
    webbrowser.open(url)
    th = threading.Thread(target=srv.handle_request)
    th.start(); th.join(timeout=300)
    srv.server_close()
    if "code" not in got:
        raise SystemExit("許可を受け取れませんでした（5分で止めました）。もう一度 --login から")
    t = call("https://oauth2.googleapis.com/token", {
        "code": got["code"], "client_id": cid, "client_secret": csec, "redirect_uri": redirect,
        "grant_type": "authorization_code", "code_verifier": verifier,
    })
    if "refresh_token" not in t:
        raise SystemExit("許可の鍵（refresh_token）が返りませんでした。--login をもう一度")
    email = ""
    try:   # どのアカウントで許可したかを表示する（id_token の中のメールアドレス）
        part = t.get("id_token", "").split(".")[1]
        email = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))).get("email", "")
    except Exception:
        pass
    save_token({"refresh_token": t["refresh_token"], "email": email})
    print("許可を保存しました:", TOKEN)
    print("許可したアカウント:", email or "（分からない）")


def access_token():
    if not os.path.isfile(TOKEN):
        raise SystemExit("まだ許可がありません。python3 youtube_schedule.py --login を先に")
    cid, csec = client()
    rt = json.load(open(TOKEN))["refresh_token"]
    t = call("https://oauth2.googleapis.com/token", {
        "refresh_token": rt, "client_id": cid, "client_secret": csec, "grant_type": "refresh_token"})
    return t["access_token"]


def seconds(iso):
    m = re.match(r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
    if not m:
        return None
    d, h, mi, s = (int(x or 0) for x in m.groups())
    return ((d * 24 + h) * 60 + mi) * 60 + s


ID_RE = re.compile(r"(?:youtu\.be/|watch\?v=|shorts/)([A-Za-z0-9_-]{11})")


def fetch():
    tok = access_token()
    hd = {"Authorization": "Bearer " + tok}
    # チャンネルは ID で決め打ちする（許可したアカウントの「自分のチャンネル」が別のこともあるため）
    me = call(f"{API}/channels?part=snippet&mine=true", headers=hd)
    me_names = [i["snippet"]["title"] for i in me.get("items", [])]
    ch = call(f"{API}/channels?part=contentDetails,snippet&id={CHANNEL_ID}", headers=hd)
    if not ch.get("items"):
        raise SystemExit("チャンネル Pixels to Motors が見つかりませんでした")
    owner = any(CHANNEL in n for n in me_names)
    print("許可したアカウント:", json.load(open(TOKEN)).get("email") or "（前の許可。--login し直すと表示されます）")
    print("許可したアカウントのチャンネル:", "・".join(me_names) or "（なし）")
    uploads = ch["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
    ids, page = [], ""
    while True:
        r = call(f"{API}/playlistItems?part=contentDetails&maxResults=50&playlistId={uploads}" + (f"&pageToken={page}" if page else ""), headers=hd)
        ids += [i["contentDetails"]["videoId"] for i in r.get("items", [])]
        page = r.get("nextPageToken")
        if not page or len(ids) >= 500:
            break
    out = []
    for k in range(0, len(ids), 50):
        r = call(f"{API}/videos?part=snippet,status,contentDetails&id={','.join(ids[k:k + 50])}", headers=hd)
        for v in r.get("items", []):
            st, sn = v["status"], v["snippet"]
            sec = seconds(v["contentDetails"].get("duration"))
            parent = next((m.group(1) for m in ID_RE.finditer(sn.get("description", "")) if m.group(1) != v["id"]), "")
            out.append({
                "id": v["id"],
                "title": sn.get("title", ""),
                "privacy": st.get("privacyStatus"),            # public / private / unlisted
                "publishAt": st.get("publishAt"),             # 公開予約の日時（予約中だけ）
                "published": sn.get("publishedAt") if st.get("privacyStatus") == "public" else None,
                "seconds": sec,
                "is_short": sec is not None and sec <= 180,   # 3分以内をショートとみなす
                "parent": parent,                             # 説明文にある本編の ID（ショート用）
                "manga_ep": (lambda m: int(m.group(1)) if m else None)(re.search(r"/manga/ep(\d+)", sn.get("description", ""))),
                                                              # 説明文にあるマンガの話の番号（ショート専用のマンガのショート。2026-10-24・SNS の予約に使う）
                "url": f"https://www.youtube.com/watch?v={v['id']}",
            })
    n_priv = sum(1 for v in out if v["privacy"] != "public")
    if not owner and n_priv == 0:
        print("注意: 許可したアカウントが Pixels to Motors の持ち主ではないので、公開予約中の動画は読めていません。--login で持ち主のアカウント（とチャンネル）を選び直してください")
    data = {"作成": datetime.now(JST).strftime("%Y-%m-%d %H:%M"), "チャンネル": ch["items"][0]["snippet"]["title"],
            "予約まで読めた": bool(owner or n_priv), "動画": out}
    with open(OUT, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=1)
    n_s = sum(1 for v in out if v["publishAt"] and v["privacy"] != "public")
    print(f"{data['作成']} {len(out)} 本を書き出しました（うち公開予約 {n_s} 本）: {OUT}")


def install():
    os.makedirs(os.path.dirname(PLIST), exist_ok=True)
    os.makedirs(CONF, exist_ok=True)
    plist = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>{LABEL}</string>
  <key>ProgramArguments</key><array><string>{sys.executable}</string><string>{os.path.abspath(__file__)}</string></array>
  <key>StartCalendarInterval</key><dict><key>Minute</key><integer>5</integer></dict>
  <key>RunAtLoad</key><true/>
  <key>StandardOutPath</key><string>{LOG}</string>
  <key>StandardErrorPath</key><string>{LOG}</string>
</dict></plist>
"""
    with open(PLIST, "w") as f:
        f.write(plist)
    subprocess.run(["launchctl", "unload", PLIST], capture_output=True)
    subprocess.run(["launchctl", "load", PLIST], check=True)
    print("1時間ごとに書き出すように登録しました。うまく動いているかは次のファイルで分かります:", LOG)


def uninstall():
    subprocess.run(["launchctl", "unload", PLIST], capture_output=True)
    if os.path.exists(PLIST):
        os.remove(PLIST)
    print("自動の登録を外しました")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--login", action="store_true")
    ap.add_argument("--install", action="store_true")
    ap.add_argument("--uninstall", action="store_true")
    a = ap.parse_args()
    if a.uninstall:
        uninstall()
    elif a.install:
        install()
    else:
        if a.login:
            login()
        fetch()
