@vibes.diy/prompts 14.1.14 → 14.1.16

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.
package/llms/index.d.ts CHANGED
@@ -13,5 +13,6 @@ export { useVibeConfig } from "./use-vibe.js";
13
13
  export { createVibeConfig } from "./create-vibe.js";
14
14
  export { accessConfig } from "./access.js";
15
15
  export { connectionsConfig } from "./connections.js";
16
+ export { youtubeConfig } from "./youtube.js";
16
17
  export type { LlmConfig } from "./types.js";
17
- export declare const allConfigs: readonly [import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig];
18
+ export declare const allConfigs: readonly [import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig];
package/llms/index.js CHANGED
@@ -13,6 +13,7 @@ import { useVibeConfig } from "./use-vibe.js";
13
13
  import { createVibeConfig } from "./create-vibe.js";
14
14
  import { accessConfig } from "./access.js";
15
15
  import { connectionsConfig } from "./connections.js";
16
+ import { youtubeConfig } from "./youtube.js";
16
17
  export { backendConfig } from "./backend.js";
17
18
  export { calendarConfig } from "./calendar.js";
18
19
  export { callaiConfig } from "./callai.js";
@@ -28,6 +29,7 @@ export { useVibeConfig } from "./use-vibe.js";
28
29
  export { createVibeConfig } from "./create-vibe.js";
29
30
  export { accessConfig } from "./access.js";
30
31
  export { connectionsConfig } from "./connections.js";
