@growth-labs/video 0.3.5 → 0.3.7
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/package.json +9 -5
- package/src/cloudflare-workers.d.ts +3 -0
- package/src/components/Video.astro +1 -0
- package/src/index.ts +129 -0
- package/src/options.ts +119 -0
- package/src/routes/import.ts +92 -0
- package/src/routes/playback.ts +101 -0
- package/src/routes/session.ts +85 -0
- package/src/types.ts +46 -0
- package/src/utils/captions.ts +72 -0
- package/src/utils/chapters.ts +63 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/ingest.ts +220 -0
- package/src/utils/jsonld.ts +42 -0
- package/src/utils/keys.ts +42 -0
- package/src/utils/session.ts +96 -0
- package/src/utils/text-tracks.ts +100 -0
- package/src/utils/url.ts +41 -0
- package/src/utils/youtube.ts +30 -0
- package/src/virtual.d.ts +9 -0
- package/src/vite-plugin.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/video",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -31,14 +31,18 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
|
-
"src
|
|
34
|
+
"src/**/*.ts",
|
|
35
|
+
"src/**/*.astro",
|
|
36
|
+
"src/**/*.css",
|
|
37
|
+
"src/**/*.md",
|
|
38
|
+
"src/**/*.sql"
|
|
35
39
|
],
|
|
36
40
|
"publishConfig": {
|
|
37
41
|
"access": "public",
|
|
38
42
|
"registry": "https://registry.npmjs.org/"
|
|
39
43
|
},
|
|
40
44
|
"peerDependencies": {
|
|
41
|
-
"astro": "^6.
|
|
45
|
+
"astro": "^6.1.10",
|
|
42
46
|
"vidstack": "^1.12.0"
|
|
43
47
|
},
|
|
44
48
|
"peerDependenciesMeta": {
|
|
@@ -51,9 +55,9 @@
|
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
57
|
"@cloudflare/workers-types": "^4.20260402.1",
|
|
54
|
-
"astro": "^6.
|
|
58
|
+
"astro": "^6.1.10",
|
|
55
59
|
"typescript": "^5.7.0",
|
|
56
|
-
"vite": "^7.
|
|
60
|
+
"vite": "^7.3.2",
|
|
57
61
|
"vitest": "^3.0.0",
|
|
58
62
|
"vidstack": "^1.12.0"
|
|
59
63
|
},
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro'
|
|
2
|
+
import { type VideoOptions, videoOptionsSchema } from './options.js'
|
|
3
|
+
import type { AccessCheck } from './types.js'
|
|
4
|
+
import { growthLabsVideoPlugin } from './vite-plugin.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Integration input. Mirrors `VideoOptions` (the Zod-validated shape). The
|
|
8
|
+
* legacy `accessCheck` inline function is accepted only to throw a clear
|
|
9
|
+
* migration error at integration init — it never worked in Cloudflare
|
|
10
|
+
* Workers production (build-time registration didn't survive to the
|
|
11
|
+
* runtime process). See MIGRATION.md.
|
|
12
|
+
*/
|
|
13
|
+
export interface VideoIntegrationInput extends VideoOptions {
|
|
14
|
+
/**
|
|
15
|
+
* @deprecated since 0.3.3 — removed. Wire your access check through
|
|
16
|
+
* `premium.accessCheckModule: { module, export }` instead. The inline
|
|
17
|
+
* form never worked in Cloudflare Workers production.
|
|
18
|
+
*/
|
|
19
|
+
accessCheck?: AccessCheck
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function video(userOptions: VideoIntegrationInput): AstroIntegration {
|
|
23
|
+
const { accessCheck: inlineAccessCheck, ...optionsInput } = userOptions
|
|
24
|
+
const options = videoOptionsSchema.parse(optionsInput)
|
|
25
|
+
|
|
26
|
+
if (options.premium?.enabled) {
|
|
27
|
+
if (inlineAccessCheck !== undefined && !options.premium.accessCheckModule) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'@growth-labs/video: inline `accessCheck` function is no longer supported since 0.3.3. ' +
|
|
30
|
+
'The inline form never worked in Cloudflare Workers production — build-time ' +
|
|
31
|
+
'registration of the function did not survive to the runtime process, so ' +
|
|
32
|
+
'`/api/video/session` returned `500 access_check_not_registered` on every request. ' +
|
|
33
|
+
'Move the function into its own module and reference it via ' +
|
|
34
|
+
"`premium.accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' }`. " +
|
|
35
|
+
'See MIGRATION.md for the migration.',
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
if (!options.premium.accessCheckModule) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'@growth-labs/video: premium.enabled=true but no `premium.accessCheckModule` was provided. ' +
|
|
41
|
+
'Premium video must not be served without an entitlement check. Wire one as ' +
|
|
42
|
+
"`premium.accessCheckModule: { module: '/src/lib/video-access-check', export: 'check' }` " +
|
|
43
|
+
'(the module path is project-root absolute; the export must implement the `AccessCheck` type). ' +
|
|
44
|
+
'See README + MIGRATION.md for the module shape, or set `premium.enabled=false`.',
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
name: '@growth-labs/video',
|
|
51
|
+
hooks: {
|
|
52
|
+
'astro:config:setup': ({ injectRoute, updateConfig }) => {
|
|
53
|
+
updateConfig({
|
|
54
|
+
vite: { plugins: [growthLabsVideoPlugin(options)] },
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (options.premium?.enabled) {
|
|
58
|
+
injectRoute({
|
|
59
|
+
pattern: options.premium.sessionRoute,
|
|
60
|
+
entrypoint: '@growth-labs/video/routes/session',
|
|
61
|
+
})
|
|
62
|
+
injectRoute({
|
|
63
|
+
pattern: `${options.premium.playbackRoute}/[...path]`,
|
|
64
|
+
entrypoint: '@growth-labs/video/routes/playback',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.admin.importRoute.enabled) {
|
|
69
|
+
injectRoute({
|
|
70
|
+
pattern: options.admin.importRoute.path,
|
|
71
|
+
entrypoint: '@growth-labs/video/routes/import',
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type { ResolvedVideoOptions, VideoOptions } from './options.js'
|
|
80
|
+
export { videoOptionsSchema } from './options.js'
|
|
81
|
+
export type {
|
|
82
|
+
AccessCheck,
|
|
83
|
+
AccessCheckParams,
|
|
84
|
+
AccessResult,
|
|
85
|
+
Chapter,
|
|
86
|
+
ChaptersFile,
|
|
87
|
+
VideoObjectInput,
|
|
88
|
+
VttCue,
|
|
89
|
+
} from './types.js'
|
|
90
|
+
export { loadCaptions, parseVtt } from './utils/captions.js'
|
|
91
|
+
export { loadChapters, parseChaptersJson, parseChaptersVtt } from './utils/chapters.js'
|
|
92
|
+
export {
|
|
93
|
+
type HlsBundleFile,
|
|
94
|
+
type HlsBundleIngestResult,
|
|
95
|
+
ingestHlsBundle,
|
|
96
|
+
type ValidatedHlsBundle,
|
|
97
|
+
type ValidatedHlsBundleFile,
|
|
98
|
+
type VideoR2Bucket,
|
|
99
|
+
validateHlsBundle,
|
|
100
|
+
} from './utils/ingest.js'
|
|
101
|
+
export { generateVideoObjectJsonLd } from './utils/jsonld.js'
|
|
102
|
+
export {
|
|
103
|
+
captionsKey,
|
|
104
|
+
chaptersJsonKey,
|
|
105
|
+
chaptersKey,
|
|
106
|
+
hlsBundlePrefix,
|
|
107
|
+
posterKey,
|
|
108
|
+
videoManifestKey,
|
|
109
|
+
} from './utils/keys.js'
|
|
110
|
+
export { issueSession, verifySession } from './utils/session.js'
|
|
111
|
+
export {
|
|
112
|
+
readCaptionsVtt,
|
|
113
|
+
readChaptersVtt,
|
|
114
|
+
type VideoTextTrackBucket,
|
|
115
|
+
type VideoTextTrackReadResult,
|
|
116
|
+
type VideoTextTrackWriteResult,
|
|
117
|
+
writeCaptionsVtt,
|
|
118
|
+
writeChaptersVtt,
|
|
119
|
+
} from './utils/text-tracks.js'
|
|
120
|
+
export {
|
|
121
|
+
captionsUrl,
|
|
122
|
+
chaptersJsonUrl,
|
|
123
|
+
chaptersUrl,
|
|
124
|
+
manifestUrl,
|
|
125
|
+
posterUrl,
|
|
126
|
+
premiumManifestUrl,
|
|
127
|
+
premiumPlaybackUrl,
|
|
128
|
+
} from './utils/url.js'
|
|
129
|
+
export { youtubeEmbedUrl, youtubeThumbnailUrl } from './utils/youtube.js'
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const SESSION_TTL_MIN_SECONDS = 60
|
|
4
|
+
const SESSION_TTL_MAX_SECONDS = 86_400
|
|
5
|
+
|
|
6
|
+
function parseDurationToSeconds(input: string): number | null {
|
|
7
|
+
if (typeof input !== 'string' || input.length === 0) return null
|
|
8
|
+
const match = input.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/)
|
|
9
|
+
if (!match) return null
|
|
10
|
+
const [, h, m, s] = match
|
|
11
|
+
if (h === undefined && m === undefined && s === undefined) return null
|
|
12
|
+
const hours = h ? Number.parseInt(h, 10) : 0
|
|
13
|
+
const minutes = m ? Number.parseInt(m, 10) : 0
|
|
14
|
+
const seconds = s ? Number.parseInt(s, 10) : 0
|
|
15
|
+
return hours * 3600 + minutes * 60 + seconds
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const videoOptionsSchema = z.object({
|
|
19
|
+
// ─── Required ───
|
|
20
|
+
publicDomain: z.string(),
|
|
21
|
+
|
|
22
|
+
// ─── R2 bindings ───
|
|
23
|
+
r2Binding: z.string().default('MEDIA_BUCKET'),
|
|
24
|
+
|
|
25
|
+
// ─── Admin import/upload helper route (optional) ───
|
|
26
|
+
admin: z
|
|
27
|
+
.object({
|
|
28
|
+
importRoute: z
|
|
29
|
+
.object({
|
|
30
|
+
enabled: z.boolean().default(false),
|
|
31
|
+
path: z.string().default('/api/video/import'),
|
|
32
|
+
requireAuth: z.boolean().default(true),
|
|
33
|
+
})
|
|
34
|
+
.default({}),
|
|
35
|
+
})
|
|
36
|
+
.default({}),
|
|
37
|
+
|
|
38
|
+
// ─── Premium gating (optional) ───
|
|
39
|
+
premium: z
|
|
40
|
+
.object({
|
|
41
|
+
enabled: z.boolean().default(false),
|
|
42
|
+
sessionRoute: z.string().default('/api/video/session'),
|
|
43
|
+
playbackRoute: z.string().default('/api/video/playback'),
|
|
44
|
+
sessionCookieName: z
|
|
45
|
+
.string()
|
|
46
|
+
.regex(/^[a-zA-Z0-9_-]+$/, 'cookie name must be alphanumeric with underscores or hyphens')
|
|
47
|
+
.default('gl_video_session'),
|
|
48
|
+
sessionScope: z.enum(['per-video', 'wildcard']).default('per-video'),
|
|
49
|
+
sessionTtl: z
|
|
50
|
+
.string()
|
|
51
|
+
.default('1h')
|
|
52
|
+
.transform((value, ctx) => {
|
|
53
|
+
const seconds = parseDurationToSeconds(value)
|
|
54
|
+
if (seconds === null) {
|
|
55
|
+
ctx.addIssue({
|
|
56
|
+
code: z.ZodIssueCode.custom,
|
|
57
|
+
message: `invalid sessionTtl "${value}". Expected a duration string like "1h", "30m", or "2h30m".`,
|
|
58
|
+
})
|
|
59
|
+
return z.NEVER
|
|
60
|
+
}
|
|
61
|
+
if (seconds < SESSION_TTL_MIN_SECONDS) {
|
|
62
|
+
ctx.addIssue({
|
|
63
|
+
code: z.ZodIssueCode.custom,
|
|
64
|
+
message: `sessionTtl "${value}" resolves to ${seconds}s; minimum is ${SESSION_TTL_MIN_SECONDS}s.`,
|
|
65
|
+
})
|
|
66
|
+
return z.NEVER
|
|
67
|
+
}
|
|
68
|
+
if (seconds > SESSION_TTL_MAX_SECONDS) {
|
|
69
|
+
ctx.addIssue({
|
|
70
|
+
code: z.ZodIssueCode.custom,
|
|
71
|
+
message: `sessionTtl "${value}" resolves to ${seconds}s; maximum is ${SESSION_TTL_MAX_SECONDS}s (24h).`,
|
|
72
|
+
})
|
|
73
|
+
return z.NEVER
|
|
74
|
+
}
|
|
75
|
+
return seconds
|
|
76
|
+
}),
|
|
77
|
+
signingSecretEnv: z.string().default('GL_VIDEO_SESSION_SECRET'),
|
|
78
|
+
accessCheckModule: z
|
|
79
|
+
.object({
|
|
80
|
+
module: z.string().min(1),
|
|
81
|
+
export: z.string().min(1).default('accessCheck'),
|
|
82
|
+
})
|
|
83
|
+
.optional(),
|
|
84
|
+
})
|
|
85
|
+
.optional(),
|
|
86
|
+
|
|
87
|
+
// ─── Player defaults ───
|
|
88
|
+
player: z
|
|
89
|
+
.object({
|
|
90
|
+
autoplay: z.boolean().default(false),
|
|
91
|
+
muted: z.boolean().default(false),
|
|
92
|
+
preload: z.enum(['none', 'metadata', 'auto']).default('metadata'),
|
|
93
|
+
showCaptions: z.boolean().default(true),
|
|
94
|
+
showChapters: z.boolean().default(true),
|
|
95
|
+
controls: z.boolean().default(true),
|
|
96
|
+
startingQuality: z.enum(['auto', 'highest', 'lowest']).default('auto'),
|
|
97
|
+
})
|
|
98
|
+
.default({}),
|
|
99
|
+
|
|
100
|
+
// ─── YouTube embed fallback ───
|
|
101
|
+
youtube: z
|
|
102
|
+
.object({
|
|
103
|
+
enabled: z.boolean().default(true),
|
|
104
|
+
privacyMode: z.boolean().default(true),
|
|
105
|
+
lazyLoad: z.boolean().default(true),
|
|
106
|
+
})
|
|
107
|
+
.default({}),
|
|
108
|
+
|
|
109
|
+
// ─── Analytics ───
|
|
110
|
+
analytics: z
|
|
111
|
+
.object({
|
|
112
|
+
enabled: z.boolean().default(true),
|
|
113
|
+
progressMarks: z.array(z.number().min(0).max(1)).default([0.25, 0.5, 0.75, 0.95]),
|
|
114
|
+
})
|
|
115
|
+
.default({}),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export type VideoOptions = z.input<typeof videoOptionsSchema>
|
|
119
|
+
export type ResolvedVideoOptions = z.output<typeof videoOptionsSchema>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
2
|
+
import { config } from 'virtual:growth-labs/video/config'
|
|
3
|
+
import type { APIRoute } from 'astro'
|
|
4
|
+
import { type HlsBundleFile, ingestHlsBundle, type VideoR2Bucket } from '../utils/ingest.js'
|
|
5
|
+
|
|
6
|
+
function json(body: unknown, status: number): Response {
|
|
7
|
+
return new Response(JSON.stringify(body), {
|
|
8
|
+
status,
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isFile(value: FormDataEntryValue | null): value is File {
|
|
14
|
+
return typeof File !== 'undefined' && value instanceof File
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fileToHlsAsset(file: File, path: string): Promise<HlsBundleFile> {
|
|
18
|
+
const normalizedPath = path || file.name
|
|
19
|
+
const body = normalizedPath.endsWith('.m3u8') ? await file.text() : file
|
|
20
|
+
return {
|
|
21
|
+
path: normalizedPath,
|
|
22
|
+
body,
|
|
23
|
+
contentType: file.type || undefined,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAuthenticated(locals: unknown): boolean {
|
|
28
|
+
const user = (locals as { user?: { authenticated?: boolean } }).user
|
|
29
|
+
return user?.authenticated === true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
33
|
+
const routeOptions = config.admin.importRoute
|
|
34
|
+
if (routeOptions.requireAuth && !isAuthenticated(locals)) {
|
|
35
|
+
return json({ error: 'Unauthorized' }, 401)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
39
|
+
const bucket = env[config.r2Binding] as VideoR2Bucket | undefined
|
|
40
|
+
if (!bucket) {
|
|
41
|
+
return json({ error: `R2 bucket "${config.r2Binding}" not configured` }, 500)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let formData: FormData
|
|
45
|
+
try {
|
|
46
|
+
formData = await request.formData()
|
|
47
|
+
} catch {
|
|
48
|
+
return json({ error: 'Expected multipart form data' }, 400)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const videoId = formData.get('videoId')
|
|
52
|
+
if (typeof videoId !== 'string' || videoId.trim().length === 0) {
|
|
53
|
+
return json({ error: 'Missing videoId' }, 400)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const files = formData.getAll('files').filter(isFile)
|
|
57
|
+
if (files.length === 0) {
|
|
58
|
+
return json({ error: 'No HLS files provided' }, 400)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const paths = formData.getAll('paths').map((value) => String(value))
|
|
62
|
+
if (paths.length > 0 && paths.length !== files.length) {
|
|
63
|
+
return json({ error: 'paths must match files length' }, 400)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hlsFiles = await Promise.all(
|
|
67
|
+
files.map((file, index) => fileToHlsAsset(file, paths[index] ?? file.name)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const posterValue = formData.get('poster')
|
|
71
|
+
const poster = isFile(posterValue)
|
|
72
|
+
? {
|
|
73
|
+
path: posterValue.name || 'poster.jpg',
|
|
74
|
+
body: posterValue as File,
|
|
75
|
+
contentType: posterValue.type || undefined,
|
|
76
|
+
}
|
|
77
|
+
: undefined
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await ingestHlsBundle({
|
|
81
|
+
bucket,
|
|
82
|
+
publicDomain: config.publicDomain,
|
|
83
|
+
videoId,
|
|
84
|
+
files: hlsFiles,
|
|
85
|
+
poster,
|
|
86
|
+
})
|
|
87
|
+
return json(result, 200)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
90
|
+
return json({ error: message }, 400)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
2
|
+
import { config } from 'virtual:growth-labs/video/config'
|
|
3
|
+
import type { APIRoute } from 'astro'
|
|
4
|
+
import { verifySession } from '../utils/session.js'
|
|
5
|
+
|
|
6
|
+
interface R2LikeBucket {
|
|
7
|
+
get(
|
|
8
|
+
key: string,
|
|
9
|
+
opts?: { range?: { offset?: number; length?: number; suffix?: number } },
|
|
10
|
+
): Promise<R2LikeObject | null>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface R2LikeObject {
|
|
14
|
+
body: ReadableStream
|
|
15
|
+
httpEtag: string
|
|
16
|
+
writeHttpMetadata(headers: Headers): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function inferContentType(key: string): string {
|
|
20
|
+
if (key.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl'
|
|
21
|
+
if (key.endsWith('.m4s')) return 'video/iso.segment'
|
|
22
|
+
if (key.endsWith('.mp4')) return 'video/mp4'
|
|
23
|
+
if (key.endsWith('.vtt')) return 'text/vtt'
|
|
24
|
+
return 'application/octet-stream'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseRangeHeader(
|
|
28
|
+
header: string,
|
|
29
|
+
): { offset?: number; length?: number; suffix?: number } | undefined {
|
|
30
|
+
const m = header.match(/^bytes=(\d*)-(\d*)$/)
|
|
31
|
+
if (!m) return undefined
|
|
32
|
+
const start = m[1]
|
|
33
|
+
const end = m[2]
|
|
34
|
+
if (start === '' && end !== '') return { suffix: parseInt(end, 10) }
|
|
35
|
+
if (start !== '' && end === '') return { offset: parseInt(start, 10) }
|
|
36
|
+
if (start !== '' && end !== '') {
|
|
37
|
+
const s = parseInt(start, 10)
|
|
38
|
+
const e = parseInt(end, 10)
|
|
39
|
+
return { offset: s, length: e - s + 1 }
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readCookie(header: string | null, name: string): string | null {
|
|
45
|
+
if (!header) return null
|
|
46
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`))
|
|
47
|
+
return match ? match[1] : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const GET: APIRoute = async (context) => {
|
|
51
|
+
const { request, params } = context
|
|
52
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
53
|
+
|
|
54
|
+
const rest = params.path as string | undefined
|
|
55
|
+
if (!rest || rest.length === 0) {
|
|
56
|
+
return new Response('invalid path', { status: 400 })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const segments = rest.split('/').filter(Boolean)
|
|
60
|
+
if (segments.length < 2) {
|
|
61
|
+
return new Response('invalid path', { status: 400 })
|
|
62
|
+
}
|
|
63
|
+
if (segments.some((s) => s === '..' || s === '.')) {
|
|
64
|
+
return new Response('invalid path', { status: 400 })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!config.premium) return new Response('premium disabled', { status: 404 })
|
|
68
|
+
|
|
69
|
+
const videoId = segments[0]
|
|
70
|
+
const subKey = segments.slice(1).join('/')
|
|
71
|
+
|
|
72
|
+
const cookie = readCookie(request.headers.get('cookie'), config.premium.sessionCookieName)
|
|
73
|
+
if (!cookie) return new Response('unauthorized', { status: 401 })
|
|
74
|
+
|
|
75
|
+
const secret = env[config.premium.signingSecretEnv] as string | undefined
|
|
76
|
+
if (!secret) return new Response('misconfigured', { status: 500 })
|
|
77
|
+
|
|
78
|
+
const session = await verifySession(cookie, secret, videoId)
|
|
79
|
+
if (!session) return new Response('forbidden', { status: 403 })
|
|
80
|
+
|
|
81
|
+
const bucket = env[config.r2Binding] as R2LikeBucket | undefined
|
|
82
|
+
if (!bucket) return new Response('misconfigured', { status: 500 })
|
|
83
|
+
|
|
84
|
+
const rangeHeader = request.headers.get('range')
|
|
85
|
+
const range = rangeHeader ? parseRangeHeader(rangeHeader) : undefined
|
|
86
|
+
|
|
87
|
+
const r2Key = `video/${videoId}/${subKey}`
|
|
88
|
+
const obj = await bucket.get(r2Key, range ? { range } : undefined)
|
|
89
|
+
if (!obj) return new Response('not found', { status: 404 })
|
|
90
|
+
|
|
91
|
+
const headers = new Headers()
|
|
92
|
+
obj.writeHttpMetadata(headers)
|
|
93
|
+
headers.set('etag', obj.httpEtag)
|
|
94
|
+
headers.set('content-type', inferContentType(r2Key))
|
|
95
|
+
headers.set('cache-control', r2Key.endsWith('.m3u8') ? 'no-cache' : 'public, max-age=3600')
|
|
96
|
+
|
|
97
|
+
return new Response(obj.body, {
|
|
98
|
+
status: range ? 206 : 200,
|
|
99
|
+
headers,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
2
|
+
import { accessRegistry } from 'virtual:growth-labs/video/access-check'
|
|
3
|
+
import { config } from 'virtual:growth-labs/video/config'
|
|
4
|
+
import type { APIRoute } from 'astro'
|
|
5
|
+
import { issueSession } from '../utils/session.js'
|
|
6
|
+
|
|
7
|
+
const MIN_SECRET_BYTES = 32
|
|
8
|
+
|
|
9
|
+
function json(body: unknown, status: number): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const POST: APIRoute = async (context) => {
|
|
17
|
+
const { request } = context
|
|
18
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
19
|
+
|
|
20
|
+
let raw: unknown
|
|
21
|
+
try {
|
|
22
|
+
raw = await request.json()
|
|
23
|
+
} catch {
|
|
24
|
+
return json({ error: 'invalid_body' }, 400)
|
|
25
|
+
}
|
|
26
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
27
|
+
return json({ error: 'invalid_body' }, 400)
|
|
28
|
+
}
|
|
29
|
+
const body = raw as { videoId?: unknown }
|
|
30
|
+
if (typeof body.videoId !== 'string' || body.videoId.length === 0) {
|
|
31
|
+
return json({ error: 'missing_video_id' }, 400)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!config.premium) {
|
|
35
|
+
return json({ error: 'premium_not_enabled' }, 500)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const accessCheck = accessRegistry.check
|
|
39
|
+
if (typeof accessCheck !== 'function') {
|
|
40
|
+
return json({ error: 'access_check_not_configured' }, 500)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = await accessCheck({ videoId: body.videoId, request, env }, context)
|
|
44
|
+
if (!result.allowed) {
|
|
45
|
+
return json({ error: 'forbidden', reason: result.reason }, 403)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const secret = env[config.premium.signingSecretEnv]
|
|
49
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
50
|
+
return json({ error: 'signing_secret_not_configured' }, 500)
|
|
51
|
+
}
|
|
52
|
+
if (secret.length < MIN_SECRET_BYTES) {
|
|
53
|
+
return json({ error: 'signing_secret_too_short' }, 500)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ttlSeconds = config.premium.sessionTtl
|
|
57
|
+
const videoIds = config.premium.sessionScope === 'wildcard' ? '*' : [body.videoId]
|
|
58
|
+
const now = Math.floor(Date.now() / 1000)
|
|
59
|
+
const cookieValue = await issueSession(
|
|
60
|
+
{
|
|
61
|
+
videoIds,
|
|
62
|
+
subjectId: result.subjectId ?? 'anonymous',
|
|
63
|
+
issuedAt: now,
|
|
64
|
+
expiresAt: now + ttlSeconds,
|
|
65
|
+
},
|
|
66
|
+
secret,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const cookieParts = [
|
|
70
|
+
`${config.premium.sessionCookieName}=${cookieValue}`,
|
|
71
|
+
`Max-Age=${ttlSeconds}`,
|
|
72
|
+
'Path=/',
|
|
73
|
+
'HttpOnly',
|
|
74
|
+
'Secure',
|
|
75
|
+
'SameSite=Lax',
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
79
|
+
status: 200,
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
'Set-Cookie': cookieParts.join('; '),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIContext } from 'astro'
|
|
2
|
+
|
|
3
|
+
export interface AccessCheckParams {
|
|
4
|
+
videoId: string
|
|
5
|
+
request: Request
|
|
6
|
+
env: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AccessResult {
|
|
10
|
+
allowed: boolean
|
|
11
|
+
reason?: string
|
|
12
|
+
subjectId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AccessCheck = (params: AccessCheckParams, context: APIContext) => Promise<AccessResult>
|
|
16
|
+
|
|
17
|
+
export interface Chapter {
|
|
18
|
+
index: number
|
|
19
|
+
title: string
|
|
20
|
+
startSeconds: number
|
|
21
|
+
endSeconds: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChaptersFile {
|
|
25
|
+
chapters: Chapter[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VttCue {
|
|
29
|
+
startSeconds: number
|
|
30
|
+
endSeconds: number
|
|
31
|
+
text: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VideoObjectInput {
|
|
35
|
+
videoId: string
|
|
36
|
+
title: string
|
|
37
|
+
description: string
|
|
38
|
+
publicDomain: string
|
|
39
|
+
uploadDate: string
|
|
40
|
+
durationSeconds: number
|
|
41
|
+
contentUrl?: string
|
|
42
|
+
embedUrl: string
|
|
43
|
+
thumbnailUrls?: string[]
|
|
44
|
+
transcript?: string
|
|
45
|
+
chapters?: Array<{ title: string; startSeconds: number }>
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { VttCue } from '../types.js'
|
|
2
|
+
|
|
3
|
+
function parseTimestamp(str: string): number | null {
|
|
4
|
+
const m = str.match(/^(?:(\d+):)?(\d{2}):(\d{2})\.(\d{3})$/)
|
|
5
|
+
if (!m) return null
|
|
6
|
+
const h = m[1] ? parseInt(m[1], 10) : 0
|
|
7
|
+
const mm = parseInt(m[2], 10)
|
|
8
|
+
const s = parseInt(m[3], 10)
|
|
9
|
+
const ms = parseInt(m[4], 10)
|
|
10
|
+
return h * 3600 + mm * 60 + s + ms / 1000
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseVtt(input: string): VttCue[] | null {
|
|
14
|
+
if (!input) return null
|
|
15
|
+
const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
16
|
+
const lines = normalized.split('\n')
|
|
17
|
+
if (!lines[0].startsWith('WEBVTT')) return null
|
|
18
|
+
|
|
19
|
+
const cues: VttCue[] = []
|
|
20
|
+
let i = 1
|
|
21
|
+
while (i < lines.length) {
|
|
22
|
+
while (i < lines.length && lines[i].trim() === '') i++
|
|
23
|
+
if (i >= lines.length) break
|
|
24
|
+
|
|
25
|
+
// Optional cue identifier line — skip if it's not a timing line.
|
|
26
|
+
if (!lines[i].includes('-->')) {
|
|
27
|
+
i++
|
|
28
|
+
if (i >= lines.length) break
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const timing = lines[i]
|
|
32
|
+
const mt = timing.match(/^([\d:.]+)\s+-->\s+([\d:.]+)/)
|
|
33
|
+
if (!mt) {
|
|
34
|
+
i++
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
const start = parseTimestamp(mt[1])
|
|
38
|
+
const end = parseTimestamp(mt[2])
|
|
39
|
+
i++
|
|
40
|
+
|
|
41
|
+
const textLines: string[] = []
|
|
42
|
+
while (i < lines.length && lines[i].trim() !== '') {
|
|
43
|
+
textLines.push(lines[i])
|
|
44
|
+
i++
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (start == null || end == null) continue
|
|
48
|
+
cues.push({ startSeconds: start, endSeconds: end, text: textLines.join('\n') })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (cues.length === 0) return null
|
|
52
|
+
return cues
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LoadCaptionsOptions {
|
|
56
|
+
fetcher?: typeof fetch
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadCaptions(
|
|
60
|
+
url: string,
|
|
61
|
+
opts: LoadCaptionsOptions = {},
|
|
62
|
+
): Promise<VttCue[] | null> {
|
|
63
|
+
const fetcher = opts.fetcher ?? fetch
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetcher(url)
|
|
66
|
+
if (!res.ok) return null
|
|
67
|
+
const body = await res.text()
|
|
68
|
+
return parseVtt(body)
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|