ytstats 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ ---
2
+ description: Credential and OAuth traps — service accounts, weekly token expiry, silent browser failures, and refresh-token preservation.
3
+ tags: [auth, oauth, credentials, tokens, gotchas]
4
+ source:
5
+ - src/auth/**
6
+ ---
7
+
8
+ # Authentication Gotchas
9
+
10
+ Traps in the bring-your-own-credentials OAuth model, and where `ytstats` defends against them.
11
+
12
+ Related: [auth.md](../auth.md) for the full flow, [config-storage.md](config-storage.md) for how the resulting tokens are written.
13
+
14
+ ## Service accounts can never be used
15
+
16
+ A platform limitation with no workaround. A service account owns no YouTube channel and there is no way to link one, so Google rejects the flow with `NoLinkedYouTubeAccount`. Domain-wide delegation does not help — it is itself a service-account mechanism.
17
+
18
+ > "the YouTube Reporting API and YouTube Analytics API do not support this flow.
19
+ > Since there is no way to link a Service Account to a YouTube account, attempts to
20
+ > authorize requests with this flow will generate an error."
21
+ > — [Google's authorization guide](https://developers.google.com/youtube/reporting/guides/authorization)
22
+
23
+ The only service-account-based YouTube API is the Content ID API, which is partner-only and does not cover single-channel analytics.
24
+
25
+ **Where handled:** `parseClientSecret()` in `src/auth/credentials.js` detects `type: 'service_account'` and fails with `AUTH_SERVICE_ACCOUNT`, marked `recoverable: false` so an agent stops instead of retrying forever.
26
+
27
+ ## Testing-mode consent screens expire tokens weekly
28
+
29
+ While an OAuth consent screen is in **Testing** status, Google expires refresh tokens after **7 days**. The symptom is re-authenticating every week, surfacing as `invalid_grant`.
30
+
31
+ Publishing the consent screen to Production removes the expiry. Verification is not required for personal use — the user clicks past a one-time "unverified app" warning.
32
+
33
+ **Where handled:** `invalid_grant` maps to `AUTH_TOKEN_EXPIRED` in `diagnoseGoogleError()` (`src/errors.js`), whose `cause` names Testing mode as the likely root cause rather than saying "log in again".
34
+
35
+ ## A bad client ID fails in the browser, not the API
36
+
37
+ An invalid or malformed client ID produces **no API error**. Google renders "Access blocked: Authorization Error" in the browser and never redirects, so the loopback listener waits until it times out. The resulting `AUTH_TIMEOUT` is misleading: retrying can never help.
38
+
39
+ **Where handled:** `validateClientId()` in `src/auth/credentials.js` runs before any browser opens, turning a five-minute hang into an instant `AUTH_CLIENT_ID_INVALID`. `AUTH_TIMEOUT` also names "Access blocked" as the leading cause and is marked `retryable: false`.
40
+
41
+ ## A merely unusual client ID must not lock anyone out
42
+
43
+ `validateClientId()` has two tiers, deliberately. An ID that does not end in `.apps.googleusercontent.com` **throws** — it cannot possibly work. An ID with the right suffix but not the canonical `<project-number>-<hash>` shape only **returns a warning diagnostic** (`AUTH_CLIENT_ID_SUSPICIOUS`), because legacy clients occasionally deviate and a false positive here would block a working setup.
44
+
45
+ Do not tighten the regex into a hard failure without evidence that the deviant shape is genuinely unusable.
46
+
47
+ **Where handled:** `CANONICAL_CLIENT_ID` and the two-tier return in `src/auth/credentials.js`.
48
+
49
+ ## Google omits refresh_token on refresh, so saving must merge
50
+
51
+ A refresh response contains a new `access_token` but **no** `refresh_token`. Writing the response over the stored tokens would therefore destroy the long-lived credential and force a re-login on the next run.
52
+
53
+ `saveAccount()` merges instead of replacing: `tokens: { ...(existing?.tokens ?? {}), ...tokens }`. The `tokens` event handler registered in `getAuthenticatedClient()` relies on this, since it fires with exactly those partial refresh payloads.
54
+
55
+ **Where handled:** `saveAccount()` in `src/auth/tokens.js`; the `client.on('tokens', …)` handler in `src/auth/session.js`.
56
+
57
+ ## The client secret must be stored, not discarded after login
58
+
59
+ Google's token endpoint requires the client secret on **every** refresh, not just the initial code exchange. Treating it as single-use — a reasonable instinct for a secret — breaks every subsequent command.
60
+
61
+ **Where handled:** `login()` calls `saveCredentials()` alongside `saveAccount()` in `src/auth/session.js`, and `getAuthenticatedClient()` resolves credentials before loading tokens.
62
+
63
+ ## Channel identity is fetched before anything is persisted
64
+
65
+ If the identity lookup were done after saving, a failed lookup would leave a half-written account with tokens but no channel id — and `saveAccount()` throws on a missing channel id, so the failure mode is a corrupt store rather than a clean error.
66
+
67
+ `login()` exchanges the code, fetches identity, and only then calls `saveCredentials()` + `saveAccount()`. Keep that ordering.
68
+
69
+ **Where handled:** `login()` in `src/auth/session.js`.
70
+
71
+ ## Credentials are diagnosed before tokens
72
+
73
+ `getAuthenticatedClient()` resolves credentials *first*, then loads the account. The ordering is deliberate: telling someone who has not yet created a Google Cloud project to "run `ytstats login`" sends them down a path that cannot succeed. They need `AUTH_NO_CREDENTIALS`, not `AUTH_NO_TOKENS`.
74
+
75
+ **Where handled:** `getAuthenticatedClient()` in `src/auth/session.js`.
76
+
77
+ ## An unknown --account never falls back to the default
78
+
79
+ `loadAccount(selector)` returns `null` for an unrecognised selector rather than quietly returning the default account. A silent fallback would answer a question about channel A with channel B's data — wrong numbers presented as correct ones.
80
+
81
+ The caller turns that `null` into `AUTH_ACCOUNT_UNKNOWN`, listing the channels that *are* signed in.
82
+
83
+ **Where handled:** `loadAccount()` in `src/auth/tokens.js`; the `AUTH_ACCOUNT_UNKNOWN` branch in `getAuthenticatedClient()`.
84
+
85
+ ## The loopback server binds 127.0.0.1, never 0.0.0.0
86
+
87
+ Binding `0.0.0.0` would expose the callback listener — and therefore the authorization code — to anything on the local network. The server binds `127.0.0.1` on an ephemeral port (`server.listen(0, '127.0.0.1')`).
88
+
89
+ The state comparison is constant-time (`crypto.timingSafeEqual`) so the value cannot be probed byte by byte, and the success page deliberately contains no code or token material. A test asserts the success page never contains the authorization code.
90
+
91
+ **Where handled:** `startLoopbackServer()` in `src/auth/oauth.js`.
92
+
93
+ ## The favicon request is not the callback
94
+
95
+ Browsers request `/favicon.ico` unprompted when the callback page loads. Treating any request as the callback would settle the promise on the wrong one.
96
+
97
+ The handler 404s `/favicon.ico` and anything that is not `/` or `/callback` before inspecting query parameters. A test covers this.
98
+
99
+ **Where handled:** the pathname guards in `startLoopbackServer()`, `src/auth/oauth.js`.
100
+
101
+ ## --no-browser uses a deliberately dead redirect URI
102
+
103
+ The paste flow sets `redirectUri` to `http://127.0.0.1:1` and starts **no** listener. Port 1 is chosen so the browser's redirect fails immediately and visibly, leaving the URL — including `?code=…` — in the address bar for the user to copy.
104
+
105
+ The same `redirect_uri` must then be sent to the token exchange, since Google validates that it matches the one used in the authorization request.
106
+
107
+ **Where handled:** `pasteFlow()` in `src/auth/session.js`.
108
+
109
+ ## Logout removes local tokens even when revocation fails
110
+
111
+ Revoking with Google is best-effort: it is wrapped in `try`/`catch` and the loop continues. If the machine is offline or the token is already invalid, revocation fails but the local removal still happens — otherwise `logout` would leave credentials on disk while reporting an error, which is the worst of both outcomes.
112
+
113
+ Resolving credentials for the revocation call is likewise optional; without them `ytstats` cannot revoke, but it can still forget.
114
+
115
+ **Where handled:** `logout()` in `src/auth/session.js`.
116
+
117
+ ## A read-only config dir must not break a working command
118
+
119
+ The `tokens` event handler that persists refreshed tokens swallows its errors. If the config directory has become unwritable, the in-memory client still holds a valid access token and the command can complete — failing the whole run over a cache write would be a worse outcome than losing the refreshed token.
120
+
121
+ **Where handled:** the empty `catch` in the `client.on('tokens', …)` handler, `src/auth/session.js`.
@@ -0,0 +1,155 @@
1
+ ---
2
+ description: Traps in the CLI wiring and the output envelope — Commander overrides, severity forcing, validation ordering, and redaction.
3
+ tags: [cli, output, diagnostics, commander, gotchas]
4
+ source:
5
+ - src/cli.js
6
+ - src/output.js
7
+ - src/errors.js
8
+ - src/diagnostics.js
9
+ ---
10
+
11
+ # CLI and Output Gotchas
12
+
13
+ Why the CLI wiring and the envelope look the way they do. Most entries here exist because the obvious implementation violates the output contract.
14
+
15
+ Related: [output-contract.md](../output-contract.md) for the envelope schema, [cli.md](../cli.md) for the command surface.
16
+
17
+ ## Commander's default behaviour writes nothing to stdout
18
+
19
+ On a usage error Commander prints prose to stderr and calls `process.exit`, leaving stdout **empty**. An agent parsing stdout gets nothing at all — which breaks the contract's central promise that every code path emits one JSON document.
20
+
21
+ Two overrides fix this and both are load-bearing:
22
+
23
+ ```js
24
+ program.exitOverride(); // throw instead of process.exit
25
+ program.configureOutput({
26
+ writeErr: () => {}, // suppressed; re-emitted as a diagnostic
27
+ writeOut: s => stdout(s.replace(/\n$/, '')),
28
+ });
29
+ ```
30
+
31
+ `exitOverride()` converts the exit into a throw that `main()` catches and routes through `diagnoseCommanderError()`. Removing either override silently reintroduces the empty-stdout failure.
32
+
33
+ **Where handled:** `buildProgram()` in `src/cli.js`.
34
+
35
+ ## --help and --version arrive as thrown errors
36
+
37
+ Because of `exitOverride()`, a successful `--help` or `--version` reaches `main()`'s `catch` block like any failure. Treating everything caught there as an error would make help exit non-zero and emit an error envelope.
38
+
39
+ `main()` checks for `commander.helpDisplayed`, `commander.help`, and `commander.version` first and returns `EXIT.OK`.
40
+
41
+ **Where handled:** `main()` in `src/cli.js`.
42
+
43
+ ## Global flags are not known when the reporter is first built
44
+
45
+ `createReporter()` is called once at build time, before `--compact` and `--quiet` have been parsed. Using that first reporter for output would ignore both flags.
46
+
47
+ A `preAction` hook rebuilds the reporter once global options are available. In `main()`'s error path — where parsing may never have reached the hook — the flags are instead sniffed directly out of `argv`.
48
+
49
+ **Where handled:** the `preAction` hook in `buildProgram()`, and the `argv.includes('--compact')` sniffing in `main()`.
50
+
51
+ ## warn() forces severity to warning, and must
52
+
53
+ `reporter.warn()` overwrites the diagnostic's `severity` with `SEVERITY.WARNING`. Routing a diagnostic through `warn()` *is* the decision that it is non-fatal, so the definition's own severity is not authoritative there.
54
+
55
+ Without this, `doctor` — whose entire job is to report the auth problems it found — would emit `ok: true` while exiting 2, because `exitCodeFor()` reads severity. That combination is incoherent for a consumer.
56
+
57
+ **Where handled:** `warn()` in `createReporter()`, `src/output.js`.
58
+
59
+ ## data is null on failure, never partial
60
+
61
+ When `ok` is false, `data` is set to `null` — not to whatever was collected before the failure. A consumer that reads `data` without checking `ok` would otherwise act on half a dataset and never know.
62
+
63
+ ```js
64
+ data: ok ? (data ?? null) : null,
65
+ ```
66
+
67
+ **Where handled:** `renderEnvelope()` in `src/output.js`.
68
+
69
+ ## Errors carrying warning severity are re-sorted into warnings
70
+
71
+ `renderEnvelope()` does not trust the caller's error/warning split. It partitions both input lists by each diagnostic's actual `severity`, so a warning-severity diagnostic passed in `errors` lands in `warnings` and does not make `ok` false.
72
+
73
+ This is why `reporter.fail()` can pass `[...list, ...warningsBuffer]` as one array without corrupting `ok`.
74
+
75
+ **Where handled:** `errorList` / `warningList` construction in `renderEnvelope()`, `src/output.js`.
76
+
77
+ ## Input is validated before authentication
78
+
79
+ Every `run()` wrapper executes its `validate` callback before touching the network. Checking auth first would hide a malformed date behind a login error, costing an agent an extra round trip to discover the second problem.
80
+
81
+ `validateRange()` also collects *every* problem rather than throwing on the first, so `--start 01/01/2026 --end yesterday --days -3` reports three diagnostics in one envelope.
82
+
83
+ **Where handled:** the `run()` wrapper and `validateRange()` in `src/cli.js`.
84
+
85
+ ## The range check is skipped when a date is already invalid
86
+
87
+ `validateRange()` only compares `start > end` when `problems` is still empty. Comparing an unparseable date string would produce a second, misleading `INPUT_INVALID_RANGE` on top of the real `INPUT_INVALID_DATE` — two diagnostics for one mistake.
88
+
89
+ **Where handled:** the `if (!problems.length && …)` guard in `validateRange()`, `src/cli.js`.
90
+
91
+ ## Date validation exists in two places, deliberately
92
+
93
+ `validateRange()` in `src/cli.js` collects problems as diagnostics without throwing; `assertIsoDate()` in `src/dates.js` throws `YtStatsError`. They overlap on purpose — the CLI needs to report all problems at once, while `resolveDateRange()` must stay safe for library callers who never went through the CLI.
94
+
95
+ Changing one without the other lets a bad date through one entry point. `src/dates.js` additionally enforces `MAX_DAYS` (3650), which the CLI validator does not check.
96
+
97
+ **Where handled:** `validateRange()` in `src/cli.js`, `assertIsoDate()` and `resolveDateRange()` in `src/dates.js`.
98
+
99
+ ## Redaction deliberately spares the "code" field
100
+
101
+ The redaction patterns strip Google client secrets, access tokens, refresh tokens, and authorization codes. They deliberately do **not** match a bare `"code"` JSON field, because every diagnostic carries `"code": "AUTH_NO_TOKENS"` — public API that must survive redaction.
102
+
103
+ Broadening the pattern to catch `"code"` generically would blank out the one field consumers branch on.
104
+
105
+ **Where handled:** `SECRET_PATTERNS` in `src/errors.js`, with a comment marking the exclusion.
106
+
107
+ ## Redaction runs on the serialized JSON, not the object
108
+
109
+ `renderEnvelope()` calls `redact(json)` after `JSON.stringify`, so redaction sees the final text including any secret that leaked into a nested `detail` string. Redacting the object first would miss anything embedded in prose.
110
+
111
+ The reporter applies `redact()` to every stderr write for the same reason.
112
+
113
+ **Where handled:** `renderEnvelope()` and `createReporter()` in `src/output.js`.
114
+
115
+ ## Google error classification is order-dependent
116
+
117
+ `diagnoseGoogleError()` checks specific signals — network error codes, reason strings, message fingerprints — **before** generic HTTP status buckets. A 403 alone can mean four unrelated things (`accessNotConfigured`, `quotaExceeded`, `NoLinkedYouTubeAccount`, plain forbidden), so reordering the checks silently degrades precise diagnostics into `API_FORBIDDEN`.
118
+
119
+ `invalid_grant` is checked before the generic 401 branch for the same reason: it has a specific, actionable cause that "token expired" alone does not convey.
120
+
121
+ **Where handled:** the ordered `if` chain in `diagnoseGoogleError()`, `src/errors.js`.
122
+
123
+ ## An empty result set is reported explicitly
124
+
125
+ An empty `rows` array is ambiguous — it could mean the query failed silently or that the channel genuinely had no activity. Every `simple()` command emits a `DATA_EMPTY` warning when the query succeeds with zero rows, so the caller can tell the two apart.
126
+
127
+ **Where handled:** the `rows.length === 0` branch in `simple()`, `src/cli.js`.
128
+
129
+ ## --no-retention is Commander's negation, so the option reads as `retention`
130
+
131
+ `.option('--no-retention', …)` makes Commander expose `cmdOpts.retention`, defaulting to `true` and becoming `false` when the flag is passed. There is no `cmdOpts.noRetention`. The same applies to `--no-browser`, read as `!cmdOpts.browser` in the `login` action.
132
+
133
+ **Where handled:** the `fetch` and `login` command definitions in `src/cli.js`.
134
+
135
+ ## Exit codes are also in the envelope
136
+
137
+ A consumer that can only see stdout — piped, captured, or read by an agent that never observed the process status — still needs the exit code. It is duplicated at `meta.exitCode`.
138
+
139
+ `exitCodeFor()` returns the **worst** code across all error-severity diagnostics, and `EXIT.OK` when there are none, which is why warnings never affect it.
140
+
141
+ **Where handled:** `exitCodeFor()` in `src/diagnostics.js`, surfaced via `meta.exitCode` in `renderEnvelope()`.
142
+
143
+ ## The bin shim is the last line of defence
144
+
145
+ `bin/ytstats.js` wraps `main()` in a final `.catch()` that writes to stderr and sets exit code 1. Its only job is to guarantee that an unanticipated throw never puts a stack trace on stdout, which is reserved for the JSON document.
146
+
147
+ **Where handled:** `bin/ytstats.js`.
148
+
149
+ ## Diagnostic detail is folded from context, so callers need not format it
150
+
151
+ `diagnose()` appends `Option: …`, `Received: …`, `Expected: …`, `Allowed: …`, `Step: …`, `Account: …`, and `Underlying: …` onto `detail` from whatever context keys were supplied. A caller reading only `detail` still learns exactly what was wrong.
152
+
153
+ Stack-like lines are stripped from context strings by `clean()` — diagnostics are prose, never traces.
154
+
155
+ **Where handled:** `diagnose()` and `clean()` in `src/diagnostics.js`.
@@ -0,0 +1,98 @@
1
+ ---
2
+ description: Traps in the per-user config store — atomic writes, permission windows, path traversal, and platform differences.
3
+ tags: [config, storage, permissions, security, gotchas]
4
+ source:
5
+ - src/config/**
6
+ ---
7
+
8
+ # Config and Storage Gotchas
9
+
10
+ Everything the config store holds is a secret — the OAuth client secret and refresh tokens. These entries explain why the write path is more careful than a `writeFileSync` would be.
11
+
12
+ Related: [configuration.md](../configuration.md) for the directory layout and env vars, [auth gotchas](auth.md) for what is stored.
13
+
14
+ ## The temp file is created *at* 0600, not chmod'd afterwards
15
+
16
+ `writeJson()` passes `{ mode: FILE_MODE }` to `fs.writeFileSync` rather than writing first and fixing permissions after. Between those two operations the secret would be briefly world-readable, and a concurrent reader can hit exactly that window.
17
+
18
+ ```js
19
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: FILE_MODE });
20
+ ```
21
+
22
+ **Where handled:** `writeJson()` in `src/config/store.js`.
23
+
24
+ ## The write is atomic via rename, so a crash never truncates tokens
25
+
26
+ Writing in place means a crash mid-write leaves a half-written token file that parses as invalid JSON — and the user is silently logged out. `writeJson()` writes to a unique temp file in the same directory and then `renameSync`s it over the target, which is atomic on POSIX filesystems.
27
+
28
+ The temp name includes the pid and six random bytes so two concurrent processes cannot collide. On failure the temp file is unlinked best-effort before the error is rethrown.
29
+
30
+ **Where handled:** `writeJson()` in `src/config/store.js`.
31
+
32
+ ## chmod runs again after the rename
33
+
34
+ `rename` preserves the temp file's mode, so the final file is already `0600`. The extra `chmodSync(target, FILE_MODE)` afterwards guards the case where the target pre-existed with looser permissions on a filesystem that does not behave as expected. It is belt-and-braces, not redundancy to remove.
35
+
36
+ Every `chmod` call in this file is wrapped in `try`/`catch` because Windows ignores POSIX modes and would otherwise throw.
37
+
38
+ **Where handled:** `writeJson()` in `src/config/store.js`.
39
+
40
+ ## The directory mode is asserted, not assumed
41
+
42
+ `fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE })` does not reliably produce `0700`: the mode is masked by the process umask, and the directory may already exist from an earlier run with different permissions.
43
+
44
+ `ensureDir()` therefore follows the mkdir with an explicit `chmodSync(dir, DIR_MODE)`.
45
+
46
+ **Where handled:** `ensureDir()` in `src/config/store.js`.
47
+
48
+ ## Unsafe filenames are rejected, not sanitised
49
+
50
+ `assertSafeName()` throws for anything that is not a flat basename — path separators, `.`, `..`, absolute paths, and embedded NUL. It does not strip or rewrite them.
51
+
52
+ Silently sanitising would hide a caller bug: code that passes `../../.ssh/id_rsa` has a defect, and rewriting it into `id_rsa` lets the defect ship. The throw is the point.
53
+
54
+ **Where handled:** `assertSafeName()` in `src/config/store.js`.
55
+
56
+ ## Windows relies on directory location, not file modes
57
+
58
+ POSIX modes are meaningless on Windows, so `0600` provides no protection there. The security property comes from the storage location instead: `%APPDATA%` is already per-user and roaming.
59
+
60
+ Do not "fix" the Windows path to a shared location on the assumption that the file modes are doing the work.
61
+
62
+ **Where handled:** the comment on `FILE_MODE` / `DIR_MODE` in `src/config/store.js`; the `win32` branch of `resolveConfigDir()` in `src/config/paths.js`.
63
+
64
+ ## readJson returns null for a corrupt file, not an error
65
+
66
+ Both the read and the `JSON.parse` are wrapped, and either failure yields `null`. A corrupt `tokens.json` therefore reads as "not signed in" rather than crashing the command.
67
+
68
+ The trade-off is deliberate but worth knowing: a truncated token file is indistinguishable from an absent one, so the user sees `AUTH_NO_TOKENS` and re-runs `login`, which repairs it. Callers must not treat `null` as proof the file does not exist.
69
+
70
+ **Where handled:** `readJson()` in `src/config/store.js`.
71
+
72
+ ## A relative XDG_CONFIG_HOME is ignored, per spec
73
+
74
+ The XDG Base Directory specification requires a relative `XDG_CONFIG_HOME` to be treated as unset. `resolveConfigDir()` implements this with `path.isAbsolute(xdg)` and falls back to `~/.config`.
75
+
76
+ `YTSTATS_CONFIG_DIR` behaves the *opposite* way: a relative value is accepted and resolved to absolute against the current working directory, since it is an explicit override rather than an environment convention.
77
+
78
+ **Where handled:** `resolveConfigDir()` in `src/config/paths.js`.
79
+
80
+ ## Config path resolution is pure so every OS can be tested from any OS
81
+
82
+ `resolveConfigDir({ platform, env, home })` takes all three inputs as parameters; only `configDir()` reads `process.platform`, `process.env`, and `os.homedir()`. This is what lets the test suite assert Windows and Linux behaviour while running on macOS.
83
+
84
+ Reading `process.*` directly inside `resolveConfigDir()` would make two thirds of that coverage impossible.
85
+
86
+ **Where handled:** `resolveConfigDir()` versus `configDir()` in `src/config/paths.js`.
87
+
88
+ ## listFiles filters in-flight temp files
89
+
90
+ `listFiles()` excludes names containing `.tmp-` so a concurrent write in progress is not reported as a stored file. Anything enumerating the config directory should apply the same filter rather than reading the raw directory listing.
91
+
92
+ **Where handled:** `listFiles()` in `src/config/store.js`.
93
+
94
+ ## removeAccount deletes the file when the last account goes
95
+
96
+ Leaving an empty `{ accounts: {} }` store behind would make `listAccounts()` cheap but leave a stale file implying state that no longer exists. `removeAccount()` unlinks `tokens.json` entirely once the last account is removed, and otherwise promotes an arbitrary remaining account to default.
97
+
98
+ **Where handled:** `removeAccount()` in `src/auth/tokens.js`.
@@ -0,0 +1,132 @@
1
+ ---
2
+ description: Non-obvious behaviour of the three YouTube APIs — metrics that never work, undocumented limits, and lag that cannot be removed.
3
+ tags: [youtube-api, analytics, reporting, quota, gotchas]
4
+ source:
5
+ - src/api/**
6
+ ---
7
+
8
+ # YouTube API Gotchas
9
+
10
+ What breaks in the YouTube Data, Analytics, and Reporting APIs, why, and where `ytstats` handles it. Each entry names the handling site so a future change does not silently undo a workaround.
11
+
12
+ Related: [auth gotchas](auth.md) for credential and token traps, [youtube-apis.md](../youtube-apis.md) for the full request reference.
13
+
14
+ ## CTR and impressions do not work on the Analytics API
15
+
16
+ The Analytics API documents `videoThumbnailImpressions` and `videoThumbnailImpressionsClickRate` as valid channel-report metrics. They do not work — every combination returns `The query is not supported.`
17
+
18
+ This is a [known Google issue](https://issuetracker.google.com/issues/254665034) affecting all channels regardless of size. The only working source is the Reporting API's `channel_reach_basic_a1` report, which is why `ytstats reach` exists at all and why it behaves unlike every other command.
19
+
20
+ **Where handled:** `src/api/reporting.js`. Never add these metrics to `src/api/analytics.js` — a test asserts they are absent.
21
+
22
+ ## Reporting API data is always 1-2 days behind
23
+
24
+ Reports cover midnight-to-midnight Pacific Time and are generated 1-2 days after the period closes, so `ytstats reach` never returns data for today or yesterday.
25
+
26
+ This is not a `ytstats` limitation. YouTube Studio shows the same lag, because real-time impression data does not exist anywhere.
27
+
28
+ ## The first reach run returns nothing for 24-48 hours
29
+
30
+ The first `ytstats reach` only *creates* the reporting job. Google then generates reports within 24-48 hours, including a 30-day backfill. Until then there is genuinely nothing to download.
31
+
32
+ This surfaces as the `REACH_PENDING` **warning**, not an error — the command succeeded, the data does not exist yet. Re-running is harmless and does not create a duplicate job, because `ensureReachJob()` looks for an existing job with the same `reportTypeId` before creating one.
33
+
34
+ **Where handled:** `fetchReach()` and `ensureReachJob()` in `src/api/reporting.js`.
35
+
36
+ ## Reach reports overlap, so rows must be deduped
37
+
38
+ Successive report files cover overlapping periods, and later files carry corrected figures for days already reported. Concatenating rows produces duplicates and stale numbers.
39
+
40
+ `fetchReach()` keys rows on `` `${date}|${videoId}` `` in a `Map` and lets the last write win, so corrections from later reports replace earlier figures.
41
+
42
+ **Where handled:** the `deduped` map in `fetchReach()`, `src/api/reporting.js`.
43
+
44
+ ## Per-video analytics rejects maxResults above 200
45
+
46
+ Queries with `dimensions=video` fail if `maxResults` exceeds 200. Channels with more than 200 videos get only the top 200 by the sort field (`-views`).
47
+
48
+ **Where handled:** `MAX_VIDEO_ROWS` in `src/api/analytics.js`, applied with `Math.min` so a caller cannot exceed it. A test asserts the clamp.
49
+
50
+ ## Traffic source detail queries are fragile in three separate ways
51
+
52
+ The `insightTrafficSourceDetail` dimension has three distinct traps:
53
+
54
+ 1. It **requires** both `sort` and `maxResults`. Without `maxResults` the query returns `The query is not supported.`
55
+ 2. With `maxResults` too high (50+) it returns `Internal error encountered.` The safe ceiling is **25**.
56
+ 3. Combining it with `estimatedMinutesWatched` also triggers an internal error. Only `views` is reliable.
57
+
58
+ **Where handled:** `MAX_DETAIL_ROWS` and the hard-coded `metrics: 'views'` in `fetchSearchTerms()` and `fetchTrafficSourceDetails()`, `src/api/analytics.js`. Tests assert all three.
59
+
60
+ ## Retention ratios legitimately exceed 1.0
61
+
62
+ `audienceWatchRatio` returns values above 1.0 for Shorts that viewers loop. A 25-second Short showing `1.54` at position 0 means 54% more viewing than the number of initial viewers.
63
+
64
+ This is a strong engagement signal, not a data error. `ytstats` never clamps it, and a test pins that behaviour so nobody "fixes" it later.
65
+
66
+ **Where handled:** `fetchAudienceRetention()` in `src/api/analytics.js` — note the deliberate absence of clamping.
67
+
68
+ ## Two content-type vocabularies disagree
69
+
70
+ | Source | Values |
71
+ |---|---|
72
+ | `classifyContent()`, duration-based | `SHORTS`, `VIDEO_ON_DEMAND`, `LIVE_STREAM` |
73
+ | YouTube's `creatorContentType` dimension | `shorts`, `videoOnDemand`, `creatorContentTypeUnspecified` |
74
+
75
+ Different casing *and* different semantics. The `ytstats` classifier uses duration alone (`<= 60s` is a Short), so a 62-second video intended as a Short reads as `VIDEO_ON_DEMAND`. YouTube uses additional signals and may disagree.
76
+
77
+ Consumers wanting YouTube's own opinion should read the `contentTypes` dataset rather than the `contentType` field on a video.
78
+
79
+ **Where handled:** `classifyContent()` in `src/api/transforms.js` (duration-based) versus `fetchContentTypes()` in `src/api/analytics.js` (YouTube's own).
80
+
81
+ ## Subscriber counts are rounded
82
+
83
+ Above 1,000 subscribers the Data API returns counts rounded to 3 significant figures — expect `1,020` or `10,400`, never exact values. Below 1,000 the count is exact. Week-over-week deltas smaller than the rounding step are invisible.
84
+
85
+ **Where handled:** nothing to handle, but `normalizeChannel()` in `src/api/transforms.js` carries a comment so the rounding is not mistaken for a bug.
86
+
87
+ ## Reporting API CSVs need a real CSV parser
88
+
89
+ Report bodies contain video titles and search terms, which routinely include commas and quotes. Splitting on `,` silently corrupts rows, and the corruption is quiet — it produces plausible-looking wrong data rather than an error.
90
+
91
+ **Where handled:** `parseCsv()` in `src/api/transforms.js` implements the RFC 4180 subset that matters: quoted fields, embedded commas and newlines, and `""` escapes.
92
+
93
+ ## Reporting API dates arrive in a different format
94
+
95
+ Report dates arrive as `20260328.0` while every other surface uses ISO `YYYY-MM-DD`. Passing them through unchanged would give consumers two date formats in one document.
96
+
97
+ `normalizeReportingDate()` unifies them. It passes ISO dates through untouched and returns the original value when it recognises neither form, so an unexpected format is visible rather than silently mangled.
98
+
99
+ **Where handled:** `normalizeReportingDate()` in `src/api/transforms.js`.
100
+
101
+ ## CSV cells with a leading zero must stay strings
102
+
103
+ Numeric-looking cells are coerced to numbers, but identifiers such as zero-padded codes would lose their leading zero and become the wrong value.
104
+
105
+ `coerce()` only converts values matching `/^-?(0|[1-9]\d*)(\.\d+)?$/` — a pattern that admits a bare `0` and `0.5` but rejects `007`, leaving it a string.
106
+
107
+ **Where handled:** `coerce()` in `src/api/transforms.js`.
108
+
109
+ ## search.list costs 100x what playlistItems.list costs
110
+
111
+ The Data API allows 10,000 quota units per project per day.
112
+
113
+ | Operation | Cost |
114
+ |---|---|
115
+ | `channels.list` | 1 |
116
+ | `playlistItems.list` | 1 per page of 50 |
117
+ | `videos.list` | 1 per batch of 50 |
118
+ | `search.list` | **100** |
119
+
120
+ `ytstats` enumerates videos through the channel's uploads playlist, never `search.list`. A full fetch for a 100-video channel costs roughly 5 units. A test asserts `search.list` is never called.
121
+
122
+ The expensive operation is retention: one Analytics call **per video**, which is why `fetch` caps it at 50 videos by default (`--retention-limit`) and offers `--no-retention`.
123
+
124
+ **Where handled:** `fetchAllVideoIds()` in `src/api/data.js`, and the retention cap in `src/fetch-all.js`.
125
+
126
+ ## Card metrics fail on some channels and are swallowed
127
+
128
+ `fetchCardMetrics()` is the one fetcher with its own `try`/`catch` returning `[]`. Some channels never have card/annotation data and the query fails outright rather than returning zero rows.
129
+
130
+ Because it degrades inside the fetcher, a card-metrics failure produces **no warning** in the envelope — unlike every other step, which degrades through `fetch-all.js`'s `step()` and is reported. Treat empty `annotationClickThroughRate` / `cardClicks` / `cardImpressions` fields as "unknown", not "zero".
131
+
132
+ **Where handled:** `fetchCardMetrics()` in `src/api/analytics.js`.
@@ -0,0 +1,10 @@
1
+ # Gotchas
2
+
3
+ Lessons learned the hard way so we don't repeat them.
4
+
5
+ Each domain file explains what breaks, why, and where `ytstats` handles it — so a future change does not silently undo a workaround.
6
+
7
+ - [YouTube API](gotchas/youtube-api.md) — metrics that never work, undocumented `maxResults` ceilings, reporting lag, quota traps, and vocabulary mismatches across the three APIs.
8
+ - [Authentication](gotchas/auth.md) — service accounts, weekly token expiry in Testing mode, client IDs that fail silently in the browser, and refresh-token preservation.
9
+ - [CLI and output](gotchas/cli-output.md) — Commander overrides that keep stdout parseable, severity forcing, validation ordering, and what redaction must not eat.
10
+ - [Config and storage](gotchas/config-storage.md) — atomic writes, permission windows, path-traversal rejection, and platform differences.