32
+ export { youtubeConfig } from "./youtube.js";
31
33
  export const allConfigs = [
32
34
  callaiConfig,
33
35
  imageGenConfig,
@@ -44,5 +46,6 @@ export const allConfigs = [
44
46
  calendarConfig,
45
47
  accessConfig,
46
48
  connectionsConfig,
49
+ youtubeConfig,
47
50
  ];
48
51
  //# sourceMappingURL=index.js.map
package/llms/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../jsr/llms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAIrD,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,YAAY;IACZ,cAAc;IACd,cAAc;IACd,QAAQ;IACR,aAAa;IACb,WAAW;IACX,eAAe;IACf,WAAW;IACX,eAAe;IACf,aAAa;IACb,gBAAgB;IAChB,aAAa;IACb,cAAc;IACd,YAAY;IACZ,iBAAiB;CACT,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../jsr/llms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAI7C,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,YAAY;IACZ,cAAc;IACd,cAAc;IACd,QAAQ;IACR,aAAa;IACb,WAAW;IACX,eAAe;IACf,WAAW;IACX,eAAe;IACf,aAAa;IACb,gBAAgB;IAChB,aAAa;IACb,cAAc;IACd,YAAY;IACZ,iBAAiB;IACjB,aAAa;CACL,CAAC"}
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const youtubeConfig: LlmConfig;
@@ -0,0 +1,15 @@
1
+ export const youtubeConfig = {
2
+ name: "youtube",
3
+ label: "YouTube channel stats",
4
+ description: "YouTube channel dashboards on the owner's own YouTube Data API v3 key: the setup steps the app shows when the key is missing, keeping the key in backend.js, resolving a channel from a handle or URL, reading videos through the uploads playlist instead of the 100-unit search endpoint, daily snapshots to chart change over time, and labeling the numbers an API key cannot see",
5
+ cues: [
6
+ "youtube",
7
+ "my channel",
8
+ "subscriber count",
9
+ "subscribers",
10
+ "video views",
11
+ "youtube stats",
12
+ "channel analytics",
13
+ ],
14
+ };
15
+ //# sourceMappingURL=youtube.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"youtube.js","sourceRoot":"","sources":["../../jsr/llms/youtube.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,uBAAuB;IAC9B,WAAW,EACT,uXAAuX;IACzX,IAAI,EAAE;QACJ,SAAS;QACT,YAAY;QACZ,kBAAkB;QAClB,aAAa;QACb,aAAa;QACb,eAAe;QACf,mBAAmB;KACpB;CACF,CAAC"}
@@ -0,0 +1,584 @@
1
+ # YouTube channel stats — with the owner's own API key
2
+
3
+ When someone asks for a dashboard of their own YouTube channel — subscribers,
4
+ views, which videos are picking up — build it on the **YouTube Data API v3**
5
+ with a key the owner supplies. This is a `backend.js` feature (see the backend
6
+ docs for the general handler contract); this page is the recipe that makes the
7
+ key safe, the quota survivable, and the numbers honest about what they are.
8
+
9
+ ## 1. Bring your own key, and explain how
10
+
11
+ The owner gets a key from Google, once:
12
+
13
+ 1. Open the Google Cloud console and create or select a project.
14
+ 2. Go to APIs & Services, then Library, and enable **YouTube Data API v3**.
15
+ 3. Go to Credentials, then Create credentials, then API key.
16
+ 4. Restrict the key to the YouTube Data API v3.
17
+ 5. Set it on the vibe as `YOUTUBE_API_KEY` — the vibe's settings page, or
18
+ `vibes-diy secrets set YOUTUBE_API_KEY <the key>`.
19
+
20
+ The app carries those five steps in its own UI, because the person reading them
21
+ is the person who has to do them.
22
+
23
+ When the key is missing, the app shows the owner the setup steps for a YouTube Data API v3 key instead of an error.
24
+
25
+ The backend answers `{ ok: false, reason: "missing-key" }` when
26
+ `ctx.secrets.YOUTUBE_API_KEY` is absent, and the client renders the numbered
27
+ steps, naming "YouTube Data API v3" and `YOUTUBE_API_KEY` in so many words so
28
+ the owner knows what to enable and what to call it.
29
+
30
+ Only the owner can set or rotate a secret, and handlers see the current value on
31
+ every invocation — so the dashboard starts working the moment the key lands, with
32
+ no redeploy.
33
+
34
+ ## 2. The key never reaches the browser
35
+
36
+ Every YouTube request is made in backend.js with ctx.fetch and ctx.secrets.YOUTUBE_API_KEY; App.jsx never calls googleapis.com.
37
+
38
+ The client talks to the app's own `/_api/...` routes with a relative `fetch`, and the
39
+ backend is the only place that knows the key exists. A key pasted into a React
40
+ component ships to every visitor who views source, and a restricted key is still
41
+ the owner's quota to spend.
42
+
43
+ ### The `/_api` routes are the second door
44
+
45
+ Keeping the key server-side is only half of it. A published vibe's `/_api/...`
46
+ routes answer *anyone*, so a handler that calls Google on every request hands a
47
+ stranger the owner's 10,000 daily units and a refresh button.
48
+
49
+ 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.
50
+
51
+ A `fetch` handler is told who it is acting for: `ctx.appInfo.ownerHandle` is this
52
+ app's owner and `ctx.userInfo` is `{ userHandle }` or `null` when the caller is
53
+ anonymous. One comparison at the top of the handler is the whole gate:
54
+
55
+ ```js
56
+ const isOwner = ctx.userInfo?.userHandle != null && ctx.userInfo.userHandle === ctx.appInfo.ownerHandle;
57
+ ```
58
+
59
+ Everyone else is answered from the daily snapshot docs section 5 already writes —
60
+ `{ ok: true, source: "snapshot", asOf: <day>, ... }`, so the client can say "as of
61
+ Tuesday" instead of implying the numbers are live — and
62
+ `{ ok: false, reason: "no-snapshot-yet" }` before the first tick has run. Nothing
63
+ about a visitor's request reaches googleapis.com.
64
+
65
+ Working out *which* channel the visitor meant has to be free too. Snapshots are
66
+ stored under the resolved `UC…` id, and a visitor arrives with `?q=@handle` — so
67
+ comparing the raw `q` against a stored id matches nothing, and resolving the
68
+ handle with Google is the quota spend this gate exists to stop. The tracked
69
+ channel doc (`type: "channel"`) therefore carries an `aliases` array: every
70
+ spelling that names this channel — the `UC…` id, the `@handle` from
71
+ `snippet.customUrl`, and any legacy username the owner connected it with — each
72
+ lowercased. The visitor path normalizes `q` through the same `channelQuery`
73
+ helper the owner path uses, lowercases what comes out, and looks for it among the
74
+ tracked channels' aliases. That is a comparison against documents the app already
75
+ holds, not a call.
76
+
77
+ An empty `q` is not a failure either: it is the owner's dashboard as visitors see
78
+ it, so answer with the newest snapshot of every tracked channel.
79
+
80
+ That visitor read happens on the `fetch` lane, which reads nothing for an
81
+ anonymous caller and cannot read an access-fn-bound database until the
82
+ backend opts in, so the recipe declares
83
+ `config.fetch.unfilteredReads = { dbs: ["youtube"], why: ... }` (see the
84
+ backend docs). The obligation that comes with it is small here: the snapshot
85
+ docs hold only a channel's public numbers, and the handler serves nothing else
86
+ from that database.
87
+
88
+ ## 3. Resolve a channel from whatever the person pastes
89
+
90
+ People arrive with a handle, a URL, or a raw id. Normalize before you call:
91
+
92
+ | What they paste | What you call |
93
+ | --- | --- |
94
+ | `UC…` (24 chars, starts `UC`) | `channels?id=UC…` |
95
+ | `@handle`, or `youtube.com/@handle` | `channels?forHandle=@handle` |
96
+ | `youtube.com/channel/UC…` | `channels?id=UC…` |
97
+ | `youtube.com/user/name` (legacy) | `channels?forUsername=name` |
98
+
99
+ The request:
100
+
101
+ ```
102
+ GET https://www.googleapis.com/youtube/v3/channels
103
+ ?part=snippet,statistics,contentDetails
104
+ &forHandle=@name
105
+ &key=<YOUTUBE_API_KEY>
106
+ ```
107
+
108
+ `snippet` carries the title, description and thumbnails; `statistics` carries
109
+ `subscriberCount`, `viewCount`, `videoCount` and `hiddenSubscriberCount`;
110
+ `contentDetails.relatedPlaylists.uploads` is the uploads playlist id you need
111
+ for the video list. An empty `items` array means no such channel — say that, and
112
+ keep the input on screen so the person can fix it.
113
+
114
+ ## 4. Video performance without burning quota
115
+
116
+ Read videos through the uploads playlist (playlistItems, then videos?part=statistics) and never through search, which costs 100 quota units per call.
117
+
118
+ The default daily quota is 10,000 units, and `channels`,
119
+ `playlistItems` and `videos` cost 1 unit each, so a full refresh of a channel
120
+ with 50 recent videos costs about 3 units. One `search` call costs 100 and
121
+ returns less.
122
+
123
+ ```
124
+ GET .../youtube/v3/playlistItems?part=contentDetails&playlistId=<uploads>&maxResults=50&key=…
125
+ GET .../youtube/v3/videos?part=snippet,statistics&id=<up to 50 comma-joined ids>&key=…
126
+ ```
127
+
128
+ "Gaining attention" is a comparison, not a field: take each video's current
129
+ `viewCount` and subtract the `viewCount` the previous snapshot stored for the
130
+ same video id. Rank by that delta, and label the list with the window it covers
131
+ ("since yesterday's snapshot"). With one snapshot on file there is no delta yet —
132
+ say "first snapshot saved, changes appear tomorrow" rather than showing zeros.
133
+
134
+ Counts arrive as strings. `Number(stats.viewCount ?? 0)` before you do arithmetic.
135
+
136
+ ## 5. A daily snapshot the app can chart
137
+
138
+ The scheduled tick writes one snapshot doc per channel per day under a fixed _id, and skips the write when nothing changed.
139
+
140
+ The fixed `_id` is what makes the tick idempotent: it can run every hour, and the day's row is written once and
141
+ then left alone.
142
+
143
+ ```js
144
+ export const config = { scheduled: { interval: "1h" } };
145
+ ```
146
+
147
+ The doc:
148
+
149
+ ```js
150
+ {
151
+ _id: `snapshot-${channelId}-${day}`, // day is YYYY-MM-DD
152
+ type: "snapshot",
153
+ channelDay: `${channelId}|${day}`, // one channel's history, readable by prefix
154
+ day,
155
+ channelId,
156
+ title, // so a visitor's snapshot answer can name the channel
157
+ subscribers,
158
+ views,
159
+ videoCount,
160
+ videos: [{ id, title, views, likes, comments }],
161
+ }
162
+ ```
163
+
164
+ Beside them sits one tracked-channel doc per channel — the list the tick sweeps
165
+ and the list a visitor's `q` is resolved against:
166
+
167
+ ```js
168
+ {
169
+ _id: `channel-${channelId}`,
170
+ type: "channel",
171
+ channelId,
172
+ aliases: ["uc…", "@handle", "legacyname"], // lowercased, from section 2
173
+ }
174
+ ```
175
+
176
+ `channelDay` is what keeps the reads channel-selective. Asking for
177
+ `{ field: "type", key: "snapshot", limit: 200 }` and filtering in the handler
178
+ reads every channel's whole history to answer about one of them, and goes blind
179
+ as soon as the app tracks more days than the page holds. Ask for the rows you
180
+ want instead:
181
+
182
+ ```js
183
+ const page = await ctx.db.query({ db: DB, field: "channelDay", prefix: `${channelId}|`, limit: 100, after });
184
+ ```
185
+
186
+ and walk `after = page.next` to the end before picking the largest `day`. The
187
+ tracked-channel read walks the same cursor. A filtered page can come back
188
+ **empty and still carry `next`**, so the loop condition is the cursor, never
189
+ emptiness (the backend docs spell this out).
190
+
191
+ Point-read the existing doc by that `_id`, compare the tracked fields **and the
192
+ per-video rows** — id, title, views, likes and comments — and return without
193
+ writing when they all match. A channel whose subscriber and view totals sat still
194
+ overnight can still have picked up likes, comments or a retitled video, and
195
+ comparing only the channel totals would throw that day's change away. A tick that
196
+ genuinely changes nothing writes nothing, which is the zero-churn rule from the
197
+ backend docs.
198
+
199
+ When the channel call succeeds but the video call fails, keep the rows the last
200
+ snapshot stored and mark the doc `videosStale: true`. Writing `videos: []` over
201
+ real rows lets one upstream blip erase a day of video history, and tomorrow's
202
+ delta then reads every video as brand new.
203
+
204
+ The client charts the history with `useLiveQuery("type", { key: "snapshot" })`,
205
+ sorts by `day`, and draws subscribers and views over time.
206
+
207
+ ## 6. Label what an API key cannot see
208
+
209
+ An API key reads public data.
210
+
211
+ 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.
212
+
213
+ Give them their own section in the UI with that label, so the owner sees the
214
+ boundary instead of believing the channel earned nothing.
215
+
216
+ Two more honest labels:
217
+
218
+ - `statistics.hiddenSubscriberCount === true` means the channel hides the
219
+ number. Show "hidden by the channel", not `0`.
220
+ - YouTube rounds public subscriber counts (three significant figures above
221
+ 1,000). Put "YouTube rounds public subscriber counts" in a caption under the
222
+ subscriber tile.
223
+ - `likeCount` is absent when the channel hides likes, and `commentCount` is
224
+ absent when comments are off. Render "hidden" for an absent field.
225
+
226
+ ## 7. Errors keep their reason
227
+
228
+ Google answers a refusal with `error.errors[0].reason`. Classify on it and pass
229
+ the reason through:
230
+
231
+ - `quotaExceeded` — "Daily quota used; resets at midnight Pacific."
232
+ - `keyInvalid` or `API_KEY_INVALID` — show the setup steps from section 1 again.
233
+ - `accessNotConfigured` — "Enable YouTube Data API v3 on this key's project."
234
+ - anything else — show the API's own `error.message`.
235
+
236
+ A failure keeps its reason all the way to the screen. "No data" tells the owner
237
+ nothing they can act on, and the four cases above each have a different fix.
238
+
239
+ ## Complete backend.js
240
+
241
+ ```js
242
+ export const config = {
243
+ scheduled: { interval: "1h" },
244
+ // The visitor path below reads the snapshot docs on the fetch lane, which
245
+ // reads nothing for a caller with no session unless the db is named here.
246
+ // The snapshots hold only public channel numbers, so the obligation is light.
247
+ fetch: { unfilteredReads: { dbs: ["youtube"], why: "serves public channel snapshots to visitors" } },
248
+ };
249
+
250
+ const API = "https://www.googleapis.com/youtube/v3";
251
+ const DB = "youtube";
252
+
253
+ function channelQuery(raw) {
254
+ const q = String(raw ?? "").trim();
255
+ const url = /^https?:\/\//i.test(q) ? q : "";
256
+ const path = url ? new URL(url).pathname : q;
257
+ const byId = /(?:^|\/)(UC[\w-]{22})(?:\/|$)/.exec(path);
258
+ if (byId) return { forHandle: null, id: byId[1], forUsername: null };
259
+ const byHandle = /(?:^|\/)@([\w.-]+)/.exec(path);
260
+ if (byHandle) return { id: null, forHandle: `@${byHandle[1]}`, forUsername: null };
261
+ const byUser = /\/user\/([\w-]+)/.exec(path);
262
+ if (byUser) return { id: null, forHandle: null, forUsername: byUser[1] };
263
+ return { id: null, forHandle: `@${q.replace(/^@/, "")}`, forUsername: null };
264
+ }
265
+
266
+ const aliasKey = (value) => String(value ?? "").trim().toLowerCase();
267
+
268
+ /** Every spelling that names this channel — the id, the @handle, and what the owner pasted. */
269
+ function channelAliases(channel, raw) {
270
+ const asked = channelQuery(raw);
271
+ const all = [channel.channelId, channel.handle, asked.id, asked.forHandle, asked.forUsername, raw];
272
+ return [...new Set(all.map(aliasKey).filter(Boolean))];
273
+ }
274
+
275
+ async function callYouTube(ctx, resource, params) {
276
+ const key = ctx.secrets.YOUTUBE_API_KEY;
277
+ if (!key) return { ok: false, reason: "missing-key" };
278
+ const url = new URL(`${API}/${resource}`);
279
+ for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
280
+ url.searchParams.set("key", key);
281
+ const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
282
+ const body = await res.json().catch(() => null);
283
+ if (res.status === 200 && body) return { ok: true, body };
284
+ const failure = body?.error?.errors?.[0]?.reason ?? "unknown";
285
+ return { ok: false, reason: failure, message: body?.error?.message ?? `HTTP ${res.status}` };
286
+ }
287
+
288
+ async function loadChannel(ctx, raw) {
289
+ const result = await callYouTube(ctx, "channels", {
290
+ part: "snippet,statistics,contentDetails",
291
+ ...channelQuery(raw),
292
+ });
293
+ if (!result.ok) return result;
294
+ const item = result.body.items?.[0];
295
+ if (!item) return { ok: false, reason: "not-found", message: "No channel matches that handle or URL." };
296
+ const stats = item.statistics ?? {};
297
+ const channel = {
298
+ channelId: item.id,
299
+ title: item.snippet?.title ?? item.id,
300
+ handle: item.snippet?.customUrl ?? null,
301
+ thumbnail: item.snippet?.thumbnails?.default?.url ?? null,
302
+ uploads: item.contentDetails?.relatedPlaylists?.uploads ?? null,
303
+ subscribers: stats.hiddenSubscriberCount === true ? null : Number(stats.subscriberCount ?? 0),
304
+ subscribersHidden: stats.hiddenSubscriberCount === true,
305
+ views: Number(stats.viewCount ?? 0),
306
+ videoCount: Number(stats.videoCount ?? 0),
307
+ };
308
+ // The client stores these on the tracked-channel doc, so a visitor's `?q=@handle` resolves
309
+ // against a document instead of costing a `channels?forHandle=` call.
310
+ channel.aliases = channelAliases(channel, raw);
311
+ return { ok: true, channel };
312
+ }
313
+
314
+ async function loadVideos(ctx, uploads) {
315
+ const list = await callYouTube(ctx, "playlistItems", {
316
+ part: "contentDetails",
317
+ playlistId: uploads,
318
+ maxResults: 50,
319
+ });
320
+ if (!list.ok) return list;
321
+ const ids = (list.body.items ?? []).map((i) => i.contentDetails?.videoId).filter(Boolean);
322
+ if (ids.length === 0) return { ok: true, videos: [] };
323
+ const detail = await callYouTube(ctx, "videos", { part: "snippet,statistics", id: ids.join(",") });
324
+ if (!detail.ok) return detail;
325
+ const videos = (detail.body.items ?? []).map((v) => ({
326
+ id: v.id,
327
+ title: v.snippet?.title ?? v.id,
328
+ published: v.snippet?.publishedAt ?? null,
329
+ views: Number(v.statistics?.viewCount ?? 0),
330
+ likes: v.statistics?.likeCount == null ? null : Number(v.statistics.likeCount),
331
+ comments: v.statistics?.commentCount == null ? null : Number(v.statistics.commentCount),
332
+ }));
333
+ return { ok: true, videos };
334
+ }
335
+
336
+ /** Every page of a keyed read. `next` rides on the array, so take it before any array operation. */
337
+ async function readAll(ctx, query) {
338
+ const rows = [];
339
+ let after;
340
+ do {
341
+ const page = await ctx.db.query({ ...query, after });
342
+ after = page.next;
343
+ rows.push(...page);
344
+ } while (after);
345
+ return rows;
346
+ }
347
+
348
+ async function trackedChannels(ctx) {
349
+ return readAll(ctx, { db: DB, field: "type", key: "channel", limit: 20 });
350
+ }
351
+
352
+ /** One channel's history, selected by prefix rather than paged whole and filtered in the handler. */
353
+ async function newestSnapshot(ctx, channelId) {
354
+ const rows = await readAll(ctx, { db: DB, field: "channelDay", prefix: `${channelId}|`, limit: 100 });
355
+ return rows.sort((a, b) => String(b.day).localeCompare(String(a.day)))[0] ?? null;
356
+ }
357
+
358
+ /** Does this tracked channel answer to what the visitor pasted? Comparison only — no call. */
359
+ function matchesPastedQuery(row, raw) {
360
+ const asked = channelQuery(raw);
361
+ const wanted = [asked.id, asked.forHandle, asked.forUsername, raw].map(aliasKey).filter(Boolean);
362
+ const aliases = (row.aliases ?? [row.channelId]).map(aliasKey);
363
+ return wanted.some((w) => aliases.includes(w));
364
+ }
365
+
366
+ function snapshotChannel(row) {
367
+ return {
368
+ channelId: row.channelId,
369
+ title: row.title ?? row.channelId,
370
+ subscribers: row.subscribers,
371
+ views: row.views,
372
+ videoCount: row.videoCount,
373
+ };
374
+ }
375
+
376
+ function json(payload, status = 200) {
377
+ return new Response(JSON.stringify(payload), {
378
+ status,
379
+ headers: { "content-type": "application/json" },
380
+ });
381
+ }
382
+
383
+ export async function fetch(request, ctx) {
384
+ const url = new URL(request.url);
385
+ if (request.method !== "GET") return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
386
+
387
+ const isOwner = ctx.userInfo?.userHandle != null && ctx.userInfo.userHandle === ctx.appInfo.ownerHandle;
388
+
389
+ // Anyone who is not the owner is answered from what the tick already stored, and the channel they
390
+ // asked for is resolved against the tracked docs' aliases. Nothing here reaches googleapis.com, so
391
+ // a stranger with a refresh button cannot spend the owner's daily quota.
392
+ if (!isOwner) {
393
+ const asked = url.searchParams.get("channelId") ?? url.searchParams.get("q") ?? "";
394
+ const tracked = await trackedChannels(ctx);
395
+ const wanted = asked.trim() === "" ? tracked : tracked.filter((row) => matchesPastedQuery(row, asked));
396
+ const newest = [];
397
+ for (const row of wanted) {
398
+ const snapshot = await newestSnapshot(ctx, row.channelId);
399
+ if (snapshot) newest.push(snapshot);
400
+ }
401
+ if (newest.length === 0) return json({ ok: false, reason: "no-snapshot-yet" });
402
+ if (url.pathname === "/channel") {
403
+ return json({
404
+ ok: true,
405
+ source: "snapshot",
406
+ asOf: newest.map((r) => String(r.day)).sort().at(-1),
407
+ channel: snapshotChannel(newest[0]),
408
+ channels: newest.map(snapshotChannel),
409
+ });
410
+ }
411
+ if (url.pathname === "/videos") {
412
+ return json({
413
+ ok: true,
414
+ source: "snapshot",
415
+ asOf: newest[0].day,
416
+ videos: newest[0].videos ?? [],
417
+ videosStale: newest[0].videosStale === true,
418
+ });
419
+ }
420
+ return new Response("not found", { status: 404 });
421
+ }
422
+
423
+ if (url.pathname === "/channel") {
424
+ const result = await loadChannel(ctx, url.searchParams.get("q"));
425
+ return json(result.ok ? { ...result, source: "live" } : result);
426
+ }
427
+
428
+ if (url.pathname === "/videos") {
429
+ const channel = await loadChannel(ctx, url.searchParams.get("channelId"));
430
+ if (!channel.ok) return json(channel);
431
+ if (!channel.channel.uploads) return json({ ok: true, source: "live", videos: [] });
432
+ const videos = await loadVideos(ctx, channel.channel.uploads);
433
+ return json(videos.ok ? { ...videos, source: "live" } : videos);
434
+ }
435
+
436
+ return new Response("not found", { status: 404 });
437
+ }
438
+
439
+ /** Stable across key order and list order, so only a real change looks like one. */
440
+ function videoFingerprint(videos) {
441
+ return JSON.stringify(
442
+ (videos ?? [])
443
+ .map((v) => [v.id, v.title, v.views, v.likes, v.comments])
444
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
445
+ );
446
+ }
447
+
448
+ function sameSnapshot(existing, next) {
449
+ if (!existing) return false;
450
+ const totals = ["subscribers", "views", "videoCount"].every((k) => existing[k] === next[k]);
451
+ return totals && videoFingerprint(existing.videos) === videoFingerprint(next.videos);
452
+ }
453
+
454
+ export async function scheduled(event, ctx) {
455
+ const tracked = await trackedChannels(ctx);
456
+ const day = new Date(event.scheduledTime ?? Date.now()).toISOString().slice(0, 10);
457
+ for (const row of tracked) {
458
+ const channel = await loadChannel(ctx, row.channelId);
459
+ if (!channel.ok) {
460
+ ctx.log("warn", "youtube refresh failed", { channelId: row.channelId, reason: channel.reason });
461
+ continue;
462
+ }
463
+ const videos = channel.channel.uploads ? await loadVideos(ctx, channel.channel.uploads) : { ok: true, videos: [] };
464
+ const id = `snapshot-${channel.channel.channelId}-${day}`;
465
+ const existing = await ctx.db.get(id, { db: DB });
466
+ // A failed video call keeps the last good rows. Writing `videos: []` over them would erase a
467
+ // day of history for one upstream blip, and tomorrow's delta would read every video as new.
468
+ const lastGood = existing?.videos ?? (await newestSnapshot(ctx, channel.channel.channelId))?.videos ?? [];
469
+ const next = {
470
+ _id: id,
471
+ type: "snapshot",
472
+ channelDay: `${channel.channel.channelId}|${day}`,
473
+ day,
474
+ channelId: channel.channel.channelId,
475
+ title: channel.channel.title,
476
+ subscribers: channel.channel.subscribers,
477
+ views: channel.channel.views,
478
+ videoCount: channel.channel.videoCount,
479
+ videos: videos.ok
480
+ ? videos.videos.map((v) => ({ id: v.id, title: v.title, views: v.views, likes: v.likes, comments: v.comments }))
481
+ : lastGood,
482
+ };
483
+ if (!videos.ok) {
484
+ next.videosStale = true;
485
+ ctx.log("warn", "youtube videos refresh failed; keeping the last good rows", {
486
+ channelId: channel.channel.channelId,
487
+ reason: videos.reason,
488
+ });
489
+ }
490
+ if (sameSnapshot(existing, next)) continue;
491
+ await ctx.db.put(next, { db: DB });
492
+ }
493
+ }
494
+ ```
495
+
496
+ ## App.jsx sketch
497
+
498
+ ```jsx
499
+ import React, { useState } from "react";
500
+ import { useFireproof } from "use-fireproof";
501
+
502
+ const SETUP_STEPS = [
503
+ "Open the Google Cloud console and create or select a project.",
504
+ "In APIs & Services, enable YouTube Data API v3.",
505
+ "In Credentials, create an API key and restrict it to YouTube Data API v3.",
506
+ "Set it on this vibe as YOUTUBE_API_KEY in the settings page.",
507
+ ];
508
+
509
+ export default function App() {
510
+ const { useLiveQuery, database } = useFireproof("youtube");
511
+ const { docs: snapshots } = useLiveQuery("type", { key: "snapshot" });
512
+ const [input, setInput] = useState("");
513
+ const [channel, setChannel] = useState(null);
514
+ const [problem, setProblem] = useState(null);
515
+ const [source, setSource] = useState(null);
516
+ const [asOf, setAsOf] = useState(null);
517
+
518
+ const connect = async () => {
519
+ const res = await fetch(`/_api/channel?q=${encodeURIComponent(input)}`);
520
+ const data = await res.json();
521
+ if (!data.ok) {
522
+ setProblem(data);
523
+ return;
524
+ }
525
+ setProblem(null);
526
+ setChannel(data.channel);
527
+ setSource(data.source);
528
+ setAsOf(data.asOf ?? null);
529
+ await database.put({
530
+ _id: `channel-${data.channel.channelId}`,
531
+ type: "channel",
532
+ channelId: data.channel.channelId,
533
+ aliases: data.channel.aliases ?? [data.channel.channelId.toLowerCase()],
534
+ });
535
+ };
536
+
537
+ const history = snapshots.slice().sort((a, b) => a.day.localeCompare(b.day));
538
+
539
+ return (
540
+ <div>
541
+ <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="@handle or channel URL" />
542
+ <button onClick={connect}>Connect channel</button>
543
+
544
+ {problem?.reason === "missing-key" && (
545
+ <section>
546
+ <h2>Add a YouTube Data API v3 key</h2>
547
+ <ol>
548
+ {SETUP_STEPS.map((step) => (
549
+ <li key={step}>{step}</li>
550
+ ))}
551
+ </ol>
552
+ <p>The key is stored as YOUTUBE_API_KEY and stays on the server.</p>
553
+ </section>
554
+ )}
555
+ {problem?.reason === "no-snapshot-yet" && <p>The first daily snapshot has not been saved yet.</p>}
556
+ {problem && !["missing-key", "no-snapshot-yet"].includes(problem.reason) && <p role="alert">{problem.message}</p>}
557
+
558
+ {channel && (
559
+ <section>
560
+ <h2>{channel.title}</h2>
561
+ <p>{channel.subscribersHidden ? "Subscribers hidden by the channel" : `${channel.subscribers} subscribers`}</p>
562
+ <small>YouTube rounds public subscriber counts.</small>
563
+ <p>{channel.views} total views across {channel.videoCount} videos</p>
564
+ {source === "snapshot" && <small>As of {asOf} — live numbers are refreshed for the channel owner.</small>}
565
+ </section>
566
+ )}
567
+
568
+ <section>
569
+ <h3>Not available with an API key</h3>
570
+ <p>Watch time, revenue, impressions, traffic sources and demographics come from YouTube Analytics with OAuth.</p>
571
+ </section>
572
+
573
+ <section>
574
+ <h3>Daily snapshots</h3>
575
+ {history.length === 0 ? <p>First snapshot saves within the hour.</p> : <Chart rows={history} />}
576
+ </section>
577
+ </div>
578
+ );
579
+ }
580
+ ```
581
+
582
+ Chart the `history` rows with whichever chart surface the app already uses:
583
+ `day` on the x axis, `subscribers` and `views` as two series, and the per-video
584
+ deltas between the last two snapshots as the "gaining attention" list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "14.1.14",
3
+ "version": "14.1.16",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {
@@ -34,9 +34,9 @@
34
34
  "license": "Apache-2.0",
35
35
  "dependencies": {
36
36
  "@adviser/cement": "~0.5.34",
37
- "@vibes.diy/call-ai-v2": "14.1.14",
38
- "@vibes.diy/identity": "14.1.14",
39
- "@vibes.diy/use-vibes-types": "14.1.14",
37
+ "@vibes.diy/call-ai-v2": "14.1.16",
38
+ "@vibes.diy/identity": "14.1.16",
39
+ "@vibes.diy/use-vibes-types": "14.1.16",
40
40
  "arktype": "~2.2.3",
41
41
  "json-schema-faker": "~0.6.3"
42
42
  },