Ran Phase 0 against a live key and hit both of the ways a fresh Google Cloud
project can be wrong, in sequence. Google reports them as the same 403
`forbidden`, so the first version of this script printed reason='forbidden'
three times and buried the one sentence that said what to do.
The distinguishing signal is in error.details[].reason, not
error.errors[].reason:
SERVICE_DISABLED YouTube Data API v3 is not enabled on the project.
Carries an activationUrl naming the project number.
API_KEY_SERVICE_BLOCKED The API is enabled, but this key's API restrictions
exclude it.
They are fixed on different console screens, so they are now separate exception
types with separate advice, and a one-call preflight reports either before the
three real checks run and fail identically.
The ordering between them is a trap worth writing down: YouTube Data API v3 does
not appear in a key's API-restriction picker until the API is enabled on the
project, so creating the key and restricting it first yields a key that blocks
the only API it exists for. That is precisely what happened here. §4.1 step 4
now says to enable before restricting.
Nothing has yet reached YouTube's own privacy check, so whether the brother's
subscriptions are readable is still untested — every call so far failed at the
key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
13 KiB
Python
Executable File
316 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Phase 0 verification: everything the plan assumes about the YouTube Data API.
|
|
|
|
The plan (§13 Phase 0) rests on three answers that can only come from a live
|
|
key. This runs all three, costs about 5 quota units of the 10,000/day budget,
|
|
and prints the numbers to paste back into plan.md.
|
|
|
|
python3 tools/verify_api.py --key AIza...
|
|
|
|
Or, once the key is in the settings table:
|
|
|
|
python3 tools/verify_api.py --key "$(sqlite3 /var/lib/ytstream/ytstream.db \
|
|
"select value from setting where key='youtube_api_key'")"
|
|
|
|
Exit status is 0 only if all three checks pass, so this is safe to gate on.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
API = "https://www.googleapis.com/youtube/v3"
|
|
|
|
# The account being mirrored — @cflux1030, resolved via yt-dlp on 2026-08-12.
|
|
BROTHER = "UCPcTWaLV8zwx4WP4QExHj4Q"
|
|
|
|
# A channel with a known-large back catalogue, used to exercise pagination.
|
|
SAMPLE_CHANNEL = "UCjCJ2LaOIsPzOoXUTMDI3wg" # Pitch Side
|
|
|
|
ISO8601 = re.compile(
|
|
r"^P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$"
|
|
)
|
|
|
|
|
|
class ApiError(Exception):
|
|
def __init__(self, status, reason, body, message="", activation_url=""):
|
|
super().__init__(f"HTTP {status} {reason}")
|
|
self.status = status
|
|
self.reason = reason
|
|
self.body = body
|
|
self.message = message
|
|
self.activation_url = activation_url
|
|
|
|
|
|
class ServiceDisabled(ApiError):
|
|
"""YouTube Data API v3 is not enabled on the key's project.
|
|
|
|
Signalled by `accessNotConfigured` in errors[] or `SERVICE_DISABLED` in
|
|
details[], and carries an activationUrl naming the project number.
|
|
"""
|
|
|
|
|
|
class KeyRestricted(ApiError):
|
|
"""The API is enabled, but this key's API restrictions exclude it.
|
|
|
|
Signalled by `API_KEY_SERVICE_BLOCKED` in details[] with the message
|
|
"Requests to this API youtube method ... are blocked". Distinct from
|
|
ServiceDisabled and fixed in a completely different console screen, which is
|
|
why the two are separate types.
|
|
|
|
Both were observed from one key on 2026-08-12, in this order, and the
|
|
ordering is the trap: YouTube Data API v3 does not appear in the key's API
|
|
restriction picker until the API is enabled on the project. Enable first,
|
|
restrict second, or the key is created blocking the very API it is for.
|
|
"""
|
|
|
|
|
|
def call(endpoint: str, key: str, **params) -> dict:
|
|
"""One API call. Raises ApiError (or ServiceDisabled) on 4xx/5xx."""
|
|
params["key"] = key
|
|
url = f"{API}/{endpoint}?" + urllib.parse.urlencode(params)
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=30) as response:
|
|
return json.load(response)
|
|
except urllib.error.HTTPError as exc:
|
|
raw = exc.read().decode("utf8", "replace")
|
|
reason = message = activation = ""
|
|
detail_reasons = set()
|
|
try:
|
|
error = json.loads(raw).get("error", {})
|
|
message = error.get("message", "")
|
|
errors = error.get("errors", [])
|
|
reason = errors[0].get("reason", "") if errors else ""
|
|
for detail in error.get("details", []):
|
|
meta = detail.get("metadata") or {}
|
|
if meta.get("activationUrl"):
|
|
activation = meta["activationUrl"]
|
|
if detail.get("reason"):
|
|
detail_reasons.add(detail["reason"])
|
|
except ValueError:
|
|
pass
|
|
|
|
# `forbidden` is used for both of these, so the details[] reason is what
|
|
# actually distinguishes them. Check the specific ones before it.
|
|
if "API_KEY_SERVICE_BLOCKED" in detail_reasons:
|
|
cls = KeyRestricted
|
|
elif ("SERVICE_DISABLED" in detail_reasons
|
|
or reason == "accessNotConfigured"
|
|
or "has not been used in project" in message):
|
|
cls = ServiceDisabled
|
|
else:
|
|
cls = ApiError
|
|
raise cls(exc.code, reason or next(iter(detail_reasons), ""),
|
|
raw[:400], message, activation) from exc
|
|
|
|
|
|
def iso8601_seconds(text: str) -> int | None:
|
|
"""PT1H2M3S -> 3723. Returns None for anything unparseable."""
|
|
match = ISO8601.match(text or "")
|
|
if not match:
|
|
return None
|
|
days, hours, minutes, seconds = (int(g or 0) for g in match.groups())
|
|
return days * 86400 + hours * 3600 + minutes * 60 + seconds
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
results: list[tuple[str, bool, str]] = []
|
|
|
|
|
|
def record(name: str, ok: bool, detail: str) -> None:
|
|
print(f" {'PASS' if ok else 'FAIL'} {name}")
|
|
for line in detail.splitlines():
|
|
print(f" {line}")
|
|
results.append((name, ok, detail))
|
|
|
|
|
|
def check_subscriptions(key: str, channel_id: str) -> None:
|
|
"""The one that decides whether the whole feature is possible."""
|
|
print("\n1. subscriptions.list on the mirrored account")
|
|
try:
|
|
page = call("subscriptions", key, part="snippet",
|
|
channelId=channel_id, maxResults=50)
|
|
except ApiError as exc:
|
|
if exc.status == 403 and exc.reason == "subscriptionForbidden":
|
|
record("subscriptions readable", False,
|
|
"403 subscriptionForbidden -- subscriptions are still PRIVATE.\n"
|
|
"Fix: youtube.com -> Settings -> Privacy -> uncheck\n"
|
|
'"Keep all my subscriptions private". There is no workaround;\n'
|
|
"the public HTML route no longer exists (plan.md §15).")
|
|
else:
|
|
record("subscriptions readable", False,
|
|
f"{exc} reason={exc.reason!r}\n{exc.body}")
|
|
return
|
|
|
|
total = (page.get("pageInfo") or {}).get("totalResults")
|
|
items = page.get("items") or []
|
|
titles = [i["snippet"]["title"] for i in items]
|
|
|
|
# Paginate so the recorded total is the real one, not just page 1.
|
|
seen = len(items)
|
|
token = page.get("nextPageToken")
|
|
pages = 1
|
|
while token and pages < 20:
|
|
page = call("subscriptions", key, part="snippet", channelId=channel_id,
|
|
maxResults=50, pageToken=token)
|
|
batch = page.get("items") or []
|
|
seen += len(batch)
|
|
titles.extend(i["snippet"]["title"] for i in batch)
|
|
token = page.get("nextPageToken")
|
|
pages += 1
|
|
|
|
suggested = max(10, -(-int(total or seen) // 5)) # ceil(total * 0.2)
|
|
record("subscriptions readable", True,
|
|
f"totalResults = {total}, fetched {seen} across {pages} page(s)\n"
|
|
f"-> set subsync_max_new = {suggested} [max(10, ceil(total*0.2))]\n"
|
|
f"first few: {', '.join(titles[:8])}"
|
|
+ (" ..." if len(titles) > 8 else ""))
|
|
|
|
|
|
def check_uploads_playlist(key: str, channel_id: str) -> None:
|
|
"""Does playlistItems.list accept the undocumented UULF playlist id?"""
|
|
print("\n2. playlistItems.list on UULF (long-form-only) vs UU (documented)")
|
|
outcome = {}
|
|
for kind, playlist_id in (("UULF", "UULF" + channel_id[2:]),
|
|
("UU", "UU" + channel_id[2:])):
|
|
try:
|
|
page = call("playlistItems", key, part="contentDetails",
|
|
playlistId=playlist_id, maxResults=5)
|
|
except ApiError as exc:
|
|
outcome[kind] = (False, f"{exc} reason={exc.reason!r}")
|
|
continue
|
|
items = page.get("items") or []
|
|
stamps = [i["contentDetails"].get("videoPublishedAt") for i in items]
|
|
exact = all(s and s.endswith("Z") and "T" in s for s in stamps)
|
|
outcome[kind] = (
|
|
bool(items) and exact,
|
|
f"{len(items)} items, videoPublishedAt exact={exact}, "
|
|
f"e.g. {stamps[0] if stamps else 'n/a'}, "
|
|
f"total={(page.get('pageInfo') or {}).get('totalResults')}",
|
|
)
|
|
|
|
uulf_ok, uulf_detail = outcome["UULF"]
|
|
uu_ok, uu_detail = outcome["UU"]
|
|
if uulf_ok:
|
|
record("uploads playlist usable", True,
|
|
f"UULF WORKS -> use it, existing Shorts/livestream filtering carries over\n"
|
|
f" UULF: {uulf_detail}\n UU: {uu_detail}")
|
|
elif uu_ok:
|
|
record("uploads playlist usable", True,
|
|
f"UULF REJECTED -> take the UU fallback and filter by duration +\n"
|
|
f"liveStreamingDetails (plan.md §3)\n"
|
|
f" UULF: {uulf_detail}\n UU: {uu_detail}")
|
|
else:
|
|
record("uploads playlist usable", False,
|
|
f"neither worked\n UULF: {uulf_detail}\n UU: {uu_detail}")
|
|
|
|
|
|
def check_durations(key: str, channel_id: str) -> None:
|
|
"""Durations for a batch of ids -- what feeds <durationinseconds>."""
|
|
print("\n3. videos.list durations for a batch of ids")
|
|
try:
|
|
listing = call("playlistItems", key, part="contentDetails",
|
|
playlistId="UU" + channel_id[2:], maxResults=50)
|
|
except ApiError as exc:
|
|
record("durations available", False, f"could not list ids: {exc}")
|
|
return
|
|
|
|
ids = [i["contentDetails"]["videoId"] for i in listing.get("items") or []]
|
|
if not ids:
|
|
record("durations available", False, "no video ids to test with")
|
|
return
|
|
|
|
try:
|
|
page = call("videos", key, part="contentDetails",
|
|
id=",".join(ids), maxResults=50)
|
|
except ApiError as exc:
|
|
record("durations available", False, f"{exc} reason={exc.reason!r}")
|
|
return
|
|
|
|
items = page.get("items") or []
|
|
parsed = [(i["id"], iso8601_seconds(i["contentDetails"].get("duration", "")))
|
|
for i in items]
|
|
bad = [vid for vid, secs in parsed if secs is None]
|
|
shorts = [vid for vid, secs in parsed if secs is not None and secs <= 120]
|
|
record("durations available", not bad and len(items) == len(ids),
|
|
f"asked for {len(ids)} ids in 1 call, got {len(items)} back, "
|
|
f"{len(bad)} unparseable\n"
|
|
f"{len(shorts)} of {len(items)} are <=120s (would be filtered as Shorts)\n"
|
|
f"sample: " + ", ".join(f"{v}={s}s" for v, s in parsed[:5]))
|
|
|
|
|
|
def preflight(key: str) -> None:
|
|
"""One cheap call to separate "project not set up" from "assumption wrong".
|
|
|
|
Without this, a disabled API fails all three checks with three different
|
|
messages and none of them says what to do about it.
|
|
"""
|
|
try:
|
|
call("videos", key, part="id", id="dQw4w9WgXcQ")
|
|
except ServiceDisabled as exc:
|
|
print("\nSTOP: the key is valid, but YouTube Data API v3 is not enabled "
|
|
"on its project.")
|
|
print(f"\n {exc.message}\n")
|
|
if exc.activation_url:
|
|
print(" Enable it here, then wait ~2 minutes and re-run:")
|
|
print(f" {exc.activation_url}\n")
|
|
print(" Creating an API key and enabling the API are separate steps in "
|
|
"the console;\n only the first one has been done.")
|
|
raise SystemExit(2)
|
|
except KeyRestricted as exc:
|
|
project = ""
|
|
match = re.search(r"projects?/(\d+)", exc.body) or \
|
|
re.search(r"project=(\d+)", exc.body)
|
|
if match:
|
|
project = f"?project={match.group(1)}"
|
|
print("\nSTOP: the API is enabled, but this key's API restrictions block "
|
|
"it (API_KEY_SERVICE_BLOCKED).")
|
|
print(f"\n {exc.message}\n")
|
|
print(" Fix: Credentials -> click the key -> API restrictions -> either")
|
|
print(' "Don\'t restrict key", or tick "YouTube Data API v3" in the list.')
|
|
print(f" https://console.cloud.google.com/apis/credentials{project}\n")
|
|
print(" Note the ordering trap: YouTube Data API v3 is absent from that")
|
|
print(" picker until the API is enabled on the project, so a key created")
|
|
print(" and restricted first ends up blocking the API it was made for.")
|
|
raise SystemExit(2)
|
|
except ApiError as exc:
|
|
if exc.status in (400, 403) and exc.reason in ("badRequest", "keyInvalid",
|
|
"API_KEY_INVALID"):
|
|
print(f"\nSTOP: the key was rejected -- {exc.reason}: {exc.message}")
|
|
raise SystemExit(2)
|
|
# Anything else is worth letting the real checks characterise.
|
|
print(f"\n (preflight warning: {exc} reason={exc.reason!r})")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--key", required=True, help="YouTube Data API v3 key")
|
|
parser.add_argument("--brother", default=BROTHER,
|
|
help=f"channel id whose subscriptions to read (default {BROTHER})")
|
|
parser.add_argument("--sample", default=SAMPLE_CHANNEL,
|
|
help="channel id to exercise the playlist/duration calls against")
|
|
args = parser.parse_args()
|
|
|
|
print(f"Phase 0 verification against the live API (~5 quota units of 10,000/day)")
|
|
preflight(args.key)
|
|
check_subscriptions(args.key, args.brother)
|
|
check_uploads_playlist(args.key, args.sample)
|
|
check_durations(args.key, args.sample)
|
|
|
|
failed = [name for name, ok, _ in results if not ok]
|
|
print()
|
|
if failed:
|
|
print(f"{len(failed)} of {len(results)} FAILED: {', '.join(failed)}")
|
|
return 1
|
|
print(f"ALL {len(results)} PASS -- paste the numbers above into plan.md §4.1 and §14")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|