YouTube channel stats — with the owner's own API key
prompts/pkg/llms/youtube.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks for a dashboard of their own YouTube channel — subscribers,
views, which videos are picking up — build it on the YouTube Data API v3
with a key the owner supplies. This is a backend.js feature (see the backend
docs for the general handler contract); this page is the recipe that makes the
key safe, the quota survivable, and the numbers honest about what they are.
This is the owner-dashboard pattern, and it has a boundary. The key is the owner's, the quota is the owner's, and every visitor sees the owner's channel. That is exactly right for "show me how my channel is doing" and exactly wrong for the same sentence asked by a visitor about theirs. An app where each person who opens it sees their own account is a connection — the platform renders the consent screen, runs the sign-in, holds the credential and refreshes it, and your code never sees a token (see the connections page). So never render an input asking a visitor to paste an access token, and never send a visitor to a developer console to mint one: that is the flow connections exists to replace. The console trip below is for the owner of this app, once, and for nobody else.
1. Bring your own key, and explain how
The owner gets a key from Google, once:
- Open the Google Cloud console and create or select a project.
- Go to APIs & Services, then Library, and enable YouTube Data API v3.
- Go to Credentials, then Create credentials, then API key.
- Restrict the key to the YouTube Data API v3.
- Set it on the vibe as
YOUTUBE_API_KEY— the vibe's settings page, orvibes-diy secrets set YOUTUBE_API_KEY <the key>.
The app carries those five steps in its own UI, because the person reading them is the person who has to do them.
When the key is missing, the app shows the owner the setup steps for a YouTube Data API v3 key instead of an error.
The backend answers { ok: false, reason: "missing-key" } when
ctx.secrets.YOUTUBE_API_KEY is absent, and the client renders the numbered
steps, naming "YouTube Data API v3" and YOUTUBE_API_KEY in so many words so
the owner knows what to enable and what to call it.
Only the owner can set or rotate a secret, and handlers see the current value on every invocation — so the dashboard starts working the moment the key lands, with no redeploy.
2. The key never reaches the browser
Every YouTube request is made in backend.js with ctx.fetch and ctx.secrets.YOUTUBE_API_KEY; App.jsx never calls googleapis.com.
The client talks to the app's own /_api/... routes with a relative fetch, and the
backend is the only place that knows the key exists. A key pasted into a React
component ships to every visitor who views source, and a restricted key is still
the owner's quota to spend.
The /_api routes are the second door
Keeping the key server-side is only half of it. A published vibe's /_api/...
routes answer anyone, so a handler that calls Google on every request hands a
stranger the owner's 10,000 daily units and a refresh button.
Live YouTube calls are made only for the vibe owner; every other visitor is served from the stored snapshot, so a stranger can never spend the owner's quota.
A fetch handler is told who it is acting for: ctx.appInfo.ownerHandle is this
app's owner and ctx.userInfo is { userHandle } or null when the caller is
anonymous. One comparison at the top of the handler is the whole gate:
jsconst isOwner = ctx.userInfo?.userHandle != null && ctx.userInfo.userHandle === ctx.appInfo.ownerHandle;
Everyone else is answered from the daily snapshot docs section 5 already writes —
{ ok: true, source: "snapshot", asOf: <day>, ... }, so the client can say "as of
Tuesday" instead of implying the numbers are live — and
{ ok: false, reason: "no-snapshot-yet" } before the first tick has run. Nothing
about a visitor's request reaches googleapis.com.
Working out which channel the visitor meant has to be free too. Snapshots are
stored under the resolved UC… id, and a visitor arrives with ?q=@handle — so
comparing the raw q against a stored id matches nothing, and resolving the
handle with Google is the quota spend this gate exists to stop. The tracked
channel doc (type: "channel") therefore carries an aliases array: every
spelling that names this channel — the UC… id, the @handle from
snippet.customUrl, and any legacy username the owner connected it with — each
lowercased. The visitor path normalizes q through the same channelQuery
helper the owner path uses, lowercases what comes out, and looks for it among the
tracked channels' aliases. That is a comparison against documents the app already
holds, not a call.
An empty q is not a failure either: it is the owner's dashboard as visitors see
it, so answer with the newest snapshot of every tracked channel.
That visitor read happens on the fetch lane, which reads nothing for an
anonymous caller and cannot read an access-fn-bound database until the
backend opts in, so the recipe declares
config.fetch.unfilteredReads = { dbs: ["youtube"], why: ... } (see the
backend docs). The obligation that comes with it is small here: the snapshot
docs hold only a channel's public numbers, and the handler serves nothing else
from that database.
3. Resolve a channel from whatever the person pastes
People arrive with a handle, a URL, or a raw id. Normalize before you call:
| What they paste | What you call |
|---|---|
UC… (24 chars, starts UC) |
channels?id=UC… |
@handle, or youtube.com/@handle |
channels?forHandle=@handle |
youtube.com/channel/UC… |
channels?id=UC… |
youtube.com/user/name (legacy) |
channels?forUsername=name |
The request:
GET https://www.googleapis.com/youtube/v3/channels
?part=snippet,statistics,contentDetails
&forHandle=@name
&key=<YOUTUBE_API_KEY>
snippet carries the title, description and thumbnails; statistics carries
subscriberCount, viewCount, videoCount and hiddenSubscriberCount;
contentDetails.relatedPlaylists.uploads is the uploads playlist id you need
for the video list. An empty items array means no such channel — say that, and
keep the input on screen so the person can fix it.
4. Video performance without burning quota
Read videos through the uploads playlist (playlistItems, then videos?part=statistics) and never through search, which costs 100 quota units per call.
The default daily quota is 10,000 units, and channels,
playlistItems and videos cost 1 unit each, so a full refresh of a channel
with 50 recent videos costs about 3 units. One search call costs 100 and
returns less.
GET .../youtube/v3/playlistItems?part=contentDetails&playlistId=<uploads>&maxResults=50&key=…
GET .../youtube/v3/videos?part=snippet,statistics&id=<up to 50 comma-joined ids>&key=…
"Gaining attention" is a comparison, not a field: take each video's current
viewCount and subtract the viewCount the previous snapshot stored for the
same video id. Rank by that delta, and label the list with the window it covers
("since yesterday's snapshot"). With one snapshot on file there is no delta yet —
say "first snapshot saved, changes appear tomorrow" rather than showing zeros.
Counts arrive as strings. Number(stats.viewCount ?? 0) before you do arithmetic.
5. A daily snapshot the app can chart
The scheduled tick writes one snapshot doc per channel per day under a fixed _id, and skips the write when nothing changed.
The fixed _id is what makes the tick idempotent: it can run every hour, and the day's row is written once and
then left alone.
jsexport const config = { scheduled: { interval: "1h" } };
The doc:
js{
_id: `snapshot-${channelId}-${day}`, // day is YYYY-MM-DD
type: "snapshot",
channelDay: `${channelId}|${day}`, // one channel's history, readable by prefix
day,
channelId,
title, // so a visitor's snapshot answer can name the channel
subscribers,
views,
videoCount,
videos: [{ id, title, views, likes, comments }],
}
Beside them sits one tracked-channel doc per channel — the list the tick sweeps
and the list a visitor's q is resolved against:
js{
_id: `channel-${channelId}`,
type: "channel",
channelId,
aliases: ["uc…", "@handle", "legacyname"], // lowercased, from section 2
}
channelDay is what keeps the reads channel-selective. Asking for
{ field: "type", key: "snapshot", limit: 200 } and filtering in the handler
reads every channel's whole history to answer about one of them, and goes blind
as soon as the app tracks more days than the page holds. Ask for the rows you
want instead:
jsconst page = await ctx.db.query({ db: DB, field: "channelDay", prefix: `${channelId}|`, limit: 100, after });
and walk after = page.next to the end before picking the largest day. The
tracked-channel read walks the same cursor. A filtered page can come back
empty and still carry next, so the loop condition is the cursor, never
emptiness (the backend docs spell this out).
Point-read the existing doc by that _id, compare the tracked fields and the
per-video rows — id, title, views, likes and comments — and return without
writing when they all match. A channel whose subscriber and view totals sat still
overnight can still have picked up likes, comments or a retitled video, and
comparing only the channel totals would throw that day's change away. A tick that
genuinely changes nothing writes nothing, which is the zero-churn rule from the
backend docs.
When the channel call succeeds but the video call fails, keep the rows the last
snapshot stored and mark the doc videosStale: true. Writing videos: [] over
real rows lets one upstream blip erase a day of video history, and tomorrow's
delta then reads every video as brand new.
The client charts the history with useLiveQuery("type", { key: "snapshot" }),
sorts by day, and draws subscribers and views over time.
6. Label what an API key cannot see
An API key reads public data.
Watch time, revenue, impressions, traffic sources and demographics need YouTube Analytics with OAuth and are labeled "Not available with an API key", never shown as zero.
Give them their own section in the UI with that label, so the owner sees the boundary instead of believing the channel earned nothing.
Two more honest labels:
statistics.hiddenSubscriberCount === truemeans the channel hides the number. Show "hidden by the channel", not0.- YouTube rounds public subscriber counts (three significant figures above 1,000). Put "YouTube rounds public subscriber counts" in a caption under the subscriber tile.
likeCountis absent when the channel hides likes, andcommentCountis absent when comments are off. Render "hidden" for an absent field.
7. Errors keep their reason
Google answers a refusal with error.errors[0].reason. Classify on it and pass
the reason through:
quotaExceeded— "Daily quota used; resets at midnight Pacific."keyInvalidorAPI_KEY_INVALID— show the setup steps from section 1 again.accessNotConfigured— "Enable YouTube Data API v3 on this key's project."- anything else — show the API's own
error.message.
A failure keeps its reason all the way to the screen. "No data" tells the owner nothing they can act on, and the four cases above each have a different fix.
Complete backend.js
backend.js
jsexport const config = {
scheduled: { interval: "1h" },
// The visitor path below reads the snapshot docs on the fetch lane, which
// reads nothing for a caller with no session unless the db is named here.
// The snapshots hold only public channel numbers, so the obligation is light.
fetch: { unfilteredReads: { dbs: ["youtube"], why: "serves public channel snapshots to visitors" } },
};
const API = "https://www.googleapis.com/youtube/v3";
const DB = "youtube";
function channelQuery(raw) {
const q = String(raw ?? "").trim();
const url = /^https?:\/\//i.test(q) ? q : "";
const path = url ? new URL(url).pathname : q;
const byId = /(?:^|\/)(UC[\w-]{22})(?:\/|$)/.exec(path);
if (byId) return { forHandle: null, id: byId[1], forUsername: null };
const byHandle = /(?:^|\/)@([\w.-]+)/.exec(path);
if (byHandle) return { id: null, forHandle: `@${byHandle[1]}`, forUsername: null };
const byUser = /\/user\/([\w-]+)/.exec(path);
if (byUser) return { id: null, forHandle: null, forUsername: byUser[1] };
return { id: null, forHandle: `@${q.replace(/^@/, "")}`, forUsername: null };
}
const aliasKey = (value) => String(value ?? "").trim().toLowerCase();
/** Every spelling that names this channel — the id, the @handle, and what the owner pasted. */
function channelAliases(channel, raw) {
const asked = channelQuery(raw);
const all = [channel.channelId, channel.handle, asked.id, asked.forHandle, asked.forUsername, raw];
return [...new Set(all.map(aliasKey).filter(Boolean))];
}
async function callYouTube(ctx, resource, params) {
const key = ctx.secrets.YOUTUBE_API_KEY;
if (!key) return { ok: false, reason: "missing-key" };
const url = new URL(`${API}/${resource}`);
for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
url.searchParams.set("key", key);
const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
const body = await res.json().catch(() => null);
if (res.status === 200 && body) return { ok: true, body };
const failure = body?.error?.errors?.[0]?.reason ?? "unknown";
return { ok: false, reason: failure, message: body?.error?.message ?? `HTTP ${res.status}` };
}
async function loadChannel(ctx, raw) {
const result = await callYouTube(ctx, "channels", {
part: "snippet,statistics,contentDetails",
...channelQuery(raw),
});
if (!result.ok) return result;
const item = result.body.items?.[0];
if (!item) return { ok: false, reason: "not-found", message: "No channel matches that handle or URL." };
const stats = item.statistics ?? {};
const channel = {
channelId: item.id,
title: item.snippet?.title ?? item.id,
handle: item.snippet?.customUrl ?? null,
thumbnail: item.snippet?.thumbnails?.default?.url ?? null,
uploads: item.contentDetails?.relatedPlaylists?.uploads ?? null,
subscribers: stats.hiddenSubscriberCount === true ? null : Number(stats.subscriberCount ?? 0),
subscribersHidden: stats.hiddenSubscriberCount === true,
views: Number(stats.viewCount ?? 0),
videoCount: Number(stats.videoCount ?? 0),
};
// The client stores these on the tracked-channel doc, so a visitor's `?q=@handle` resolves
// against a document instead of costing a `channels?forHandle=` call.
channel.aliases = channelAliases(channel, raw);
return { ok: true, channel };
}
async function loadVideos(ctx, uploads) {
const list = await callYouTube(ctx, "playlistItems", {
part: "contentDetails",
playlistId: uploads,
maxResults: 50,
});
if (!list.ok) return list;
const ids = (list.body.items ?? []).map((i) => i.contentDetails?.videoId).filter(Boolean);
if (ids.length === 0) return { ok: true, videos: [] };
const detail = await callYouTube(ctx, "videos", { part: "snippet,statistics", id: ids.join(",") });
if (!detail.ok) return detail;
const videos = (detail.body.items ?? []).map((v) => ({
id: v.id,
title: v.snippet?.title ?? v.id,
published: v.snippet?.publishedAt ?? null,
views: Number(v.statistics?.viewCount ?? 0),
likes: v.statistics?.likeCount == null ? null : Number(v.statistics.likeCount),
comments: v.statistics?.commentCount == null ? null : Number(v.statistics.commentCount),
}));
return { ok: true, videos };
}
/** Every page of a keyed read. `next` rides on the array, so take it before any array operation. */
async function readAll(ctx, query) {
const rows = [];
let after;
do {
const page = await ctx.db.query({ ...query, after });
after = page.next;
rows.push(...page);
} while (after);
return rows;
}
async function trackedChannels(ctx) {
return readAll(ctx, { db: DB, field: "type", key: "channel", limit: 20 });
}
/** One channel's history, selected by prefix rather than paged whole and filtered in the handler. */
async function newestSnapshot(ctx, channelId) {
const rows = await readAll(ctx, { db: DB, field: "channelDay", prefix: `${channelId}|`, limit: 100 });
return rows.sort((a, b) => String(b.day).localeCompare(String(a.day)))[0] ?? null;
}
/** Does this tracked channel answer to what the visitor pasted? Comparison only — no call. */
function matchesPastedQuery(row, raw) {
const asked = channelQuery(raw);
const wanted = [asked.id, asked.forHandle, asked.forUsername, raw].map(aliasKey).filter(Boolean);
const aliases = (row.aliases ?? [row.channelId]).map(aliasKey);
return wanted.some((w) => aliases.includes(w));
}
function snapshotChannel(row) {
return {
channelId: row.channelId,
title: row.title ?? row.channelId,
subscribers: row.subscribers,
views: row.views,
videoCount: row.videoCount,
};
}
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
export async function fetch(request, ctx) {
const url = new URL(request.url);
if (request.method !== "GET") return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
const isOwner = ctx.userInfo?.userHandle != null && ctx.userInfo.userHandle === ctx.appInfo.ownerHandle;
// Anyone who is not the owner is answered from what the tick already stored, and the channel they
// asked for is resolved against the tracked docs' aliases. Nothing here reaches googleapis.com, so
// a stranger with a refresh button cannot spend the owner's daily quota.
if (!isOwner) {
const asked = url.searchParams.get("channelId") ?? url.searchParams.get("q") ?? "";
const tracked = await trackedChannels(ctx);
const wanted = asked.trim() === "" ? tracked : tracked.filter((row) => matchesPastedQuery(row, asked));
const newest = [];
for (const row of wanted) {
const snapshot = await newestSnapshot(ctx, row.channelId);
if (snapshot) newest.push(snapshot);
}
if (newest.length === 0) return json({ ok: false, reason: "no-snapshot-yet" });
if (url.pathname === "/channel") {
return json({
ok: true,
source: "snapshot",
asOf: newest.map((r) => String(r.day)).sort().at(-1),
channel: snapshotChannel(newest[0]),
channels: newest.map(snapshotChannel),
});
}
if (url.pathname === "/videos") {
return json({
ok: true,
source: "snapshot",
asOf: newest[0].day,
videos: newest[0].videos ?? [],
videosStale: newest[0].videosStale === true,
});
}
return new Response("not found", { status: 404 });
}
if (url.pathname === "/channel") {
const result = await loadChannel(ctx, url.searchParams.get("q"));
return json(result.ok ? { ...result, source: "live" } : result);
}
if (url.pathname === "/videos") {
const channel = await loadChannel(ctx, url.searchParams.get("channelId"));
if (!channel.ok) return json(channel);
if (!channel.channel.uploads) return json({ ok: true, source: "live", videos: [] });
const videos = await loadVideos(ctx, channel.channel.uploads);
return json(videos.ok ? { ...videos, source: "live" } : videos);
}
return new Response("not found", { status: 404 });
}
/** Stable across key order and list order, so only a real change looks like one. */
function videoFingerprint(videos) {
return JSON.stringify(
(videos ?? [])
.map((v) => [v.id, v.title, v.views, v.likes, v.comments])
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
);
}
function sameSnapshot(existing, next) {
if (!existing) return false;
const totals = ["subscribers", "views", "videoCount"].every((k) => existing[k] === next[k]);
return totals && videoFingerprint(existing.videos) === videoFingerprint(next.videos);
}
export async function scheduled(event, ctx) {
const tracked = await trackedChannels(ctx);
const day = new Date(event.scheduledTime ?? Date.now()).toISOString().slice(0, 10);
for (const row of tracked) {
const channel = await loadChannel(ctx, row.channelId);
if (!channel.ok) {
ctx.log("warn", "youtube refresh failed", { channelId: row.channelId, reason: channel.reason });
continue;
}
const videos = channel.channel.uploads ? await loadVideos(ctx, channel.channel.uploads) : { ok: true, videos: [] };
const id = `snapshot-${channel.channel.channelId}-${day}`;
const existing = await ctx.db.get(id, { db: DB });
// A failed video call keeps the last good rows. Writing `videos: []` over them would erase a
// day of history for one upstream blip, and tomorrow's delta would read every video as new.
const lastGood = existing?.videos ?? (await newestSnapshot(ctx, channel.channel.channelId))?.videos ?? [];
const next = {
_id: id,
type: "snapshot",
channelDay: `${channel.channel.channelId}|${day}`,
day,
channelId: channel.channel.channelId,
title: channel.channel.title,
subscribers: channel.channel.subscribers,
views: channel.channel.views,
videoCount: channel.channel.videoCount,
videos: videos.ok
? videos.videos.map((v) => ({ id: v.id, title: v.title, views: v.views, likes: v.likes, comments: v.comments }))
: lastGood,
};
if (!videos.ok) {
next.videosStale = true;
ctx.log("warn", "youtube videos refresh failed; keeping the last good rows", {
channelId: channel.channel.channelId,
reason: videos.reason,
});
}
if (sameSnapshot(existing, next)) continue;
await ctx.db.put(next, { db: DB });
}
}
App.jsx sketch
App.jsx
jsximport React, { useState } from "react";
import { useFireproof } from "use-fireproof";
import { Button, Field, Input, Row } from "@vibes.diy/pouf";
const SETUP_STEPS = [
"Open the Google Cloud console and create or select a project.",
"In APIs & Services, enable YouTube Data API v3.",
"In Credentials, create an API key and restrict it to YouTube Data API v3.",
"Set it on this vibe as YOUTUBE_API_KEY in the settings page.",
];
export default function App() {
const { useLiveQuery, database } = useFireproof("youtube");
const { docs: snapshots } = useLiveQuery("type", { key: "snapshot" });
const [input, setInput] = useState("");
const [channel, setChannel] = useState(null);
const [problem, setProblem] = useState(null);
const [source, setSource] = useState(null);
const [asOf, setAsOf] = useState(null);
const connect = async () => {
const res = await fetch(`/_api/channel?q=${encodeURIComponent(input)}`);
const data = await res.json();
if (!data.ok) {
setProblem(data);
return;
}
setProblem(null);
setChannel(data.channel);
setSource(data.source);
setAsOf(data.asOf ?? null);
await database.put({
_id: `channel-${data.channel.channelId}`,
type: "channel",
channelId: data.channel.channelId,
aliases: data.channel.aliases ?? [data.channel.channelId.toLowerCase()],
});
};
const history = snapshots.slice().sort((a, b) => a.day.localeCompare(b.day));
return (
<div>
<Row gap={2} align="top">
<Field label="Channel">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={input} onChange={setInput} placeholder="@handle or channel URL" />}</Field>
<Button type="button" onClick={connect}>Connect channel</Button>
</Row>
{problem?.reason === "missing-key" && (
<section>
<h2>Add a YouTube Data API v3 key</h2>
<ol>
{SETUP_STEPS.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p>The key is stored as YOUTUBE_API_KEY and stays on the server.</p>
</section>
)}
{problem?.reason === "no-snapshot-yet" && <p>The first daily snapshot has not been saved yet.</p>}
{problem && !["missing-key", "no-snapshot-yet"].includes(problem.reason) && <p role="alert">{problem.message}</p>}
{channel && (
<section>
<h2>{channel.title}</h2>
<p>{channel.subscribersHidden ? "Subscribers hidden by the channel" : `${channel.subscribers} subscribers`}</p>
<small>YouTube rounds public subscriber counts.</small>
<p>{channel.views} total views across {channel.videoCount} videos</p>
{source === "snapshot" && <small>As of {asOf} — live numbers are refreshed for the channel owner.</small>}
</section>
)}
<section>
<h3>Not available with an API key</h3>
<p>Watch time, revenue, impressions, traffic sources and demographics come from YouTube Analytics with OAuth.</p>
</section>
<section>
<h3>Daily snapshots</h3>
{history.length === 0 ? <p>First snapshot saves within the hour.</p> : <Chart rows={history} />}
</section>
</div>
);
}
Chart the history rows with whichever chart surface the app already uses:
day on the x axis, subscribers and views as two series, and the per-video
deltas between the last two snapshots as the "gaining attention" list.