@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Videos module storage: settings, the Stream library cache, per-video
|
|
3
|
+
* metadata, play/like counters and the embed index (which entries embed
|
|
4
|
+
* which videos).
|
|
5
|
+
*/
|
|
6
|
+
import { STORAGE_IN_LIMIT, deleteManyBatched, getManyBatched } from "../core/storage.js";
|
|
7
|
+
import type { PluginContext, StorageCollection } from "emdash";
|
|
8
|
+
|
|
9
|
+
import { PLUGIN_ID, isCurrent, readSiteSetting, registerSiteSetting, settingsEpoch } from "../core/features.js";
|
|
10
|
+
import { batchedAll } from "../core/d1-batch.js";
|
|
11
|
+
import { peekRequestMemo, seedRequestMemo } from "../core/request-memo.js";
|
|
12
|
+
import { secret, workerEnv } from "../shared.js";
|
|
13
|
+
import {
|
|
14
|
+
type VideoFacts,
|
|
15
|
+
type VideoRef,
|
|
16
|
+
customerHostFromUrl,
|
|
17
|
+
isHex,
|
|
18
|
+
MAX_CAPTION_BYTES,
|
|
19
|
+
isUid,
|
|
20
|
+
normalizeCustomerHost,
|
|
21
|
+
parseTags,
|
|
22
|
+
utf8Bytes,
|
|
23
|
+
vttToTranscript,
|
|
24
|
+
} from "./lib.js";
|
|
25
|
+
import { type VideoDisplay, normalizeDisplay } from "./render.js";
|
|
26
|
+
import { type StreamClient, type StreamCredentials, type StreamVideo, streamClient } from "./stream.js";
|
|
27
|
+
|
|
28
|
+
export const COLLECTIONS = {
|
|
29
|
+
meta: "videosMeta",
|
|
30
|
+
stats: "videosStats",
|
|
31
|
+
likes: "videosLikes",
|
|
32
|
+
embeds: "videosEmbeds",
|
|
33
|
+
captions: "videosCaptions",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export const STORAGE = {
|
|
37
|
+
[COLLECTIONS.meta]: { indexes: ["captionsPending", "downloadStatus"] },
|
|
38
|
+
[COLLECTIONS.stats]: { indexes: [] },
|
|
39
|
+
[COLLECTIONS.likes]: { indexes: ["day"] },
|
|
40
|
+
[COLLECTIONS.embeds]: { indexes: ["status", "collection"] },
|
|
41
|
+
[COLLECTIONS.captions]: { indexes: ["uid"] },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const SETTINGS = {
|
|
45
|
+
accountId: "videosAccountId",
|
|
46
|
+
token: "videosApiToken",
|
|
47
|
+
host: "videosCustomerSubdomain",
|
|
48
|
+
accent: "videosAccentColor",
|
|
49
|
+
background: "videosBackgroundColor",
|
|
50
|
+
webhookSecret: "videosWebhookSecret",
|
|
51
|
+
/** Poster first, player when needed (default on; false loads the player with the page). */
|
|
52
|
+
lightEmbed: "videosLightEmbed",
|
|
53
|
+
/** Views & likes and Appearance (one object, see render.ts VideoDisplay). */
|
|
54
|
+
display: "videosDisplay",
|
|
55
|
+
/** Tags added to every upload (comma-separated). */
|
|
56
|
+
defaultTags: "videosDefaultTags",
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The API token and webhook secret are declared secret in the plugin's
|
|
61
|
+
* settingsSchema (src/core/secrets.ts); the account ID and player settings
|
|
62
|
+
* are plain values edited on the Videos page → Settings, read with fallbacks.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
export type Ctx = PluginContext;
|
|
66
|
+
|
|
67
|
+
const coll = <T>(ctx: Ctx, name: string) => (ctx.storage as Record<string, StorageCollection<T>>)[name];
|
|
68
|
+
|
|
69
|
+
// ── Settings ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface PublicConfig {
|
|
72
|
+
host: string | null;
|
|
73
|
+
accent: string | null;
|
|
74
|
+
background: string | null;
|
|
75
|
+
/** Show the poster and load Stream's player only when it's needed. */
|
|
76
|
+
lightEmbed: boolean;
|
|
77
|
+
/** What blocks show by default, and how the figure looks. */
|
|
78
|
+
display: VideoDisplay;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let publicCache: { value: PublicConfig; at: number; epoch: number } | null = null;
|
|
82
|
+
/** Kept until a pack settings save (here or in another isolate) or ten minutes. */
|
|
83
|
+
const PUBLIC_TTL = 10 * 60_000;
|
|
84
|
+
/** The player settings public pages need, read with the feature switches (no query of their own). */
|
|
85
|
+
const PUBLIC_SETTINGS = [SETTINGS.host, SETTINGS.accent, SETTINGS.background, SETTINGS.lightEmbed, SETTINGS.display] as const;
|
|
86
|
+
for (const key of PUBLIC_SETTINGS) registerSiteSetting(key);
|
|
87
|
+
|
|
88
|
+
export function invalidatePublicConfig(): void {
|
|
89
|
+
publicCache = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const HOST_KEY = "state:videos:customerHost";
|
|
93
|
+
|
|
94
|
+
/** The player and display settings: from the switches' cached query, else one read each through the plugin context. */
|
|
95
|
+
async function playerSettings(ctx: Ctx): Promise<Array<unknown>> {
|
|
96
|
+
const shared = await Promise.all(PUBLIC_SETTINGS.map((key) => readSiteSetting(key).catch(() => null)));
|
|
97
|
+
if (shared.every(Boolean)) return shared.map((read) => read?.value ?? null);
|
|
98
|
+
return Promise.all(PUBLIC_SETTINGS.map((key) => ctx.settings.get<unknown>(key)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Player settings safe to use on public pages (cached per isolate until settings change, ten minutes at most). */
|
|
102
|
+
export async function publicConfig(ctx: Ctx): Promise<PublicConfig> {
|
|
103
|
+
if (publicCache && isCurrent(publicCache, PUBLIC_TTL)) return publicCache.value;
|
|
104
|
+
const epoch = settingsEpoch();
|
|
105
|
+
const [host, accent, background, lightEmbed, display] = await playerSettings(ctx);
|
|
106
|
+
const value: PublicConfig = {
|
|
107
|
+
host: normalizeCustomerHost(host as string | null) ?? normalizeCustomerHost(await ctx.kv.get<string>(HOST_KEY)),
|
|
108
|
+
accent: isHex(accent) ? accent : null,
|
|
109
|
+
background: isHex(background) ? background : null,
|
|
110
|
+
lightEmbed: lightEmbed !== false,
|
|
111
|
+
display: normalizeDisplay(display),
|
|
112
|
+
};
|
|
113
|
+
if (epoch === settingsEpoch()) publicCache = { value, at: Date.now(), epoch };
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Remember the customer host seen in Stream's own URLs when none is configured. */
|
|
118
|
+
async function learnHost(ctx: Ctx, videos: StreamVideo[]): Promise<void> {
|
|
119
|
+
for (const v of videos) {
|
|
120
|
+
const host = customerHostFromUrl(v.thumbnail) ?? customerHostFromUrl(v.preview) ?? customerHostFromUrl(v.playback?.hls);
|
|
121
|
+
if (!host) continue;
|
|
122
|
+
if ((await ctx.kv.get<string>(HOST_KEY)) !== host) {
|
|
123
|
+
await ctx.kv.set(HOST_KEY, host);
|
|
124
|
+
invalidatePublicConfig();
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function credentials(ctx: Ctx): Promise<StreamCredentials | null> {
|
|
131
|
+
const [accountId, token] = await Promise.all([ctx.settings.get<string>(SETTINGS.accountId), ctx.settings.get<string>(SETTINGS.token)]);
|
|
132
|
+
let env: Record<string, unknown> = {};
|
|
133
|
+
try {
|
|
134
|
+
env = await workerEnv();
|
|
135
|
+
} catch {
|
|
136
|
+
env = {};
|
|
137
|
+
}
|
|
138
|
+
const a = accountId?.trim() || secret(env, "CF_ACCOUNT_ID");
|
|
139
|
+
const t = token?.trim() || secret(env, "CF_STREAM_TOKEN");
|
|
140
|
+
return a && t ? { accountId: a, token: t } : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The saved settings for the Videos page's Settings tab (the token only as set / not set). */
|
|
144
|
+
export async function adminSettings(ctx: Ctx) {
|
|
145
|
+
const [accountId, token, host, accent, background, lightEmbed, display, defaultTags] = await Promise.all([
|
|
146
|
+
ctx.settings.get<string>(SETTINGS.accountId),
|
|
147
|
+
ctx.settings.get<string>(SETTINGS.token).catch(() => null),
|
|
148
|
+
ctx.settings.get<string>(SETTINGS.host),
|
|
149
|
+
ctx.settings.get<string>(SETTINGS.accent),
|
|
150
|
+
ctx.settings.get<string>(SETTINGS.background),
|
|
151
|
+
ctx.settings.get<boolean>(SETTINGS.lightEmbed),
|
|
152
|
+
ctx.settings.get<unknown>(SETTINGS.display),
|
|
153
|
+
ctx.settings.get<string>(SETTINGS.defaultTags),
|
|
154
|
+
]);
|
|
155
|
+
let env: Record<string, unknown> = {};
|
|
156
|
+
try {
|
|
157
|
+
env = await workerEnv();
|
|
158
|
+
} catch {
|
|
159
|
+
env = {};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
accountId: accountId ?? "",
|
|
163
|
+
tokenSet: Boolean(token?.trim()),
|
|
164
|
+
customerSubdomain: host ?? "",
|
|
165
|
+
accentColor: accent ?? "",
|
|
166
|
+
backgroundColor: background ?? "",
|
|
167
|
+
lightEmbed: lightEmbed !== false,
|
|
168
|
+
display: normalizeDisplay(display),
|
|
169
|
+
defaultTags: defaultTags ?? "",
|
|
170
|
+
/** Worker variables used when the settings are empty. */
|
|
171
|
+
envAccountId: Boolean(secret(env, "CF_ACCOUNT_ID")),
|
|
172
|
+
envToken: Boolean(secret(env, "CF_STREAM_TOKEN")),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function client(ctx: Ctx): Promise<StreamClient | null> {
|
|
177
|
+
const creds = await credentials(ctx);
|
|
178
|
+
return creds ? streamClient(creds) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Library cache ────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export interface LibraryVideo {
|
|
184
|
+
uid: string;
|
|
185
|
+
name: string;
|
|
186
|
+
duration: number;
|
|
187
|
+
created: string | null;
|
|
188
|
+
width: number;
|
|
189
|
+
height: number;
|
|
190
|
+
state: string;
|
|
191
|
+
ready: boolean;
|
|
192
|
+
size: number;
|
|
193
|
+
allowedOrigins: string[];
|
|
194
|
+
/** From Stream's meta.tags (Video Manager's tags). */
|
|
195
|
+
tags: string[];
|
|
196
|
+
creator: string;
|
|
197
|
+
meta: Record<string, unknown>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const LIST_KEY = "cache:videos:list:v2";
|
|
201
|
+
const LIST_TTL = 5 * 60_000;
|
|
202
|
+
let listMemo: { items: LibraryVideo[]; at: number } | null = null;
|
|
203
|
+
|
|
204
|
+
export async function invalidateLibrary(ctx: Ctx): Promise<void> {
|
|
205
|
+
listMemo = null;
|
|
206
|
+
await ctx.kv.delete(LIST_KEY);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function slim(v: StreamVideo): LibraryVideo {
|
|
210
|
+
const name = typeof v.meta?.name === "string" && v.meta.name ? (v.meta.name as string) : v.uid;
|
|
211
|
+
return {
|
|
212
|
+
uid: v.uid,
|
|
213
|
+
name,
|
|
214
|
+
duration: typeof v.duration === "number" && v.duration > 0 ? v.duration : 0,
|
|
215
|
+
created: v.created ?? null,
|
|
216
|
+
width: v.input?.width ?? 0,
|
|
217
|
+
height: v.input?.height ?? 0,
|
|
218
|
+
state: v.status?.state ?? "unknown",
|
|
219
|
+
ready: v.readyToStream === true,
|
|
220
|
+
size: v.size ?? 0,
|
|
221
|
+
allowedOrigins: v.allowedOrigins ?? [],
|
|
222
|
+
tags: parseTags(v.meta?.tags),
|
|
223
|
+
creator: typeof v.creator === "string" ? v.creator : "",
|
|
224
|
+
meta: v.meta ?? {},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The Stream library: isolate memory → plugin KV (5 minutes) → one Stream API call. */
|
|
229
|
+
export async function library(ctx: Ctx, force = false): Promise<LibraryVideo[]> {
|
|
230
|
+
if (!force && listMemo && Date.now() - listMemo.at < LIST_TTL) return listMemo.items;
|
|
231
|
+
if (!force) {
|
|
232
|
+
const cached = await ctx.kv.get<{ items: LibraryVideo[]; at: number }>(LIST_KEY);
|
|
233
|
+
if (cached && Date.now() - cached.at < LIST_TTL) {
|
|
234
|
+
listMemo = cached;
|
|
235
|
+
return cached.items;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const api = await client(ctx);
|
|
239
|
+
if (!api) throw new Error("Connect Cloudflare Stream first (account ID and API token in the plugin settings).");
|
|
240
|
+
const videos = await api.list();
|
|
241
|
+
await learnHost(ctx, videos);
|
|
242
|
+
const items = videos.map(slim);
|
|
243
|
+
listMemo = { items, at: Date.now() };
|
|
244
|
+
await ctx.kv.set(LIST_KEY, listMemo);
|
|
245
|
+
await syncMeta(ctx, items);
|
|
246
|
+
return items;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Per-video metadata ───────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
export interface VideoMeta extends VideoFacts {
|
|
252
|
+
ready?: boolean;
|
|
253
|
+
allowedOrigins?: string[];
|
|
254
|
+
downloadStatus?: string;
|
|
255
|
+
captionsPending?: boolean;
|
|
256
|
+
captionsChecked?: string;
|
|
257
|
+
updatedAt?: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const metaStore = (ctx: Ctx) => coll<VideoMeta>(ctx, COLLECTIONS.meta);
|
|
261
|
+
|
|
262
|
+
const SYNCED: Array<keyof VideoMeta> = ["name", "duration", "created", "width", "height", "ready"];
|
|
263
|
+
|
|
264
|
+
function fromLibrary(v: LibraryVideo): Partial<VideoMeta> {
|
|
265
|
+
return { name: v.name, duration: v.duration, created: v.created ?? undefined, width: v.width, height: v.height, ready: v.ready, allowedOrigins: v.allowedOrigins };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Write Stream's facts into the metadata docs that changed (one read, at most one batched write). */
|
|
269
|
+
async function syncMeta(ctx: Ctx, items: LibraryVideo[]): Promise<void> {
|
|
270
|
+
const store = metaStore(ctx);
|
|
271
|
+
for (let i = 0; i < items.length; i += STORAGE_IN_LIMIT) {
|
|
272
|
+
const chunk = items.slice(i, i + STORAGE_IN_LIMIT);
|
|
273
|
+
const existing = await store.getMany(chunk.map((v) => v.uid));
|
|
274
|
+
const changed: Array<{ id: string; data: VideoMeta }> = [];
|
|
275
|
+
for (const v of chunk) {
|
|
276
|
+
const prev = existing.get(v.uid);
|
|
277
|
+
const next = { ...(prev ?? { uid: v.uid }), ...fromLibrary(v), uid: v.uid } as VideoMeta;
|
|
278
|
+
if (!prev || SYNCED.some((k) => prev[k] !== next[k])) changed.push({ id: v.uid, data: { ...next, updatedAt: new Date().toISOString() } });
|
|
279
|
+
}
|
|
280
|
+
if (changed.length) await store.putMany(changed);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Re-read one video from Stream into its metadata doc. */
|
|
285
|
+
export async function refreshVideo(ctx: Ctx, uid: string, api?: StreamClient | null): Promise<VideoMeta | null> {
|
|
286
|
+
const stream = api ?? (await client(ctx));
|
|
287
|
+
if (!stream || !isUid(uid)) return null;
|
|
288
|
+
const video = await stream.get(uid);
|
|
289
|
+
if (!video) return null;
|
|
290
|
+
await learnHost(ctx, [video]);
|
|
291
|
+
const store = metaStore(ctx);
|
|
292
|
+
const prev = await store.get(uid);
|
|
293
|
+
const next: VideoMeta = { ...(prev ?? { uid }), ...fromLibrary(slim(video)), uid, updatedAt: new Date().toISOString() };
|
|
294
|
+
await store.put(uid, next);
|
|
295
|
+
return next;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function patchMeta(ctx: Ctx, uid: string, patch: Partial<VideoMeta>): Promise<VideoMeta> {
|
|
299
|
+
const store = metaStore(ctx);
|
|
300
|
+
const prev = (await store.get(uid)) ?? { uid };
|
|
301
|
+
const next: VideoMeta = { ...prev, ...patch, uid, updatedAt: new Date().toISOString() };
|
|
302
|
+
for (const [k, v] of Object.entries(next)) if (v === undefined || v === null || v === "") delete (next as unknown as Record<string, unknown>)[k];
|
|
303
|
+
next.uid = uid;
|
|
304
|
+
await store.put(uid, next);
|
|
305
|
+
return next;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Check a pending MP4 download and record its URL once Stream has it ready. */
|
|
309
|
+
export async function refreshDownload(ctx: Ctx, uid: string, api?: StreamClient | null): Promise<VideoMeta | null> {
|
|
310
|
+
const stream = api ?? (await client(ctx));
|
|
311
|
+
if (!stream) return null;
|
|
312
|
+
let d: { status?: string; url?: string } | undefined;
|
|
313
|
+
try {
|
|
314
|
+
d = (await stream.getDownloads(uid))?.default;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if ((error as { status?: number }).status === 404) return patchMeta(ctx, uid, { downloadStatus: undefined, downloadUrl: undefined });
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
if (!d) return patchMeta(ctx, uid, { downloadStatus: undefined, downloadUrl: undefined });
|
|
320
|
+
return patchMeta(ctx, uid, { downloadStatus: d.status, downloadUrl: d.status === "ready" ? d.url : undefined });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Captions cache ───────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export interface CaptionDoc {
|
|
326
|
+
uid: string;
|
|
327
|
+
language: string;
|
|
328
|
+
label: string;
|
|
329
|
+
vtt: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const captionStore = (ctx: Ctx) => coll<CaptionDoc>(ctx, COLLECTIONS.captions);
|
|
333
|
+
|
|
334
|
+
/** Copy a video's ready caption tracks (and a transcript) from Stream, so public pages never call the API. */
|
|
335
|
+
export async function refreshCaptions(ctx: Ctx, uid: string, api?: StreamClient | null): Promise<VideoMeta | null> {
|
|
336
|
+
const stream = api ?? (await client(ctx));
|
|
337
|
+
if (!stream) return null;
|
|
338
|
+
const list = await stream.listCaptions(uid);
|
|
339
|
+
const ready = list.filter((c) => (c.status ?? "ready") === "ready" && /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(c.language)).slice(0, 10);
|
|
340
|
+
const pending = list.some((c) => c.status === "inprogress");
|
|
341
|
+
const store = captionStore(ctx);
|
|
342
|
+
const docs: CaptionDoc[] = [];
|
|
343
|
+
for (const c of ready) {
|
|
344
|
+
try {
|
|
345
|
+
const vtt = await stream.captionVtt(uid, c.language);
|
|
346
|
+
if (utf8Bytes(vtt) > MAX_CAPTION_BYTES) {
|
|
347
|
+
ctx.log.warn("Videos: caption track too large to copy, skipped", { uid, language: c.language, bytes: utf8Bytes(vtt) });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
docs.push({ uid, language: c.language, label: c.label || c.language, vtt });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
ctx.log.warn("Videos: caption download failed", { uid, language: c.language, error: String(error) });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const old = await store.query({ where: { uid }, limit: 50 });
|
|
356
|
+
const keep = new Set(docs.map((d) => `${uid}:${d.language}`));
|
|
357
|
+
const stale = old.items.map((i) => i.id).filter((id) => !keep.has(id));
|
|
358
|
+
if (stale.length) await deleteManyBatched(store, stale);
|
|
359
|
+
if (docs.length) await store.putMany(docs.map((d) => ({ id: `${uid}:${d.language}`, data: d })));
|
|
360
|
+
const preferred = docs.find((d) => d.language.toLowerCase().startsWith((ctx.site.locale || "en").slice(0, 2).toLowerCase())) ?? docs[0];
|
|
361
|
+
return patchMeta(ctx, uid, {
|
|
362
|
+
captions: docs.map((d) => ({ language: d.language, label: d.label })),
|
|
363
|
+
transcript: preferred ? vttToTranscript(preferred.vtt).slice(0, 50_000) : undefined,
|
|
364
|
+
captionsPending: pending || undefined,
|
|
365
|
+
captionsChecked: new Date().toISOString(),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Counters ─────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
export interface Counts {
|
|
372
|
+
plays: number;
|
|
373
|
+
likes: number;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const statsStore = (ctx: Ctx) => coll<Counts>(ctx, COLLECTIONS.stats);
|
|
377
|
+
|
|
378
|
+
/** Atomically add to a counter, creating the row on first use. */
|
|
379
|
+
export async function bump(ctx: Ctx, uid: string, field: keyof Counts, by: 1 | -1): Promise<Counts> {
|
|
380
|
+
const store = statsStore(ctx);
|
|
381
|
+
const args = by > 0 ? { where: {}, delta: { [field]: { inc: 1 } } } : { where: { [field]: { gte: 1 } }, delta: { [field]: { dec: 1 } } };
|
|
382
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
383
|
+
const r = await store.updateIf(uid, args as Parameters<typeof store.updateIf>[1]);
|
|
384
|
+
if (r.applied) return r.data;
|
|
385
|
+
if (by < 0) return (await store.get(uid)) ?? { plays: 0, likes: 0 };
|
|
386
|
+
const init: Counts = { plays: 0, likes: 0, [field]: 1 };
|
|
387
|
+
const c = await store.compareAndSet(uid, null, init);
|
|
388
|
+
if (c.applied) return init;
|
|
389
|
+
}
|
|
390
|
+
return (await store.get(uid)) ?? { plays: 0, likes: 0 };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function countsFor(ctx: Ctx, uids: string[]): Promise<Map<string, Counts>> {
|
|
394
|
+
const out = new Map<string, Counts>();
|
|
395
|
+
const store = statsStore(ctx);
|
|
396
|
+
for (const [k, v] of await getManyBatched(store, uids)) out.set(k, v);
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** What a page render needs about one video. */
|
|
401
|
+
export interface PageVideoFacts {
|
|
402
|
+
/** Embedded in a published entry (public routes only answer for these). */
|
|
403
|
+
published: boolean;
|
|
404
|
+
meta: VideoMeta | null;
|
|
405
|
+
counts: Counts | null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const factsKey = (uid: string) => `coywolf-video-facts:${uid}`;
|
|
409
|
+
|
|
410
|
+
/** One D1 batch for many videos: their metadata and counters, and which are embedded in published entries. */
|
|
411
|
+
async function readFactsD1(db: D1Database, uids: string[]): Promise<Map<string, PageVideoFacts>> {
|
|
412
|
+
const marks = uids.map(() => "?").join(",");
|
|
413
|
+
// Both go out with the page's other pack reads of this tick (one D1 batch).
|
|
414
|
+
const [docs, published] = await Promise.all([
|
|
415
|
+
batchedAll<{ collection: string; id: string; data: string | null }>(
|
|
416
|
+
db,
|
|
417
|
+
db
|
|
418
|
+
.prepare(`SELECT collection, id, data FROM _plugin_storage WHERE plugin_id = ? AND collection IN (?, ?) AND id IN (${marks})`)
|
|
419
|
+
.bind(PLUGIN_ID, COLLECTIONS.meta, COLLECTIONS.stats, ...uids),
|
|
420
|
+
),
|
|
421
|
+
batchedAll<{ uid: string }>(
|
|
422
|
+
db,
|
|
423
|
+
db
|
|
424
|
+
.prepare(
|
|
425
|
+
`SELECT DISTINCT j.value AS uid FROM _plugin_storage AS s, json_each(s.data, '$.uids') AS j WHERE s.plugin_id = ? AND s.collection = ? AND json_extract(s.data, '$.status') = 'published' AND j.value IN (${marks})`,
|
|
426
|
+
)
|
|
427
|
+
.bind(PLUGIN_ID, COLLECTIONS.embeds, ...uids),
|
|
428
|
+
),
|
|
429
|
+
]);
|
|
430
|
+
const out = new Map<string, PageVideoFacts>(uids.map((uid) => [uid, { published: false, meta: null, counts: null }]));
|
|
431
|
+
for (const row of docs) {
|
|
432
|
+
const facts = out.get(row.id);
|
|
433
|
+
if (!facts || !row.data) continue;
|
|
434
|
+
try {
|
|
435
|
+
if (row.collection === COLLECTIONS.meta) facts.meta = JSON.parse(row.data) as VideoMeta;
|
|
436
|
+
else facts.counts = JSON.parse(row.data) as Counts;
|
|
437
|
+
} catch {
|
|
438
|
+
// An unreadable doc reads as missing.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const row of published) {
|
|
442
|
+
const facts = out.get(row.uid);
|
|
443
|
+
if (facts) facts.published = true;
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function readFactsCtx(ctx: Ctx, uids: string[], publishedUids: () => Promise<Set<string>>): Promise<Map<string, PageVideoFacts>> {
|
|
449
|
+
const [published, meta, counts] = await Promise.all([publishedUids(), getManyBatched(metaStore(ctx), uids), getManyBatched(statsStore(ctx), uids)]);
|
|
450
|
+
return new Map(uids.map((uid) => [uid, { published: published.has(uid), meta: meta.get(uid) ?? null, counts: counts.get(uid) ?? null }]));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Facts about these videos for a page render, shared within the request: the
|
|
455
|
+
* page:metadata hook (VideoObject schema) and every Coywolf Video block on
|
|
456
|
+
* the page (the videos/embed route) read each video once, in one D1 batch
|
|
457
|
+
* for all the videos asked for together. `publishedUids` is the fallback
|
|
458
|
+
* check when there's no D1 binding.
|
|
459
|
+
*/
|
|
460
|
+
export async function videoFacts(ctx: Ctx, uids: string[], publishedUids: () => Promise<Set<string>>): Promise<Map<string, PageVideoFacts>> {
|
|
461
|
+
const unique = [...new Set(uids)].slice(0, STORAGE_IN_LIMIT);
|
|
462
|
+
const pending = new Map<string, Promise<PageVideoFacts>>();
|
|
463
|
+
const missing: string[] = [];
|
|
464
|
+
for (const uid of unique) {
|
|
465
|
+
const hit = peekRequestMemo<PageVideoFacts>(factsKey(uid));
|
|
466
|
+
if (hit) pending.set(uid, hit);
|
|
467
|
+
else missing.push(uid);
|
|
468
|
+
}
|
|
469
|
+
if (missing.length) {
|
|
470
|
+
const batch = (async () => {
|
|
471
|
+
let db: D1Database | undefined;
|
|
472
|
+
try {
|
|
473
|
+
db = (await workerEnv()).DB as D1Database | undefined;
|
|
474
|
+
} catch {
|
|
475
|
+
db = undefined; // Not on Workers.
|
|
476
|
+
}
|
|
477
|
+
if (db && typeof db.prepare === "function") {
|
|
478
|
+
try {
|
|
479
|
+
return await readFactsD1(db, missing);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
ctx.log?.warn?.("Videos: batched read failed; reading one by one", { error: String(error) });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return readFactsCtx(ctx, missing, publishedUids);
|
|
485
|
+
})();
|
|
486
|
+
for (const uid of missing) {
|
|
487
|
+
const one = batch.then((m) => m.get(uid) as PageVideoFacts);
|
|
488
|
+
seedRequestMemo(factsKey(uid), one);
|
|
489
|
+
pending.set(uid, one);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const entries = [...pending];
|
|
493
|
+
const values = await Promise.all(entries.map(([, facts]) => facts));
|
|
494
|
+
return new Map(entries.map(([uid], i) => [uid, values[i]]));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function metaFor(ctx: Ctx, uids: string[]): Promise<Map<string, VideoMeta>> {
|
|
498
|
+
const out = new Map<string, VideoMeta>();
|
|
499
|
+
const store = metaStore(ctx);
|
|
500
|
+
for (const [k, v] of await getManyBatched(store, uids)) out.set(k, v);
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Embed index ──────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
export interface EmbedEntry {
|
|
507
|
+
collection: string;
|
|
508
|
+
entryId: string;
|
|
509
|
+
slug: string | null;
|
|
510
|
+
status: string;
|
|
511
|
+
title: string | null;
|
|
512
|
+
url: string | null;
|
|
513
|
+
publishedAt: string | null;
|
|
514
|
+
updatedAt: string | null;
|
|
515
|
+
uids: string[];
|
|
516
|
+
videos: VideoRef[];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export const embedStore = (ctx: Ctx) => coll<EmbedEntry>(ctx, COLLECTIONS.embeds);
|
|
520
|
+
export const embedKey = (collection: string, id: string) => `${collection}:${id}`;
|
|
521
|
+
|
|
522
|
+
let embedsMemo: { items: EmbedEntry[]; at: number } | null = null;
|
|
523
|
+
const EMBEDS_TTL = 60_000;
|
|
524
|
+
|
|
525
|
+
export function invalidateEmbeds(): void {
|
|
526
|
+
embedsMemo = null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Every indexed entry (paged through plugin storage; cached per isolate for a minute). */
|
|
530
|
+
export async function allEmbeds(ctx: Ctx): Promise<EmbedEntry[]> {
|
|
531
|
+
if (embedsMemo && Date.now() - embedsMemo.at < EMBEDS_TTL) return embedsMemo.items;
|
|
532
|
+
const store = embedStore(ctx);
|
|
533
|
+
const items: EmbedEntry[] = [];
|
|
534
|
+
let cursor: string | undefined;
|
|
535
|
+
for (let page = 0; page < 200; page++) {
|
|
536
|
+
const res = await store.query({ limit: 100, ...(cursor ? { cursor } : {}) });
|
|
537
|
+
items.push(...res.items.map((i) => i.data));
|
|
538
|
+
if (!res.hasMore || !res.cursor) break;
|
|
539
|
+
cursor = res.cursor;
|
|
540
|
+
}
|
|
541
|
+
embedsMemo = { items, at: Date.now() };
|
|
542
|
+
return items;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Sitemap cache ────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
export const SITEMAP_KEY = "cache:videos:sitemap";
|
|
548
|
+
|
|
549
|
+
export async function invalidateSitemap(ctx: Ctx): Promise<void> {
|
|
550
|
+
await ctx.kv.delete(SITEMAP_KEY);
|
|
551
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Cloudflare Stream API client (the parts the Videos module uses).
|
|
3
|
+
* Same endpoints and token as EmDash's cloudflareStream() media provider,
|
|
4
|
+
* which this module doesn't depend on: that provider resolves its
|
|
5
|
+
* credentials from env at import time and has no captions, downloads or
|
|
6
|
+
* webhook calls.
|
|
7
|
+
*/
|
|
8
|
+
const API = "https://api.cloudflare.com/client/v4";
|
|
9
|
+
|
|
10
|
+
export interface StreamCredentials {
|
|
11
|
+
accountId: string;
|
|
12
|
+
token: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StreamVideo {
|
|
16
|
+
uid: string;
|
|
17
|
+
thumbnail?: string;
|
|
18
|
+
preview?: string;
|
|
19
|
+
readyToStream?: boolean;
|
|
20
|
+
status?: { state?: string; pctComplete?: string; errorReasonText?: string };
|
|
21
|
+
meta?: Record<string, unknown>;
|
|
22
|
+
creator?: string | null;
|
|
23
|
+
created?: string;
|
|
24
|
+
modified?: string;
|
|
25
|
+
uploaded?: string;
|
|
26
|
+
size?: number;
|
|
27
|
+
duration?: number;
|
|
28
|
+
input?: { width?: number; height?: number };
|
|
29
|
+
allowedOrigins?: string[];
|
|
30
|
+
requireSignedURLs?: boolean;
|
|
31
|
+
playback?: { hls?: string; dash?: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StreamCaption {
|
|
35
|
+
language: string;
|
|
36
|
+
label?: string;
|
|
37
|
+
generated?: boolean;
|
|
38
|
+
status?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class StreamError extends Error {
|
|
42
|
+
// A plain field, not a parameter property, so `node --test` can load this file (type stripping).
|
|
43
|
+
readonly status: number;
|
|
44
|
+
constructor(message: string, status: number) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.status = status;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Envelope<T> {
|
|
51
|
+
success?: boolean;
|
|
52
|
+
errors?: Array<{ message?: string }>;
|
|
53
|
+
result?: T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function streamClient(creds: StreamCredentials) {
|
|
57
|
+
const base = `${API}/accounts/${encodeURIComponent(creds.accountId)}/stream`;
|
|
58
|
+
const auth = { Authorization: `Bearer ${creds.token}` };
|
|
59
|
+
|
|
60
|
+
async function call<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
|
|
61
|
+
const init: RequestInit = { method, headers: { ...auth, ...headers } };
|
|
62
|
+
if (body instanceof FormData) init.body = body;
|
|
63
|
+
else if (body !== undefined) {
|
|
64
|
+
init.body = JSON.stringify(body);
|
|
65
|
+
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch(`${base}${path}`, init);
|
|
68
|
+
let data: Envelope<T> | null = null;
|
|
69
|
+
try {
|
|
70
|
+
data = (await res.json()) as Envelope<T>;
|
|
71
|
+
} catch {
|
|
72
|
+
data = null;
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok || data?.success === false) {
|
|
75
|
+
const message = data?.errors?.map((e) => e.message).filter(Boolean).join(" ") || `Cloudflare Stream returned HTTP ${res.status}.`;
|
|
76
|
+
throw new StreamError(message, res.status);
|
|
77
|
+
}
|
|
78
|
+
return (data?.result ?? null) as T;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const id = (uid: string) => `/${encodeURIComponent(uid)}`;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
list: (limit = 1000) => call<StreamVideo[]>("GET", `?limit=${limit}&asc=false`),
|
|
85
|
+
get: (uid: string) => call<StreamVideo>("GET", id(uid)),
|
|
86
|
+
update: (uid: string, fields: Record<string, unknown>) => call<StreamVideo>("POST", id(uid), { uid, ...fields }),
|
|
87
|
+
remove: (uid: string) => call<unknown>("DELETE", id(uid)),
|
|
88
|
+
/** Minutes stored and the account's limit. */
|
|
89
|
+
storageUsage: () => call<{ videoCount?: number; totalStorageMinutes?: number; totalStorageMinutesLimit?: number }>("GET", "/storage-usage"),
|
|
90
|
+
directUpload: (opts: { maxDurationSeconds: number; meta: Record<string, string> }) =>
|
|
91
|
+
call<{ uploadURL: string; uid: string }>("POST", "/direct_upload", { maxDurationSeconds: opts.maxDurationSeconds, meta: opts.meta }),
|
|
92
|
+
|
|
93
|
+
/** One-time tus upload URL for large files (the browser uploads straight to Stream). */
|
|
94
|
+
async tusUpload(opts: { length: number; maxDurationSeconds: number; name: string }): Promise<{ uploadURL: string; uid: string | null }> {
|
|
95
|
+
const b64 = (s: string) => btoa(String.fromCharCode(...new TextEncoder().encode(s)));
|
|
96
|
+
const res = await fetch(`${base}?direct_user=true`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
...auth,
|
|
100
|
+
"Tus-Resumable": "1.0.0",
|
|
101
|
+
"Upload-Length": String(opts.length),
|
|
102
|
+
"Upload-Metadata": `name ${b64(opts.name)},maxdurationseconds ${b64(String(opts.maxDurationSeconds))}`,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const location = res.headers.get("Location");
|
|
106
|
+
if (!res.ok || !location) throw new StreamError(`Cloudflare Stream refused the upload (HTTP ${res.status}).`, res.status);
|
|
107
|
+
return { uploadURL: location, uid: res.headers.get("stream-media-id") };
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
getDownloads: (uid: string) => call<{ default?: { status?: string; url?: string; percentComplete?: number } }>("GET", `${id(uid)}/downloads`),
|
|
111
|
+
createDownload: (uid: string) => call<{ default?: { status?: string; url?: string; percentComplete?: number } }>("POST", `${id(uid)}/downloads`),
|
|
112
|
+
deleteDownload: (uid: string) => call<unknown>("DELETE", `${id(uid)}/downloads`),
|
|
113
|
+
|
|
114
|
+
listCaptions: (uid: string) => call<StreamCaption[]>("GET", `${id(uid)}/captions`),
|
|
115
|
+
uploadCaption(uid: string, lang: string, vtt: string) {
|
|
116
|
+
const form = new FormData();
|
|
117
|
+
form.append("file", new Blob([vtt], { type: "text/vtt" }), `${lang}.vtt`);
|
|
118
|
+
return call<StreamCaption>("PUT", `${id(uid)}/captions/${encodeURIComponent(lang)}`, form);
|
|
119
|
+
},
|
|
120
|
+
generateCaption: (uid: string, lang: string) => call<StreamCaption>("POST", `${id(uid)}/captions/${encodeURIComponent(lang)}/generate`),
|
|
121
|
+
deleteCaption: (uid: string, lang: string) => call<unknown>("DELETE", `${id(uid)}/captions/${encodeURIComponent(lang)}`),
|
|
122
|
+
async captionVtt(uid: string, lang: string): Promise<string> {
|
|
123
|
+
const res = await fetch(`${base}${id(uid)}/captions/${encodeURIComponent(lang)}/vtt`, { headers: auth });
|
|
124
|
+
if (!res.ok) throw new StreamError(`Could not download the ${lang} captions (HTTP ${res.status}).`, res.status);
|
|
125
|
+
return res.text();
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
setWebhook: (notificationUrl: string) => call<{ notificationUrl?: string; secret?: string }>("PUT", "/webhook", { notificationUrl }),
|
|
129
|
+
getWebhook: () => call<{ notificationUrl?: string } | null>("GET", "/webhook"),
|
|
130
|
+
deleteWebhook: () => call<unknown>("DELETE", "/webhook"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type StreamClient = ReturnType<typeof streamClient>;
|