Tell the two API-key setup failures apart in verify_api.py

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>
This commit is contained in:
Tom Flux
2026-08-12 15:45:15 +01:00
co-authored by Claude Opus 5
parent 339232c7f9
commit f3d70f1c87
2 changed files with 123 additions and 12 deletions
+28 -7
View File
@@ -211,6 +211,9 @@ short of §4.2.
4. Restrict the key: Application restrictions → *None* (it is called from a server, so referrer and 4. Restrict the key: Application restrictions → *None* (it is called from a server, so referrer and
Android/iOS restrictions do not apply; an IP restriction is optional and breaks if susan's Android/iOS restrictions do not apply; an IP restriction is optional and breaks if susan's
residential IP rotates). API restrictions → **YouTube Data API v3 only**. residential IP rotates). API restrictions → **YouTube Data API v3 only**.
**Do step 2 before this step.** YouTube Data API v3 does not appear in the API-restriction picker
until it is enabled on the project, so restricting first produces a key that blocks the one API it
exists for — see §16, which is exactly what happened.
5. Paste it into the ytstream admin UI. It is stored in the `setting` table like 5. Paste it into the ytstream admin UI. It is stored in the `setting` table like
`jellyfin_api_key` already is — never in the repo, never in a systemd unit. `jellyfin_api_key` already is — never in the repo, never in a systemd unit.
@@ -778,6 +781,11 @@ Things already paid for once. All of these are verified.
spend an afternoon trying to scrape it; the API is the only route (§4). spend an afternoon trying to scrape it; the API is the only route (§4).
- **`pkill -f 'ytstream.py'` kills the shell that runs it**, because the command string contains its - **`pkill -f 'ytstream.py'` kills the shell that runs it**, because the command string contains its
own pattern. Bracket it: `pkill -f 'ytstrea[m].py'`. own pattern. Bracket it: `pkill -f 'ytstrea[m].py'`.
- **Google returns 403 `forbidden` for two unrelated setup mistakes**, and the useful signal is in
`error.details[].reason`, not `error.errors[].reason`. `SERVICE_DISABLED` means the API is not
enabled on the project (and carries an `activationUrl` naming the project number);
`API_KEY_SERVICE_BLOCKED` means the API is enabled but *this key's* restrictions exclude it. They
are fixed on different console screens. `tools/verify_api.py` distinguishes them and prints the fix.
--- ---
@@ -800,12 +808,24 @@ Things already paid for once. All of these are verified.
- **`tools/verify_api.py` written and self-tested** (ISO-8601 duration parser unit-checked against six - **`tools/verify_api.py` written and self-tested** (ISO-8601 duration parser unit-checked against six
cases; argparse and import verified). It runs all three API checks in one command. cases; argparse and import verified). It runs all three API checks in one command.
### Blocked on one thing only ### Key created, project 510818173753 — two setup steps deep, one to go
**There is no Google API key on this machine** — I searched for `AIza`-shaped strings across Progress on the key itself, all of it diagnosed from the error bodies:
`/opt/*`, `/home/susan`, the settings table and the config trees, and there is none. Creating one
needs a browser and Tom's Google account (§4.1, ~5 minutes, free, no billing). Until then these three 1. **Key created** and reaching Google — it authenticates, so the key string is good.
remain unverified: 2. **`SERVICE_DISABLED`** — YouTube Data API v3 was not enabled on project `510818173753`. Fixed by
enabling it.
3. **`API_KEY_SERVICE_BLOCKED`** ← *current state.* The API is now enabled, but the key's own API
restrictions exclude it, so every method returns "Requests to this API youtube method … are
blocked". Fix at
`https://console.cloud.google.com/apis/credentials?project=510818173753` → the key → API
restrictions → *Don't restrict key*, or tick YouTube Data API v3.
The ordering trap is worth remembering rather than rediscovering: the API must be enabled **before**
the key can be restricted to it, because it is absent from the picker until then. §4.1 step 4 now
says so.
Until the key answers, these three assumptions remain unverified:
| # | Assumption | Consequence if it fails | | # | Assumption | Consequence if it fails |
|---|---|---| |---|---|---|
@@ -814,8 +834,9 @@ remain unverified:
| 3 | `videos.list` returns parseable `contentDetails.duration` | No `<durationinseconds>` in NFOs without a yt-dlp extraction per video. Degrades, does not block. | | 3 | `videos.list` returns parseable `contentDetails.duration` | No `<durationinseconds>` in NFOs without a yt-dlp extraction per video. Degrades, does not block. |
Only #1 is a genuine blocker, and it also carries the number that sets `subsync_max_new`. Two Only #1 is a genuine blocker, and it also carries the number that sets `subsync_max_new`. Two
prerequisites, both external: **Tom creates the API key**, and **his brother unchecks "Keep all my external prerequisites remain: **the key's API restriction** (above), and **his brother unchecking
subscriptions private"**. Then one command closes Phase 0: "Keep all my subscriptions private"** — note that nothing so far has tested the second, because every
call has failed at the key before reaching YouTube's privacy check. Then one command closes Phase 0:
```sh ```sh
python3 /opt/ytstream/tools/verify_api.py --key AIza... python3 /opt/ytstream/tools/verify_api.py --key AIza...
+95 -5
View File
@@ -38,15 +38,40 @@ ISO8601 = re.compile(
class ApiError(Exception): class ApiError(Exception):
def __init__(self, status, reason, body): def __init__(self, status, reason, body, message="", activation_url=""):
super().__init__(f"HTTP {status} {reason}") super().__init__(f"HTTP {status} {reason}")
self.status = status self.status = status
self.reason = reason self.reason = reason
self.body = body 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: def call(endpoint: str, key: str, **params) -> dict:
"""One API call. Raises ApiError with the parsed reason on 4xx/5xx.""" """One API call. Raises ApiError (or ServiceDisabled) on 4xx/5xx."""
params["key"] = key params["key"] = key
url = f"{API}/{endpoint}?" + urllib.parse.urlencode(params) url = f"{API}/{endpoint}?" + urllib.parse.urlencode(params)
try: try:
@@ -54,13 +79,34 @@ def call(endpoint: str, key: str, **params) -> dict:
return json.load(response) return json.load(response)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf8", "replace") raw = exc.read().decode("utf8", "replace")
reason = "" reason = message = activation = ""
detail_reasons = set()
try: try:
errors = json.loads(raw).get("error", {}).get("errors", []) error = json.loads(raw).get("error", {})
message = error.get("message", "")
errors = error.get("errors", [])
reason = errors[0].get("reason", "") if errors else "" 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: except ValueError:
pass pass
raise ApiError(exc.code, reason, raw[:400]) from exc
# `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: def iso8601_seconds(text: str) -> int | None:
@@ -198,6 +244,49 @@ def check_durations(key: str, channel_id: str) -> None:
f"sample: " + ", ".join(f"{v}={s}s" for v, s in parsed[:5])) 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: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--key", required=True, help="YouTube Data API v3 key") parser.add_argument("--key", required=True, help="YouTube Data API v3 key")
@@ -208,6 +297,7 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
print(f"Phase 0 verification against the live API (~5 quota units of 10,000/day)") 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_subscriptions(args.key, args.brother)
check_uploads_playlist(args.key, args.sample) check_uploads_playlist(args.key, args.sample)
check_durations(args.key, args.sample) check_durations(args.key, args.sample)