@coywolf/emdash 0.0.0-stage → 0.35.13

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.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Videos module: Cloudflare Stream library, the coywolf-video block's data,
3
+ * VideoObject schema, the embed index, the video sitemap, plays/likes,
4
+ * captions and the Stream webhook.
5
+ */
6
+ import { PluginRouteError, definePluginRoute } from "emdash";
7
+ import type { PageMetadataContribution, PageMetadataEvent } from "emdash";
8
+ import { z } from "zod";
9
+
10
+ import { absoluteUrl, entryUrl } from "../core/content-url.js";
11
+ import { ctxFeatures, invalidateFeatures, isOn, requireCachedFeature, requireFeature, cachedCtxFeatures } from "../core/features.js";
12
+ import { parseInput } from "../shared.js";
13
+ import { ENGAGEMENT_HEADER, ENGAGEMENT_LIMITS, EngagementLimiter, LIKES_BY_PREFIX, hasEngagementHeader, likeAction, likesByKey, staleLikesByKey } from "./engagement.js";
14
+ import {
15
+ type SitemapEntry,
16
+ type VideoRef,
17
+ buildVideoObject,
18
+ buildVideoSitemap,
19
+ findVideoBlocks,
20
+ iframeUrl,
21
+ indexSource,
22
+ isImageUrl,
23
+ RecentKeys,
24
+ isUid,
25
+ normalizeCustomerHost,
26
+ parseTags,
27
+ posterUrl,
28
+ sha256Hex,
29
+ MAX_CAPTION_BYTES,
30
+ utf8Bytes,
31
+ verifyWebhookSignature,
32
+ watchUrl,
33
+ } from "./lib.js";
34
+ import {
35
+ COLLECTIONS,
36
+ type Counts,
37
+ type Ctx,
38
+ type EmbedEntry,
39
+ SETTINGS,
40
+ type VideoMeta,
41
+ videoFacts,
42
+ SITEMAP_KEY,
43
+ adminSettings,
44
+ allEmbeds,
45
+ bump,
46
+ captionStore,
47
+ client,
48
+ countsFor,
49
+ embedKey,
50
+ embedStore,
51
+ invalidateEmbeds,
52
+ invalidateLibrary,
53
+ invalidatePublicConfig,
54
+ invalidateSitemap,
55
+ library,
56
+ metaFor,
57
+ metaStore,
58
+ patchMeta,
59
+ publicConfig,
60
+ refreshCaptions,
61
+ refreshDownload,
62
+ refreshVideo,
63
+ statsStore,
64
+ } from "./store.js";
65
+ import { FONT_WEIGHTS, normalizeDisplay } from "./render.js";
66
+ import { StreamError } from "./stream.js";
67
+
68
+ export interface VideosOptions {
69
+ /** Longest upload Stream should accept, in seconds (reserves quota while uploading). Default 3600. */
70
+ maxUploadDurationSeconds?: number;
71
+ }
72
+
73
+ export const F = {
74
+ main: "videos",
75
+ schema: "videos.schema",
76
+ sitemap: "videos.sitemap",
77
+ engagement: "videos.engagement",
78
+ captions: "videos.captions",
79
+ webhook: "videos.webhook",
80
+ } as const;
81
+
82
+ export const TASKS = { captions: "videos-captions-refresh", likes: "videos-likes-prune", downloads: "videos-downloads-refresh" } as const;
83
+
84
+ const hexColor = z
85
+ .string()
86
+ .trim()
87
+ .refine((v) => v === "" || /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(v), "Colors must be hex, such as #f6821f.");
88
+
89
+ const align = z.enum(["left", "center", "right"]);
90
+ const optionalHex = hexColor.optional();
91
+
92
+ /** Views & likes and Appearance (stored normalized; see render.ts). */
93
+ const displayInput = z.object({
94
+ showName: z.boolean(),
95
+ showDescription: z.boolean(),
96
+ showPlays: z.boolean(),
97
+ showLikes: z.boolean(),
98
+ showLikeCount: z.boolean(),
99
+ showDate: z.boolean(),
100
+ followSiteDefaults: z.boolean(),
101
+ dateStyle: z.enum(["absolute", "relative"]),
102
+ likeIcon: z.enum(["heart", "thumbs", "star"]),
103
+ scheme: z.enum(["auto", "light", "dark", "off"]),
104
+ align,
105
+ metaAlign: align,
106
+ radius: z.number().int("Use whole pixels for the corner radius.").min(0).max(48, "Corner radius is 48 px at most."),
107
+ border: z.boolean(),
108
+ borderWidth: z.number().int("Use whole pixels for the border width.").min(0).max(20, "Border width is 20 px at most."),
109
+ borderColor: optionalHex,
110
+ titleColor: optionalHex,
111
+ titleSize: z.number().min(0.5, "Font sizes are 0.5 to 4 em.").max(4, "Font sizes are 0.5 to 4 em."),
112
+ titleWeight: z.enum(["", ...FONT_WEIGHTS]),
113
+ descWeight: z.enum(["", ...FONT_WEIGHTS]),
114
+ likeColor: optionalHex,
115
+ likeBg: optionalHex,
116
+ likeActiveColor: optionalHex,
117
+ likeActiveBg: optionalHex,
118
+ metaColor: optionalHex,
119
+ metaSize: z.number().min(0.5, "Font sizes are 0.5 to 4 em.").max(4, "Font sizes are 0.5 to 4 em."),
120
+ });
121
+
122
+ const settingsInput = z.object({
123
+ accountId: z
124
+ .string()
125
+ .trim()
126
+ .refine((v) => v === "" || /^[0-9a-f]{32}$/i.test(v), "The account ID should be 32 hexadecimal characters.")
127
+ .optional(),
128
+ /** Write-only: a new token replaces the saved one; empty keeps it. */
129
+ token: z.string().trim().max(500).optional(),
130
+ clearToken: z.boolean().optional(),
131
+ customerSubdomain: z
132
+ .string()
133
+ .trim()
134
+ .max(253)
135
+ .refine((v) => v === "" || normalizeCustomerHost(v) !== null, "Use the customer subdomain, such as customer-abc123.cloudflarestream.com.")
136
+ .optional(),
137
+ accentColor: hexColor.optional(),
138
+ backgroundColor: hexColor.optional(),
139
+ lightEmbed: z.boolean().optional(),
140
+ display: displayInput.partial().optional(),
141
+ defaultTags: z.string().max(2000).optional(),
142
+ });
143
+
144
+ const uidSchema = z.string().regex(/^[0-9a-f]{32}$/, "Not a Stream video ID.");
145
+ const langSchema = z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "Use a language code such as en or pt-BR.");
146
+
147
+ /** Turn Stream API failures into readable admin errors. */
148
+ async function stream<T>(fn: () => Promise<T>): Promise<T> {
149
+ try {
150
+ return await fn();
151
+ } catch (error) {
152
+ if (error instanceof StreamError) throw PluginRouteError.badRequest(`Cloudflare Stream: ${error.message}`);
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ async function requireClient(ctx: Ctx) {
158
+ const api = await client(ctx);
159
+ if (!api) throw PluginRouteError.badRequest("Add your Cloudflare account ID and Stream API token on the Videos page → Settings first.");
160
+ return api;
161
+ }
162
+
163
+ // ── Embed index ──────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Index one entry. Hook events can carry draft-hydrated data under the live
167
+ * status, so hooks re-read the live row; `isLive` is for rows that already
168
+ * came from the database (reindex).
169
+ */
170
+ async function indexEntry(ctx: Ctx, collection: string, content: Record<string, unknown>, fetchMissing = true, isLive = false): Promise<number> {
171
+ const id = typeof content.id === "string" ? content.id : null;
172
+ if (!id) return 0;
173
+ let live: Record<string, unknown> | null = isLive ? content : null;
174
+ if (!isLive && ctx.content) {
175
+ live = ((await ctx.content.get(collection, id)) as unknown as Record<string, unknown> | null) ?? null;
176
+ if (!live) {
177
+ await unindexEntry(ctx, collection, id);
178
+ return 0;
179
+ }
180
+ }
181
+ const item = indexSource(content, live);
182
+ if (!item) return 0;
183
+ const data = item.data as Record<string, unknown>;
184
+ const videos = findVideoBlocks(data);
185
+ const store = embedStore(ctx);
186
+ const key = embedKey(collection, id);
187
+ const prev = await store.get(key);
188
+ if (!videos.length) {
189
+ if (prev) {
190
+ await store.delete(key);
191
+ invalidateEmbeds();
192
+ await invalidateSitemap(ctx);
193
+ }
194
+ return 0;
195
+ }
196
+ const status = typeof item.status === "string" ? item.status : "draft";
197
+ const path =
198
+ status === "published" && typeof item.slug === "string"
199
+ ? await entryUrl(ctx, collection, { id, slug: item.slug, publishedAt: typeof item.publishedAt === "string" || item.publishedAt instanceof Date ? item.publishedAt : null, locale: typeof item.locale === "string" ? item.locale : null, status }).catch(() => null)
200
+ : null;
201
+ const url = path && ctx.site.url ? absoluteUrl(path, ctx.site.url) : null;
202
+ const entry: EmbedEntry = {
203
+ collection,
204
+ entryId: id,
205
+ slug: typeof item.slug === "string" ? item.slug : null,
206
+ status,
207
+ title: typeof data.title === "string" ? data.title : typeof data.name === "string" ? data.name : null,
208
+ url,
209
+ publishedAt: typeof item.publishedAt === "string" ? item.publishedAt : null,
210
+ updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : null,
211
+ uids: videos.map((v) => v.uid),
212
+ videos,
213
+ };
214
+ await store.put(key, entry);
215
+ invalidateEmbeds();
216
+ await invalidateSitemap(ctx);
217
+ if (fetchMissing) {
218
+ const known = await metaFor(ctx, entry.uids);
219
+ const api = await client(ctx);
220
+ for (const uid of entry.uids.filter((u) => !known.has(u)).slice(0, 5)) {
221
+ try {
222
+ await refreshVideo(ctx, uid, api);
223
+ } catch (error) {
224
+ ctx.log.warn("Videos: could not read video from Stream", { uid, error: String(error) });
225
+ }
226
+ }
227
+ }
228
+ return videos.length;
229
+ }
230
+
231
+ async function unindexEntry(ctx: Ctx, collection: string, id: string): Promise<void> {
232
+ if (await embedStore(ctx).delete(embedKey(collection, id))) {
233
+ invalidateEmbeds();
234
+ await invalidateSitemap(ctx);
235
+ }
236
+ }
237
+
238
+ /** uid → entries that embed it. */
239
+ function usage(entries: EmbedEntry[]): Map<string, Array<{ title: string | null; url: string | null; collection: string; status: string }>> {
240
+ const map = new Map<string, Array<{ title: string | null; url: string | null; collection: string; status: string }>>();
241
+ for (const e of entries) {
242
+ for (const uid of e.uids) {
243
+ const list = map.get(uid) ?? [];
244
+ list.push({ title: e.title, url: e.url, collection: e.collection, status: e.status });
245
+ map.set(uid, list);
246
+ }
247
+ }
248
+ return map;
249
+ }
250
+
251
+ /**
252
+ * Indexed videos no longer in the Stream library (deleted in Stream's
253
+ * dashboard), with where they're used. Only from a complete library listing:
254
+ * a truncated one would make videos look deleted.
255
+ */
256
+ function orphans(libraryUids: Set<string>, complete: boolean, entries: EmbedEntry[]) {
257
+ if (!complete) return [];
258
+ return [...usage(entries)]
259
+ .filter(([uid]) => !libraryUids.has(uid))
260
+ .map(([uid, usedIn]) => ({ uid, usedIn }));
261
+ }
262
+
263
+ interface StorageUsage {
264
+ videos: number;
265
+ minutes: number;
266
+ limit: number;
267
+ }
268
+ const STORAGE_KEY = "cache:videos:storage";
269
+
270
+ /** Forget a deleted video's details, counts and caption copies. */
271
+ async function removeLocal(ctx: Ctx, uid: string): Promise<void> {
272
+ await Promise.all([metaStore(ctx).delete(uid), statsStore(ctx).delete(uid)]);
273
+ const captions = await captionStore(ctx).query({ where: { uid }, limit: 50 });
274
+ if (captions.items.length) await captionStore(ctx).deleteMany(captions.items.map((c) => c.id));
275
+ await Promise.all([invalidateLibrary(ctx), invalidateSitemap(ctx), ctx.kv.delete(STORAGE_KEY)]);
276
+ }
277
+
278
+ // ── Schema ───────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * VideoObject nodes (no @context) for the videos embedded in a page's entry.
282
+ * Schema & Social folds these into its @graph; on their own they're emitted
283
+ * by pageMetadata below.
284
+ */
285
+ export async function entryVideoObjects(ctx: Ctx, page: PageMetadataEvent["page"], embeds?: EmbedEntry | null): Promise<Record<string, unknown>[]> {
286
+ const contributions = await videoContributions(ctx, page, embeds);
287
+ return (contributions ?? []).flatMap((c) => {
288
+ if (c.kind !== "jsonld" || Array.isArray(c.graph)) return [];
289
+ const { "@context": _omit, ...node } = c.graph;
290
+ return [node];
291
+ });
292
+ }
293
+
294
+ async function pageMetadata(event: PageMetadataEvent, ctx: Ctx): Promise<PageMetadataContribution[] | null> {
295
+ // Schema & Social's graph includes these videos (linked from the Article) when it's on.
296
+ if (isOn(await cachedCtxFeatures(ctx), "schema.graph")) return null;
297
+ return videoContributions(ctx, event.page);
298
+ }
299
+
300
+ /**
301
+ * `embeds`: the entry's embed-index doc when the caller already read it
302
+ * (undefined: read it here). The videos' metadata and counts are shared with
303
+ * the page's Coywolf Video blocks (videoFacts), so each is read once per page.
304
+ */
305
+ async function videoContributions(ctx: Ctx, page: PageMetadataEvent["page"], embeds?: EmbedEntry | null): Promise<PageMetadataContribution[] | null> {
306
+ if (!page.content) return null;
307
+ const entry = embeds !== undefined ? embeds : await embedStore(ctx).get(embedKey(page.content.collection, page.content.id));
308
+ // Legacy WordPress markers are rendered (with their own schema) by the theme.
309
+ const refs = (entry?.videos ?? []).filter((v) => !v.legacy);
310
+ if (!refs.length) return null;
311
+ const features = await cachedCtxFeatures(ctx);
312
+ const engagement = isOn(features, F.engagement);
313
+ const uids = refs.map((r) => r.uid);
314
+ const [facts, cfg] = await Promise.all([videoFacts(ctx, uids, () => publishedUids(ctx)), publicConfig(ctx)]);
315
+ const meta = new Map<string, VideoMeta>();
316
+ const counts = new Map<string, Counts>();
317
+ for (const [uid, f] of facts) {
318
+ if (f.meta) meta.set(uid, f.meta);
319
+ if (f.counts) counts.set(uid, f.counts);
320
+ }
321
+ const siteUrl = page.siteUrl || ctx.site.url || new URL(page.url).origin;
322
+ return refs.map((ref) => ({
323
+ kind: "jsonld" as const,
324
+ id: `coywolf-video-${ref.uid}`,
325
+ graph: buildVideoObject({
326
+ ref,
327
+ video: meta.get(ref.uid),
328
+ host: cfg.host,
329
+ siteUrl,
330
+ page: { title: page.pageTitle ?? page.title, description: page.description, publishedTime: page.articleMeta?.publishedTime },
331
+ counts: engagement ? (counts.get(ref.uid) ?? { plays: 0, likes: 0 }) : undefined,
332
+ likesEnabled: engagement,
333
+ captionsEnabled: isOn(features, F.captions),
334
+ }),
335
+ }));
336
+ }
337
+
338
+ // ── Sitemap ──────────────────────────────────────────────────────
339
+
340
+ const SITEMAP_TTL = 10 * 60_000;
341
+
342
+ async function sitemapXml(ctx: Ctx, origin: string): Promise<string> {
343
+ const cached = await ctx.kv.get<{ xml: string; at: number }>(SITEMAP_KEY);
344
+ if (cached && Date.now() - cached.at < SITEMAP_TTL) return cached.xml;
345
+ const features = await ctxFeatures(ctx);
346
+ const entries = (await allEmbeds(ctx)).filter((e) => e.status === "published" && e.url);
347
+ const uids = [...new Set(entries.flatMap((e) => e.uids))];
348
+ const [meta, counts, cfg] = await Promise.all([
349
+ metaFor(ctx, uids),
350
+ isOn(features, F.engagement) ? countsFor(ctx, uids) : Promise.resolve(new Map<string, Counts>()),
351
+ publicConfig(ctx),
352
+ ]);
353
+ const out: SitemapEntry[] = entries
354
+ .sort((a, b) => (a.url ?? "").localeCompare(b.url ?? ""))
355
+ .map((e) => ({
356
+ loc: new URL(e.url as string, origin).href,
357
+ videos: e.videos.map((ref: VideoRef) => {
358
+ const v = meta.get(ref.uid);
359
+ return {
360
+ thumbnail: posterUrl(cfg.host, ref.uid, ref, v, undefined, origin),
361
+ title: ref.title ?? v?.name ?? e.title ?? "Video",
362
+ description: ref.caption ?? v?.description ?? e.title ?? ref.title ?? v?.name ?? "Video",
363
+ contentLoc: v?.downloadUrl,
364
+ playerLoc: iframeUrl(cfg.host, ref.uid),
365
+ duration: v?.duration,
366
+ viewCount: counts.get(ref.uid)?.plays,
367
+ publicationDate: v?.created ?? e.publishedAt ?? undefined,
368
+ };
369
+ }),
370
+ }));
371
+ const xml = buildVideoSitemap(out.map((e) => ({ ...e, videos: e.videos.map((v) => ({ ...v, description: v.description.replace(/<[^>]*>/g, "") })) })));
372
+ await ctx.kv.set(SITEMAP_KEY, { xml, at: Date.now() });
373
+ return xml;
374
+ }
375
+
376
+ // ── Plays and likes ──────────────────────────────────────────────
377
+
378
+ const SALT_KEY = "state:videos:likeSalt";
379
+ let saltMemo: { day: string; salt: string } | null = null;
380
+
381
+ async function dailySalt(ctx: Ctx): Promise<{ day: string; salt: string }> {
382
+ const day = new Date().toISOString().slice(0, 10);
383
+ if (saltMemo?.day === day) return saltMemo;
384
+ let stored = await ctx.kv.get<{ day: string; salt: string }>(SALT_KEY);
385
+ if (stored?.day !== day) {
386
+ const bytes = crypto.getRandomValues(new Uint8Array(24));
387
+ const fresh = { day, salt: [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("") };
388
+ const prev = await ctx.kv.getVersioned<{ day: string; salt: string }>(SALT_KEY);
389
+ const res = await ctx.kv.compareAndSet(SALT_KEY, prev?.revision ?? null, fresh);
390
+ stored = res.applied ? fresh : ((await ctx.kv.get<{ day: string; salt: string }>(SALT_KEY)) ?? fresh);
391
+ }
392
+ saltMemo = stored;
393
+ return stored;
394
+ }
395
+
396
+ /**
397
+ * A per-day pseudonymous visitor id from the IP address alone (it never leaves
398
+ * this function). Not the user agent: visitors choose it, so each new one was
399
+ * a new visitor and could add another play or like.
400
+ *
401
+ * Null when the request has no address (EmDash reads it from Cloudflare's `cf`
402
+ * object, missing in some setups such as local dev or a proxy in front). Then
403
+ * every visitor would hash to one id, sharing a single like and a single play
404
+ * per half hour, so the routes don't count anything instead (see videos/play
405
+ * and videos/like).
406
+ */
407
+ async function visitorHash(ctx: Ctx & { requestMeta: { ip: string | null } }): Promise<{ hash: string; day: string } | null> {
408
+ const ip = ctx.requestMeta.ip?.trim();
409
+ if (!ip) return null;
410
+ const { day, salt } = await dailySalt(ctx);
411
+ return { hash: await sha256Hex(`${ip}|${salt}`), day };
412
+ }
413
+
414
+ const limiter = new EngagementLimiter();
415
+ /**
416
+ * Video uploads go through the site in pieces (videos/upload → videos/upload-chunk): 5 MiB,
417
+ * Stream's minimum tus chunk (a multiple of 256 KiB) and under EmDash's 8 MiB plugin route
418
+ * body cap.
419
+ */
420
+ export const UPLOAD_CHUNK_BYTES = 5 * 1024 * 1024;
421
+ /** How long a started upload can keep sending pieces. */
422
+ const UPLOAD_TTL_MS = 6 * 60 * 60 * 1000;
423
+ export const UPLOAD_UID_HEADER = "x-cw-upload-uid";
424
+ export const UPLOAD_OFFSET_HEADER = "x-cw-upload-offset";
425
+ interface PendingUpload {
426
+ url: string;
427
+ size: number;
428
+ createdAt: number;
429
+ }
430
+ const uploadKey = (uid: string) => `videos:upload:${uid}`;
431
+
432
+ const rateLimited = () => new PluginRouteError("RATE_LIMITED", "Too many requests. Try again in a minute.", 429);
433
+
434
+ /** Videos embedded in published entries: the only ones public routes answer for (drafts stay private). */
435
+ let publishedMemo: { items: EmbedEntry[]; uids: Set<string> } | null = null;
436
+ async function publishedUids(ctx: Ctx): Promise<Set<string>> {
437
+ const items = await allEmbeds(ctx);
438
+ if (publishedMemo?.items !== items) {
439
+ publishedMemo = { items, uids: new Set(items.filter((e) => e.status === "published").flatMap((e) => e.uids)) };
440
+ }
441
+ return publishedMemo.uids;
442
+ }
443
+
444
+ /** Plays already counted by this isolate (visitor+video → time), so reloads don't inflate counts. */
445
+ const recentPlays = new RecentKeys(5000, 30 * 60_000);
446
+ const seenRecently = (key: string) => recentPlays.seen(key);
447
+
448
+ // ── Routes ───────────────────────────────────────────────────────
449
+
450
+ const updateInput = z.object({
451
+ uid: uidSchema,
452
+ name: z.string().max(200).optional(),
453
+ description: z.string().max(5000).optional(),
454
+ posterTime: z.number().min(0).max(86_400).nullable().optional(),
455
+ posterImage: z
456
+ .string()
457
+ .max(2000)
458
+ .refine((v) => v === "" || isImageUrl(v.trim()), "The poster image must be an http(s) URL or a site path starting with /.")
459
+ .nullable()
460
+ .optional(),
461
+ allowedOrigins: z.array(z.string().max(253)).max(50).optional(),
462
+ downloads: z.boolean().optional(),
463
+ /** Comma-separated; kept in Stream's meta.tags like Video Manager. */
464
+ tags: z.string().max(2000).optional(),
465
+ creator: z.string().trim().max(64).optional(),
466
+ });
467
+
468
+ export function videosModule(options: VideosOptions) {
469
+ const maxDuration = Math.min(21_600, Math.max(60, options.maxUploadDurationSeconds ?? 3600));
470
+
471
+ const routes = {
472
+ // ── Admin ───────────────────────────────────────────────
473
+
474
+ "videos/status": {
475
+ permission: "media:upload" as const,
476
+ handler: async (ctx: Ctx) => {
477
+ await requireFeature(ctx, F.main);
478
+ const [api, features, cfg, webhookSecret] = await Promise.all([client(ctx), ctxFeatures(ctx), publicConfig(ctx), ctx.settings.get<string>(SETTINGS.webhookSecret)]);
479
+ return {
480
+ configured: api !== null,
481
+ host: cfg.host,
482
+ features: Object.fromEntries(Object.entries(F).map(([k, id]) => [k, isOn(features, id)])),
483
+ webhook: { subscribed: Boolean(webhookSecret), url: ctx.url("/_emdash/api/plugins/coywolf-pack/videos/webhook") },
484
+ sitemapUrl: ctx.url("/coywolf-video-sitemap.xml"),
485
+ };
486
+ },
487
+ },
488
+
489
+ /** The Videos page's Settings tab (the token is reported as set or not, never returned). */
490
+ "videos/settings": {
491
+ permission: "plugins:manage" as const,
492
+ handler: async (ctx: Ctx) => {
493
+ await requireFeature(ctx, F.main);
494
+ return adminSettings(ctx);
495
+ },
496
+ },
497
+
498
+ "videos/settings/save": definePluginRoute({
499
+ permission: "plugins:manage",
500
+ methods: ["POST"],
501
+ request: { body: "json" },
502
+ handler: async (ctx) => {
503
+ await requireFeature(ctx, F.main);
504
+ const input = parseInput(settingsInput, ctx.input);
505
+ // Only the fields sent are written (the setup card sends just the connection).
506
+ if (input.accountId !== undefined) await ctx.settings.set(SETTINGS.accountId, input.accountId);
507
+ if (input.customerSubdomain !== undefined)
508
+ await ctx.settings.set(SETTINGS.host, input.customerSubdomain ? (normalizeCustomerHost(input.customerSubdomain) ?? "") : "");
509
+ if (input.accentColor !== undefined) await ctx.settings.set(SETTINGS.accent, input.accentColor);
510
+ if (input.backgroundColor !== undefined) await ctx.settings.set(SETTINGS.background, input.backgroundColor);
511
+ if (input.lightEmbed !== undefined) await ctx.settings.set(SETTINGS.lightEmbed, input.lightEmbed);
512
+ if (input.display !== undefined) {
513
+ const current = normalizeDisplay(await ctx.settings.get<unknown>(SETTINGS.display));
514
+ await ctx.settings.set(SETTINGS.display, normalizeDisplay({ ...current, ...input.display }));
515
+ }
516
+ if (input.defaultTags !== undefined) await ctx.settings.set(SETTINGS.defaultTags, parseTags(input.defaultTags).join(", "));
517
+ if (input.clearToken) await ctx.settings.delete(SETTINGS.token);
518
+ else if (input.token) await ctx.settings.set(SETTINGS.token, input.token);
519
+ // The player settings are read with the feature switches: drop both caches.
520
+ invalidateFeatures();
521
+ invalidatePublicConfig();
522
+ await invalidateLibrary(ctx);
523
+ ctx.log.info("Videos settings saved", { tokenChanged: Boolean(input.clearToken || input.token) });
524
+ return adminSettings(ctx);
525
+ },
526
+ }),
527
+
528
+ "videos/test": definePluginRoute({
529
+ permission: "plugins:manage",
530
+ methods: ["POST"],
531
+ request: { body: "json" },
532
+ handler: async (ctx) => {
533
+ await requireFeature(ctx, F.main);
534
+ const api = await requireClient(ctx);
535
+ const videos = await stream(() => api.list(1));
536
+ return { ok: true, message: `Connected to Cloudflare Stream${videos.length ? "" : " (the library is empty)"}.` };
537
+ },
538
+ }),
539
+
540
+ "videos/list": definePluginRoute({
541
+ permission: "media:upload",
542
+ methods: ["POST"],
543
+ request: { body: "json" },
544
+ handler: async (ctx) => {
545
+ await requireFeature(ctx, F.main);
546
+ const { refresh } = parseInput(z.object({ refresh: z.boolean().optional() }), ctx.input ?? {});
547
+ if (!(await client(ctx))) return { configured: false, items: [] };
548
+ const items = await stream(() => library(ctx, refresh === true));
549
+ const uids = items.map((v) => v.uid);
550
+ const [features, meta, entries, cfg] = await Promise.all([ctxFeatures(ctx), metaFor(ctx, uids), allEmbeds(ctx), publicConfig(ctx)]);
551
+ const counts = isOn(features, F.engagement) ? await countsFor(ctx, uids) : new Map<string, Counts>();
552
+ const used = usage(entries);
553
+ return {
554
+ configured: true,
555
+ // Stream lists at most 1,000 videos per call (store.ts library()).
556
+ orphans: orphans(new Set(uids), items.length < 1000, entries),
557
+ items: items.map((v) => {
558
+ const m = meta.get(v.uid);
559
+ return {
560
+ ...v,
561
+ meta: undefined,
562
+ description: m?.description ?? "",
563
+ thumbnail: posterUrl(cfg.host, v.uid, {}, m ?? {}, 320, ctx.site.url || new URL(ctx.request.url).origin),
564
+ plays: counts.get(v.uid)?.plays ?? 0,
565
+ likes: counts.get(v.uid)?.likes ?? 0,
566
+ usedIn: used.get(v.uid) ?? [],
567
+ captions: m?.captions ?? [],
568
+ };
569
+ }),
570
+ };
571
+ },
572
+ }),
573
+
574
+ /** Options for the block's video picker ({ items: [{ id, name }] }, newest first). */
575
+ "videos/options": definePluginRoute({
576
+ permission: "content:edit_own",
577
+ methods: ["POST"],
578
+ request: { body: "json" },
579
+ handler: async (ctx) => {
580
+ await requireFeature(ctx, F.main);
581
+ if (!(await client(ctx))) return { items: [] };
582
+ const items = await stream(() => library(ctx));
583
+ const clock = (s: number) => (s > 0 ? ` (${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")})` : "");
584
+ return { items: items.map((v) => ({ id: v.uid, name: `${v.name}${clock(v.duration)}${v.ready ? "" : " · processing"}` })) };
585
+ },
586
+ }),
587
+
588
+ "videos/detail": definePluginRoute({
589
+ permission: "media:upload",
590
+ methods: ["POST"],
591
+ request: { body: "json" },
592
+ handler: async (ctx) => {
593
+ await requireFeature(ctx, F.main);
594
+ const { uid } = parseInput(z.object({ uid: uidSchema }), ctx.input);
595
+ const api = await requireClient(ctx);
596
+ const meta = await stream(() => refreshVideo(ctx, uid, api));
597
+ let download: { status?: string; url?: string; percent?: number } | null = null;
598
+ try {
599
+ const d = (await api.getDownloads(uid))?.default;
600
+ download = d ? { status: d.status, url: d.url, percent: d.percentComplete } : null;
601
+ if (d?.status === "ready" && d.url && meta?.downloadUrl !== d.url) await patchMeta(ctx, uid, { downloadUrl: d.url, downloadStatus: "ready" });
602
+ } catch {
603
+ download = null;
604
+ }
605
+ return { meta, download };
606
+ },
607
+ }),
608
+
609
+ "videos/update": definePluginRoute({
610
+ permission: "media:edit_any",
611
+ methods: ["POST"],
612
+ request: { body: "json" },
613
+ handler: async (ctx) => {
614
+ await requireFeature(ctx, F.main);
615
+ const input = parseInput(updateInput, ctx.input);
616
+ const api = await requireClient(ctx);
617
+ const fields: Record<string, unknown> = {};
618
+ if (input.name !== undefined || input.allowedOrigins !== undefined || input.tags !== undefined || input.creator !== undefined) {
619
+ const current = await stream(() => api.get(input.uid));
620
+ if (input.name !== undefined || input.tags !== undefined) {
621
+ const meta: Record<string, unknown> = { ...(current.meta ?? {}) };
622
+ if (input.name !== undefined) meta.name = input.name.trim() || input.uid;
623
+ if (input.tags !== undefined) {
624
+ const tags = parseTags(input.tags);
625
+ if (tags.length) meta.tags = tags.join(", ");
626
+ else delete meta.tags;
627
+ }
628
+ fields.meta = meta;
629
+ }
630
+ if (input.allowedOrigins !== undefined) fields.allowedOrigins = input.allowedOrigins.map((o) => o.trim()).filter(Boolean);
631
+ if (input.creator !== undefined) fields.creator = input.creator || null;
632
+ await stream(() => api.update(input.uid, fields));
633
+ }
634
+ const patch: Record<string, unknown> = {};
635
+ if (input.description !== undefined) patch.description = input.description.trim();
636
+ if (input.posterTime !== undefined) patch.posterTime = input.posterTime ?? undefined;
637
+ if (input.posterImage !== undefined) patch.posterImage = input.posterImage?.trim() || undefined;
638
+ let download: { status?: string; url?: string } | null = null;
639
+ if (input.downloads === true) {
640
+ const d = (await stream(() => api.createDownload(input.uid)))?.default;
641
+ download = d ? { status: d.status, url: d.url } : null;
642
+ patch.downloadStatus = d?.status;
643
+ patch.downloadUrl = d?.status === "ready" ? d.url : undefined;
644
+ } else if (input.downloads === false) {
645
+ await stream(() => api.deleteDownload(input.uid));
646
+ patch.downloadStatus = undefined;
647
+ patch.downloadUrl = undefined;
648
+ }
649
+ await patchMeta(ctx, input.uid, patch);
650
+ const meta = await stream(() => refreshVideo(ctx, input.uid, api));
651
+ await invalidateLibrary(ctx);
652
+ await invalidateSitemap(ctx);
653
+ ctx.log.info("Video updated", { uid: input.uid });
654
+ return { meta, download };
655
+ },
656
+ }),
657
+
658
+ /**
659
+ * Starts an upload. EmDash's admin Content-Security-Policy (connect-src 'self', since
660
+ * EmDash 1.3.1) blocks the browser from sending the file to Stream itself, so the
661
+ * browser sends it here in UPLOAD_CHUNK_BYTES pieces (videos/upload-chunk) and the
662
+ * Worker forwards each piece to a tus upload created here. The tus URL stays on the
663
+ * server (KV, by uid): the chunk route only ever forwards to a URL this route made.
664
+ */
665
+ "videos/upload": definePluginRoute({
666
+ permission: "media:upload",
667
+ methods: ["POST"],
668
+ request: { body: "json" },
669
+ handler: async (ctx) => {
670
+ await requireFeature(ctx, F.main);
671
+ const input = parseInput(
672
+ z.object({
673
+ name: z.string().min(1).max(200),
674
+ size: z.number().int().positive(),
675
+ tags: z.string().max(2000).optional(),
676
+ type: z.string().max(200).optional(),
677
+ }),
678
+ ctx.input,
679
+ );
680
+ const { name, size } = input;
681
+ // Stream turns any file it's given into a "video" (a still image becomes a one-frame clip).
682
+ if (input.type !== undefined && !input.type.startsWith("video/")) throw new PluginRouteError("BAD_REQUEST", "Choose a video file (MP4, MOV, WebM…), not an image or other file.", 400);
683
+ const api = await requireClient(ctx);
684
+ await invalidateLibrary(ctx);
685
+ // The upload dialog starts with the default tags (Settings → Uploads).
686
+ const tags = parseTags(input.tags ?? (await ctx.settings.get<string>(SETTINGS.defaultTags)) ?? "").join(", ");
687
+ const meta: Record<string, string> = tags ? { name, tags } : { name };
688
+ const r = await stream(() => api.tusUpload({ length: size, maxDurationSeconds: maxDuration, name }));
689
+ if (!r.uid) throw new PluginRouteError("INTERNAL_ERROR", "Cloudflare Stream didn't return an id for the upload.", 502);
690
+ const uid = r.uid;
691
+ // tus uploads carry only the name; tags go on the waiting video record.
692
+ if (tags) await api.update(uid, { meta }).catch((error: unknown) => ctx.log.warn("Videos: could not tag the upload", { uid, error: String(error) }));
693
+ await ctx.kv.set(uploadKey(uid), { url: r.uploadURL, size, createdAt: Date.now() } satisfies PendingUpload);
694
+ return { method: "proxy", uid, chunkBytes: UPLOAD_CHUNK_BYTES };
695
+ },
696
+ }),
697
+
698
+ /** One piece of an upload started by videos/upload, forwarded to its tus URL. */
699
+ "videos/upload-chunk": definePluginRoute({
700
+ permission: "media:upload",
701
+ methods: ["POST"],
702
+ request: { body: "bytes", maxBytes: UPLOAD_CHUNK_BYTES, headers: [UPLOAD_UID_HEADER, UPLOAD_OFFSET_HEADER] },
703
+ handler: async (ctx) => {
704
+ await requireFeature(ctx, F.main);
705
+ const uid = ctx.request.headers.get(UPLOAD_UID_HEADER) ?? "";
706
+ const offset = Number(ctx.request.headers.get(UPLOAD_OFFSET_HEADER));
707
+ const bytes = ctx.input as Uint8Array;
708
+ if (!/^[a-f0-9]{32}$/.test(uid)) throw new PluginRouteError("BAD_REQUEST", "Unknown upload.", 400);
709
+ const pending = await ctx.kv.get<PendingUpload>(uploadKey(uid));
710
+ if (!pending || Date.now() - pending.createdAt > UPLOAD_TTL_MS) throw new PluginRouteError("NOT_FOUND", "This upload has expired. Start it again.", 404);
711
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw new PluginRouteError("BAD_REQUEST", "Empty upload piece.", 400);
712
+ if (!Number.isInteger(offset) || offset < 0 || offset + bytes.byteLength > pending.size) throw new PluginRouteError("BAD_REQUEST", "Upload piece out of range.", 400);
713
+ const res = await fetch(pending.url, {
714
+ method: "PATCH",
715
+ headers: { "Tus-Resumable": "1.0.0", "Upload-Offset": String(offset), "Content-Type": "application/offset+octet-stream" },
716
+ body: new Uint8Array(bytes),
717
+ });
718
+ if (!res.ok) {
719
+ ctx.log.warn("Videos: Stream refused an upload piece", { uid, offset, status: res.status });
720
+ throw new PluginRouteError("INTERNAL_ERROR", `Cloudflare Stream refused part of the upload (HTTP ${res.status}).`, 502);
721
+ }
722
+ const next = Number(res.headers.get("Upload-Offset") ?? offset + bytes.byteLength);
723
+ if (next >= pending.size) {
724
+ await ctx.kv.delete(uploadKey(uid));
725
+ await invalidateLibrary(ctx);
726
+ }
727
+ return { offset: next, done: next >= pending.size };
728
+ },
729
+ }),
730
+
731
+ /**
732
+ * Delete a video from Stream and the site's copies of its details, counts and captions.
733
+ * Refused while content embeds it, unless `force` (the admin confirms first).
734
+ */
735
+ "videos/delete": definePluginRoute({
736
+ permission: "media:delete_any",
737
+ methods: ["POST"],
738
+ request: { body: "json" },
739
+ handler: async (ctx) => {
740
+ await requireFeature(ctx, F.main);
741
+ const { uid, force } = parseInput(z.object({ uid: uidSchema, force: z.boolean().optional() }), ctx.input);
742
+ const used = usage(await allEmbeds(ctx)).get(uid) ?? [];
743
+ if (used.length && !force) {
744
+ throw PluginRouteError.badRequest(`This video is used in ${used.length} ${used.length === 1 ? "entry" : "entries"}. Remove it from them first, or delete it anyway.`);
745
+ }
746
+ const api = await requireClient(ctx);
747
+ await stream(async () => {
748
+ try {
749
+ await api.remove(uid);
750
+ } catch (error) {
751
+ // Already gone from Stream (deleted in its dashboard): still clear the site's copies.
752
+ if (!(error instanceof StreamError && error.status === 404)) throw error;
753
+ }
754
+ });
755
+ await removeLocal(ctx, uid);
756
+ ctx.log.info("Video deleted", { uid, usedIn: used.length });
757
+ return { deleted: true, usedIn: used.length };
758
+ },
759
+ }),
760
+
761
+ /** Minutes stored in Stream and the account's limit (cached for five minutes). */
762
+ "videos/storage": definePluginRoute({
763
+ permission: "media:upload",
764
+ methods: ["POST"],
765
+ request: { body: "json" },
766
+ handler: async (ctx) => {
767
+ await requireFeature(ctx, F.main);
768
+ const cached = await ctx.kv.get<{ usage: StorageUsage; at: number }>(STORAGE_KEY);
769
+ if (cached && Date.now() - cached.at < 5 * 60_000) return cached.usage;
770
+ const api = await requireClient(ctx);
771
+ const r = await stream(() => api.storageUsage());
772
+ const usage: StorageUsage = { videos: r?.videoCount ?? 0, minutes: r?.totalStorageMinutes ?? 0, limit: r?.totalStorageMinutesLimit ?? 0 };
773
+ await ctx.kv.set(STORAGE_KEY, { usage, at: Date.now() });
774
+ return usage;
775
+ },
776
+ }),
777
+
778
+ /** Rebuild the embed index from all content, one page per call (the admin loops until done). */
779
+ "videos/reindex": definePluginRoute({
780
+ permission: "plugins:manage",
781
+ methods: ["POST"],
782
+ request: { body: "json" },
783
+ handler: async (ctx) => {
784
+ await requireFeature(ctx, F.main);
785
+ const { state } = parseInput(z.object({ state: z.object({ collections: z.array(z.string()), index: z.number().int(), cursor: z.string().nullable(), cleanup: z.number().int().optional() }).nullish() }), ctx.input ?? {});
786
+ if (!ctx.content || !ctx.schema) throw PluginRouteError.internal("Content access is unavailable.");
787
+ const collections = state?.collections ?? (await ctx.schema.listCollections()).map((c) => c.slug);
788
+ let index = state?.index ?? 0;
789
+ let cursor = state?.cursor ?? null;
790
+ let scanned = 0;
791
+ let found = 0;
792
+ const started = Date.now();
793
+ while (index < collections.length && Date.now() - started < 10_000) {
794
+ const page = await ctx.content.list(collections[index], { limit: 50, ...(cursor ? { cursor } : {}) });
795
+ for (const item of page.items) {
796
+ scanned++;
797
+ found += await indexEntry(ctx, collections[index], item as unknown as Record<string, unknown>, false, true);
798
+ }
799
+ if (page.hasMore && page.cursor) cursor = page.cursor;
800
+ else {
801
+ index++;
802
+ cursor = null;
803
+ }
804
+ }
805
+ if (index < collections.length) {
806
+ return { done: false, scanned, found, removed: 0, state: { collections, index, cursor }, progress: `${Math.min(index + 1, collections.length)} of ${collections.length} collections` };
807
+ }
808
+ // Then drop index rows whose entries no longer exist.
809
+ invalidateEmbeds();
810
+ const entries = await allEmbeds(ctx);
811
+ let at = state?.cleanup ?? 0;
812
+ let removed = 0;
813
+ while (at < entries.length && Date.now() - started < 10_000) {
814
+ const e = entries[at++];
815
+ if (!(await ctx.content.get(e.collection, e.entryId))) {
816
+ await embedStore(ctx).delete(embedKey(e.collection, e.entryId));
817
+ removed++;
818
+ }
819
+ }
820
+ if (removed) {
821
+ invalidateEmbeds();
822
+ await invalidateSitemap(ctx);
823
+ }
824
+ const done = at >= entries.length;
825
+ return { done, scanned, found, removed, state: done ? null : { collections, index, cursor, cleanup: at }, progress: "checking for deleted entries" };
826
+ },
827
+ }),
828
+
829
+ "videos/captions/list": definePluginRoute({
830
+ permission: "media:upload",
831
+ methods: ["POST"],
832
+ request: { body: "json" },
833
+ handler: async (ctx) => {
834
+ await requireFeature(ctx, F.captions);
835
+ const { uid } = parseInput(z.object({ uid: uidSchema }), ctx.input);
836
+ const api = await requireClient(ctx);
837
+ return { captions: await stream(() => api.listCaptions(uid)) };
838
+ },
839
+ }),
840
+
841
+ "videos/captions/upload": definePluginRoute({
842
+ permission: "media:edit_any",
843
+ methods: ["POST"],
844
+ request: { body: "json", maxBytes: 2 * 1024 * 1024 },
845
+ handler: async (ctx) => {
846
+ await requireFeature(ctx, F.captions);
847
+ const { uid, lang, vtt } = parseInput(z.object({ uid: uidSchema, lang: langSchema, vtt: z.string().min(8).max(MAX_CAPTION_BYTES) }), ctx.input);
848
+ if (utf8Bytes(vtt) > MAX_CAPTION_BYTES) throw PluginRouteError.badRequest("Caption files must be under 1.5 MB.");
849
+ if (!/^?WEBVTT/.test(vtt)) throw PluginRouteError.badRequest("That isn't a WebVTT file (it must start with WEBVTT).");
850
+ const api = await requireClient(ctx);
851
+ await stream(() => api.uploadCaption(uid, lang, vtt));
852
+ await stream(() => refreshCaptions(ctx, uid, api));
853
+ return { captions: await stream(() => api.listCaptions(uid)) };
854
+ },
855
+ }),
856
+
857
+ "videos/captions/generate": definePluginRoute({
858
+ permission: "media:edit_any",
859
+ methods: ["POST"],
860
+ request: { body: "json" },
861
+ handler: async (ctx) => {
862
+ await requireFeature(ctx, F.captions);
863
+ const { uid, lang } = parseInput(z.object({ uid: uidSchema, lang: langSchema }), ctx.input);
864
+ const api = await requireClient(ctx);
865
+ await stream(() => api.generateCaption(uid, lang));
866
+ await patchMeta(ctx, uid, { captionsPending: true });
867
+ return { captions: await stream(() => api.listCaptions(uid)) };
868
+ },
869
+ }),
870
+
871
+ "videos/captions/delete": definePluginRoute({
872
+ permission: "media:edit_any",
873
+ methods: ["POST"],
874
+ request: { body: "json" },
875
+ handler: async (ctx) => {
876
+ await requireFeature(ctx, F.captions);
877
+ const { uid, lang } = parseInput(z.object({ uid: uidSchema, lang: langSchema }), ctx.input);
878
+ const api = await requireClient(ctx);
879
+ await stream(() => api.deleteCaption(uid, lang));
880
+ await stream(() => refreshCaptions(ctx, uid, api));
881
+ return { captions: await stream(() => api.listCaptions(uid)) };
882
+ },
883
+ }),
884
+
885
+ /** Copy the current tracks into the site (for schema and the public caption files). */
886
+ "videos/captions/refresh": definePluginRoute({
887
+ permission: "media:edit_any",
888
+ methods: ["POST"],
889
+ request: { body: "json" },
890
+ handler: async (ctx) => {
891
+ await requireFeature(ctx, F.captions);
892
+ const { uid } = parseInput(z.object({ uid: uidSchema }), ctx.input);
893
+ const meta = await stream(() => refreshCaptions(ctx, uid));
894
+ return { meta };
895
+ },
896
+ }),
897
+
898
+ "videos/webhook/subscribe": definePluginRoute({
899
+ permission: "plugins:manage",
900
+ methods: ["POST"],
901
+ request: { body: "json" },
902
+ handler: async (ctx) => {
903
+ await requireFeature(ctx, F.webhook);
904
+ const api = await requireClient(ctx);
905
+ const url = ctx.url("/_emdash/api/plugins/coywolf-pack/videos/webhook");
906
+ if (!url.startsWith("https://")) throw PluginRouteError.badRequest(`Stream only calls HTTPS URLs; this site's URL is ${url}.`);
907
+ const result = await stream(() => api.setWebhook(url));
908
+ if (!result?.secret) throw PluginRouteError.internal("Stream didn't return a signing secret.");
909
+ try {
910
+ await ctx.settings.set(SETTINGS.webhookSecret, result.secret);
911
+ } catch {
912
+ await stream(() => api.deleteWebhook());
913
+ throw PluginRouteError.badRequest("Couldn't store the webhook secret. Secret plugin settings need the EMDASH_ENCRYPTION_KEY Worker secret.");
914
+ }
915
+ ctx.log.info("Stream webhook subscribed", { url });
916
+ return { subscribed: true, url };
917
+ },
918
+ }),
919
+
920
+ "videos/webhook/unsubscribe": definePluginRoute({
921
+ permission: "plugins:manage",
922
+ methods: ["POST"],
923
+ request: { body: "json" },
924
+ handler: async (ctx) => {
925
+ await requireFeature(ctx, F.webhook);
926
+ const api = await requireClient(ctx);
927
+ await stream(() => api.deleteWebhook());
928
+ await ctx.settings.delete(SETTINGS.webhookSecret);
929
+ return { subscribed: false };
930
+ },
931
+ }),
932
+
933
+ // ── Public ──────────────────────────────────────────────
934
+
935
+ /** Render data for one block (called in-process by the Astro component). */
936
+ "videos/embed": definePluginRoute({
937
+ public: true,
938
+ methods: ["POST"],
939
+ request: { body: "json", maxBytes: 1024 },
940
+ handler: async (ctx) => {
941
+ // Called once per video block on every render: the switches and the player settings
942
+ // come from per-isolate caches; the video's facts are one D1 batch, shared with the
943
+ // page's schema (videoFacts), so a video page reads them once.
944
+ await requireCachedFeature(ctx, F.main);
945
+ const { uid } = parseInput(z.object({ uid: uidSchema }), ctx.input);
946
+ const [facts, features, cfg] = await Promise.all([videoFacts(ctx, [uid], () => publishedUids(ctx)), cachedCtxFeatures(ctx), publicConfig(ctx)]);
947
+ const fact = facts.get(uid);
948
+ // Only videos embedded in published content get metadata (no probing the library
949
+ // by ID, and nothing about videos that are only in drafts).
950
+ const embedded = fact?.published === true;
951
+ const engagement = embedded && isOn(features, F.engagement);
952
+ const meta = embedded ? (fact?.meta ?? null) : null;
953
+ const counts = engagement ? (fact?.counts ?? { plays: 0, likes: 0 }) : null;
954
+ return {
955
+ ...cfg,
956
+ engagement,
957
+ video: meta
958
+ ? {
959
+ name: meta.name ?? null,
960
+ description: meta.description ?? null,
961
+ duration: meta.duration ?? 0,
962
+ created: meta.created ?? null,
963
+ width: meta.width ?? 0,
964
+ height: meta.height ?? 0,
965
+ posterTime: meta.posterTime ?? null,
966
+ posterImage: meta.posterImage ?? null,
967
+ }
968
+ : null,
969
+ counts,
970
+ watchUrl: watchUrl(cfg.host, uid),
971
+ };
972
+ },
973
+ }),
974
+
975
+ "videos/sitemap": definePluginRoute({
976
+ public: true,
977
+ methods: ["POST"],
978
+ request: { body: "json", maxBytes: 1024 },
979
+ handler: async (ctx) => {
980
+ await requireFeature(ctx, F.sitemap);
981
+ return { xml: await sitemapXml(ctx, ctx.site.url || new URL(ctx.request.url).origin) };
982
+ },
983
+ }),
984
+
985
+ "videos/caption": definePluginRoute({
986
+ public: true,
987
+ methods: ["POST"],
988
+ request: { body: "json", maxBytes: 1024 },
989
+ handler: async (ctx) => {
990
+ await requireFeature(ctx, F.captions);
991
+ const { uid, lang } = parseInput(z.object({ uid: uidSchema, lang: langSchema }), ctx.input);
992
+ const doc = await captionStore(ctx).get(`${uid}:${lang}`);
993
+ if (!doc) throw PluginRouteError.notFound("No such caption track.");
994
+ return { vtt: doc.vtt };
995
+ },
996
+ }),
997
+
998
+ "videos/play": definePluginRoute({
999
+ public: true,
1000
+ methods: ["POST"],
1001
+ request: { body: "json", maxBytes: 512, headers: [ENGAGEMENT_HEADER] },
1002
+ handler: async (ctx) => {
1003
+ await requireCachedFeature(ctx, F.engagement);
1004
+ const { uid } = parseInput(z.object({ uid: uidSchema }), ctx.input);
1005
+ if (!(await publishedUids(ctx)).has(uid)) throw PluginRouteError.notFound("Unknown video.");
1006
+ // The header isn't required here: pages cached before it was added (for days, at the
1007
+ // edge) still send plays without it. A play only bumps a counter, and every address
1008
+ // is rate limited and counted once per half hour per isolate, header or not.
1009
+ // Likes (a stored row per visitor) do require it.
1010
+ const visitor = await visitorHash(ctx);
1011
+ // No address: plays aren't counted (they'd all be one visitor, see visitorHash).
1012
+ if (!visitor) return { plays: ((await statsStore(ctx).get(uid)) ?? { plays: 0 }).plays, counted: false };
1013
+ const { hash } = visitor;
1014
+ if (!limiter.play(hash)) throw rateLimited();
1015
+ if (seenRecently(`${uid}:${hash}`)) return { plays: ((await statsStore(ctx).get(uid)) ?? { plays: 0 }).plays, counted: false };
1016
+ const counts = await bump(ctx, uid, "plays", 1);
1017
+ return { plays: counts.plays, counted: true };
1018
+ },
1019
+ }),
1020
+
1021
+ "videos/like": definePluginRoute({
1022
+ public: true,
1023
+ methods: ["POST"],
1024
+ request: { body: "json", maxBytes: 512, headers: [ENGAGEMENT_HEADER] },
1025
+ handler: async (ctx) => {
1026
+ await requireCachedFeature(ctx, F.engagement);
1027
+ // Only the Coywolf Video script sends this header, so another site can't like (or
1028
+ // unlike) on a visitor's behalf.
1029
+ if (!hasEngagementHeader(ctx.request.headers)) throw PluginRouteError.forbidden("Missing the X-Coywolf-Video header.");
1030
+ const { uid, liked } = parseInput(z.object({ uid: uidSchema, liked: z.boolean().optional() }), ctx.input);
1031
+ if (!(await publishedUids(ctx)).has(uid)) throw PluginRouteError.notFound("Unknown video.");
1032
+ const current = async (isLiked: boolean) => ({ likes: ((await statsStore(ctx).get(uid)) ?? { likes: 0 }).likes, liked: isLiked });
1033
+ const visitor = await visitorHash(ctx);
1034
+ // No address: likes can't be told apart (every visitor would share one), so nothing
1035
+ // is stored and the button shows the count without the visitor's like.
1036
+ if (!visitor) return current(false);
1037
+ const { hash, day } = visitor;
1038
+ if (!limiter.like(hash)) throw rateLimited();
1039
+ const id = await sha256Hex(`${uid}|${hash}`);
1040
+ const likes = (ctx.storage as Record<string, import("emdash").StorageCollection<{ uid: string; day: string }>>)[COLLECTIONS.likes];
1041
+ const action = likeAction(liked, await likes.exists(id));
1042
+ if (action === "keep") return current(Boolean(liked));
1043
+ if (action === "remove") {
1044
+ if (!(await likes.delete(id))) return current(false);
1045
+ const counts = await bump(ctx, uid, "likes", -1);
1046
+ return { likes: counts.likes, liked: false };
1047
+ }
1048
+ // A daily cap on new likes per address: counted in KV (across isolates) and per isolate.
1049
+ const capKey = likesByKey(day, hash);
1050
+ const given = (await ctx.kv.get<number>(capKey)) ?? 0;
1051
+ if (given >= ENGAGEMENT_LIMITS.likesPerDay || !limiter.newLike(hash)) throw rateLimited();
1052
+ const created = await likes.compareAndSet(id, null, { uid, day });
1053
+ if (!created.applied) return current(true);
1054
+ await ctx.kv.set(capKey, given + 1);
1055
+ const counts = await bump(ctx, uid, "likes", 1);
1056
+ return { likes: counts.likes, liked: true };
1057
+ },
1058
+ }),
1059
+
1060
+ "videos/webhook": definePluginRoute({
1061
+ public: true,
1062
+ methods: ["POST"],
1063
+ request: { body: "text", maxBytes: 64 * 1024, headers: ["webhook-signature"] },
1064
+ handler: async (ctx) => {
1065
+ await requireFeature(ctx, F.webhook);
1066
+ const secretValue = await ctx.settings.get<string>(SETTINGS.webhookSecret);
1067
+ if (!secretValue) throw PluginRouteError.forbidden("The webhook isn't subscribed.");
1068
+ const body = typeof ctx.input === "string" ? ctx.input : "";
1069
+ if (!(await verifyWebhookSignature(ctx.request.headers.get("webhook-signature"), body, secretValue))) {
1070
+ throw PluginRouteError.forbidden("Invalid webhook signature.");
1071
+ }
1072
+ let uid: unknown = null;
1073
+ try {
1074
+ uid = (JSON.parse(body) as { uid?: unknown }).uid;
1075
+ } catch {
1076
+ uid = null;
1077
+ }
1078
+ await invalidateLibrary(ctx);
1079
+ await invalidateSitemap(ctx);
1080
+ if (isUid(uid)) {
1081
+ try {
1082
+ const meta = await refreshVideo(ctx, uid);
1083
+ if (meta?.downloadStatus === "inprogress") await refreshDownload(ctx, uid);
1084
+ } catch (error) {
1085
+ ctx.log.warn("Videos: webhook refresh failed", { uid, error: String(error) });
1086
+ }
1087
+ }
1088
+ return { ok: true };
1089
+ },
1090
+ }),
1091
+ };
1092
+
1093
+ const hooks = {
1094
+ "content:afterSave": (event: { content: Record<string, unknown>; collection: string }, ctx: Ctx) => indexEntry(ctx, event.collection, event.content),
1095
+ "content:afterPublish": (event: { content: Record<string, unknown>; collection: string }, ctx: Ctx) => indexEntry(ctx, event.collection, event.content),
1096
+ "content:afterUnpublish": (event: { content: Record<string, unknown>; collection: string }, ctx: Ctx) => indexEntry(ctx, event.collection, event.content),
1097
+ "content:afterRestore": (event: { content: Record<string, unknown>; collection: string }, ctx: Ctx) => indexEntry(ctx, event.collection, event.content),
1098
+ "content:afterDelete": (event: { id: string; collection: string }, ctx: Ctx) => unindexEntry(ctx, event.collection, event.id),
1099
+ "page:metadata": pageMetadata,
1100
+ };
1101
+
1102
+ const tasks = [
1103
+ {
1104
+ // MP4 downloads take a while to generate; record the URL (schema contentUrl) once ready.
1105
+ name: TASKS.downloads,
1106
+ schedule: "@hourly",
1107
+ feature: F.main,
1108
+ handler: async (ctx: Ctx) => {
1109
+ const pending = await metaStore(ctx).query({ where: { downloadStatus: "inprogress" }, limit: 20 });
1110
+ if (!pending.items.length) return;
1111
+ const api = await client(ctx);
1112
+ if (!api) return;
1113
+ for (const item of pending.items) {
1114
+ try {
1115
+ await refreshDownload(ctx, item.id, api);
1116
+ } catch (error) {
1117
+ ctx.log.warn("Videos: download refresh failed", { uid: item.id, error: String(error) });
1118
+ }
1119
+ }
1120
+ await invalidateSitemap(ctx);
1121
+ },
1122
+ },
1123
+ {
1124
+ name: TASKS.captions,
1125
+ schedule: "@hourly",
1126
+ feature: F.captions,
1127
+ handler: async (ctx: Ctx) => {
1128
+ const pending = await metaStore(ctx).query({ where: { captionsPending: true }, limit: 10 });
1129
+ const api = await client(ctx);
1130
+ if (!api) return;
1131
+ for (const item of pending.items) {
1132
+ try {
1133
+ await refreshCaptions(ctx, item.id, api);
1134
+ } catch (error) {
1135
+ ctx.log.warn("Videos: caption refresh failed", { uid: item.id, error: String(error) });
1136
+ }
1137
+ }
1138
+ },
1139
+ },
1140
+ {
1141
+ name: TASKS.likes,
1142
+ schedule: "@daily",
1143
+ feature: F.engagement,
1144
+ handler: async (ctx: Ctx) => {
1145
+ // Like rows only dedupe within a day (the visitor hash rotates daily); drop old ones.
1146
+ const cutoff = new Date(Date.now() - 2 * 86_400_000).toISOString().slice(0, 10);
1147
+ const likes = (ctx.storage as Record<string, import("emdash").StorageCollection<unknown>>)[COLLECTIONS.likes];
1148
+ for (let i = 0; i < 50; i++) {
1149
+ const old = await likes.query({ where: { day: { lt: cutoff } }, limit: 90 });
1150
+ if (!old.items.length) break;
1151
+ await likes.deleteMany(old.items.map((x) => x.id));
1152
+ }
1153
+ // And the per-address daily like counts from before then.
1154
+ for (const { key } of await ctx.kv.list(LIKES_BY_PREFIX)) if (staleLikesByKey(key, cutoff)) await ctx.kv.delete(key);
1155
+ },
1156
+ },
1157
+ ];
1158
+
1159
+ return { routes, hooks, tasks };
1160
+ }