@growth-labs/cms 0.4.0 → 0.5.0
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/README.md +24 -3
- package/dist/engine/foundry-dispatch.d.ts +5 -1
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +19 -3
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publisher.d.ts +4 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +190 -54
- package/dist/engine/publisher.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +76 -16
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +1 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/media.d.ts.map +1 -1
- package/dist/routes/media.js +53 -31
- package/dist/routes/media.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +65 -10
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +2 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +15 -4
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/editor/editor-media-duration.d.ts +24 -0
- package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
- package/dist/ui/editor/editor-media-duration.js +51 -0
- package/dist/ui/editor/editor-media-duration.js.map +1 -0
- package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
- package/dist/ui/screens/MediaScreen.js +8 -1
- package/dist/ui/screens/MediaScreen.js.map +1 -1
- package/dist/ui/screens/media-upload.d.ts +9 -2
- package/dist/ui/screens/media-upload.d.ts.map +1 -1
- package/dist/ui/screens/media-upload.js +24 -0
- package/dist/ui/screens/media-upload.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +32 -4
- package/src/engine/index.ts +1 -0
- package/src/engine/publisher.ts +297 -97
- package/src/providers/types.ts +1 -0
- package/src/routes/content.ts +91 -16
- package/src/routes/context.ts +1 -0
- package/src/routes/media.ts +63 -41
- package/src/ui/editor/ContentForm.tsx +121 -4
- package/src/ui/editor/content-payload.ts +20 -2
- package/src/ui/editor/editor-media-duration.ts +75 -0
- package/src/ui/screens/MediaScreen.tsx +13 -2
- package/src/ui/screens/media-upload.ts +41 -2
|
@@ -28,6 +28,15 @@ export interface DispatchResult {
|
|
|
28
28
|
triggerToken: string
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export class FoundryDispatchPrerequisiteError extends Error {
|
|
32
|
+
readonly code = 'missing_duration' as const
|
|
33
|
+
|
|
34
|
+
constructor(contentId: string) {
|
|
35
|
+
super(`Content ${contentId} requires a positive finite duration before dispatch`)
|
|
36
|
+
this.name = 'FoundryDispatchPrerequisiteError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
export interface CallbackInput {
|
|
32
41
|
eventId: string
|
|
33
42
|
eventKind: string
|
|
@@ -82,7 +91,7 @@ function inferVideoSourceKind(sourceUrl: string): string {
|
|
|
82
91
|
/**
|
|
83
92
|
* Dispatch a video or podcast content item to Foundry via the injected hook.
|
|
84
93
|
* Mints a fresh `processing_trigger_token` (UUID), calls
|
|
85
|
-
* `hook.dispatchVideo({ contentId, sourceUrl, kind })`, and writes
|
|
94
|
+
* `hook.dispatchVideo({ contentId, sourceUrl, kind, durationSeconds })`, and writes
|
|
86
95
|
* `processing_correlation_id` + advances to `'queued'` in either
|
|
87
96
|
* `video_content` or `podcast_content`.
|
|
88
97
|
*
|
|
@@ -110,12 +119,13 @@ export async function dispatchToFoundry(
|
|
|
110
119
|
// Determine which media table owns this content id.
|
|
111
120
|
const videoRow = await db
|
|
112
121
|
.prepare(
|
|
113
|
-
'SELECT content_id, video_id, processing_source_url, processing_source_kind FROM video_content WHERE content_id = ?',
|
|
122
|
+
'SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind FROM video_content WHERE content_id = ?',
|
|
114
123
|
)
|
|
115
124
|
.bind(contentId)
|
|
116
125
|
.first<{
|
|
117
126
|
content_id: string
|
|
118
127
|
video_id: string
|
|
128
|
+
duration_seconds: number | null
|
|
119
129
|
processing_source_url: string | null
|
|
120
130
|
processing_source_kind: string | null
|
|
121
131
|
}>()
|
|
@@ -125,6 +135,7 @@ export async function dispatchToFoundry(
|
|
|
125
135
|
let kind: string
|
|
126
136
|
let sourceKind: string | null = null
|
|
127
137
|
let videoId: string | null = null
|
|
138
|
+
let durationSeconds: number | null
|
|
128
139
|
|
|
129
140
|
if (videoRow) {
|
|
130
141
|
table = 'video_content'
|
|
@@ -135,11 +146,18 @@ export async function dispatchToFoundry(
|
|
|
135
146
|
kind = 'video'
|
|
136
147
|
sourceKind = videoRow.processing_source_kind || inferVideoSourceKind(sourceUrl)
|
|
137
148
|
videoId = normalizeVideoId(videoRow.video_id, content.slug)
|
|
149
|
+
durationSeconds = videoRow.duration_seconds
|
|
138
150
|
} else {
|
|
139
151
|
const podcastRow = await db
|
|
140
|
-
.prepare(
|
|
152
|
+
.prepare(
|
|
153
|
+
'SELECT content_id, audio_r2_key, duration_seconds FROM podcast_content WHERE content_id = ?',
|
|
154
|
+
)
|
|
141
155
|
.bind(contentId)
|
|
142
|
-
.first<{
|
|
156
|
+
.first<{
|
|
157
|
+
content_id: string
|
|
158
|
+
audio_r2_key: string | null
|
|
159
|
+
duration_seconds: number | null
|
|
160
|
+
}>()
|
|
143
161
|
|
|
144
162
|
if (!podcastRow) {
|
|
145
163
|
throw new Error(
|
|
@@ -154,6 +172,15 @@ export async function dispatchToFoundry(
|
|
|
154
172
|
kind = 'podcast'
|
|
155
173
|
sourceKind = 'r2_key'
|
|
156
174
|
videoId = normalizeVideoId(content.slug, content.slug)
|
|
175
|
+
durationSeconds = podcastRow.duration_seconds
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (
|
|
179
|
+
typeof durationSeconds !== 'number' ||
|
|
180
|
+
!Number.isFinite(durationSeconds) ||
|
|
181
|
+
durationSeconds <= 0
|
|
182
|
+
) {
|
|
183
|
+
throw new FoundryDispatchPrerequisiteError(contentId)
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
const triggerToken = crypto.randomUUID()
|
|
@@ -162,6 +189,7 @@ export async function dispatchToFoundry(
|
|
|
162
189
|
contentId,
|
|
163
190
|
sourceUrl,
|
|
164
191
|
kind,
|
|
192
|
+
durationSeconds,
|
|
165
193
|
...(sourceKind ? { sourceKind } : {}),
|
|
166
194
|
...(videoId ? { videoId } : {}),
|
|
167
195
|
title: content.title,
|
package/src/engine/index.ts
CHANGED
|
@@ -204,6 +204,7 @@ export {
|
|
|
204
204
|
type UpdateContentInput,
|
|
205
205
|
unpublishContent,
|
|
206
206
|
updateContentItem,
|
|
207
|
+
VerifiedMediaDurationConflictError,
|
|
207
208
|
type VideoInput,
|
|
208
209
|
} from './publisher.js'
|
|
209
210
|
// Verified-user/site reader product state. Analytics remains observation-only.
|
package/src/engine/publisher.ts
CHANGED
|
@@ -181,6 +181,118 @@ function isBodyBackedContentInput(input: CreateContentInput): input is BodyBacke
|
|
|
181
181
|
return isBodyBackedContentType(input.type)
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
function assertValidMediaDuration(value: unknown): asserts value is number | null | undefined {
|
|
185
|
+
if (value === null || value === undefined) return
|
|
186
|
+
if (
|
|
187
|
+
typeof value !== 'number' ||
|
|
188
|
+
!Number.isFinite(value) ||
|
|
189
|
+
!Number.isInteger(value) ||
|
|
190
|
+
value <= 0
|
|
191
|
+
) {
|
|
192
|
+
throw new Error('Media duration must be a positive integer when supplied')
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class VerifiedMediaDurationConflictError extends Error {
|
|
197
|
+
readonly code = 'verified_duration_conflict'
|
|
198
|
+
|
|
199
|
+
constructor() {
|
|
200
|
+
super('Media duration conflicts with verified upload duration')
|
|
201
|
+
this.name = 'VerifiedMediaDurationConflictError'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type VerifiedMediaKind = 'video' | 'podcast'
|
|
206
|
+
|
|
207
|
+
function parseVerifiedMediaDuration(variantsJson: string | null): number | null {
|
|
208
|
+
if (!variantsJson) return null
|
|
209
|
+
try {
|
|
210
|
+
const parsed: unknown = JSON.parse(variantsJson)
|
|
211
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
212
|
+
const durationSeconds = (parsed as Record<string, unknown>).durationSeconds
|
|
213
|
+
return typeof durationSeconds === 'number' &&
|
|
214
|
+
Number.isFinite(durationSeconds) &&
|
|
215
|
+
Number.isInteger(durationSeconds) &&
|
|
216
|
+
durationSeconds > 0
|
|
217
|
+
? durationSeconds
|
|
218
|
+
: null
|
|
219
|
+
} catch {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function loadVerifiedMediaDuration(
|
|
225
|
+
db: D1Database,
|
|
226
|
+
kind: VerifiedMediaKind,
|
|
227
|
+
source: string | null | undefined,
|
|
228
|
+
): Promise<number | null> {
|
|
229
|
+
if (!source) return null
|
|
230
|
+
|
|
231
|
+
const isHttpUrl = /^https?:\/\//i.test(source)
|
|
232
|
+
const statement =
|
|
233
|
+
kind === 'video'
|
|
234
|
+
? isHttpUrl
|
|
235
|
+
? db
|
|
236
|
+
.prepare(
|
|
237
|
+
`SELECT variants_json
|
|
238
|
+
FROM media_assets
|
|
239
|
+
WHERE asset_type = 'video'
|
|
240
|
+
AND source_type = 'editor-video-source'
|
|
241
|
+
AND (url = ? OR public_url = ?)`,
|
|
242
|
+
)
|
|
243
|
+
.bind(source, source)
|
|
244
|
+
: db
|
|
245
|
+
.prepare(
|
|
246
|
+
`SELECT variants_json
|
|
247
|
+
FROM media_assets
|
|
248
|
+
WHERE asset_type = 'video'
|
|
249
|
+
AND source_type = 'editor-video-source'
|
|
250
|
+
AND (id = ? OR object_key = ?)`,
|
|
251
|
+
)
|
|
252
|
+
.bind(source, source)
|
|
253
|
+
: db
|
|
254
|
+
.prepare(
|
|
255
|
+
`SELECT variants_json
|
|
256
|
+
FROM media_assets
|
|
257
|
+
WHERE asset_type = 'audio'
|
|
258
|
+
AND source_type = 'editor-upload'
|
|
259
|
+
AND (id = ? OR object_key = ?)`,
|
|
260
|
+
)
|
|
261
|
+
.bind(source, source)
|
|
262
|
+
|
|
263
|
+
const rows = await statement.all<{ variants_json: string | null }>()
|
|
264
|
+
const durations = new Set<number>()
|
|
265
|
+
for (const row of rows.results ?? []) {
|
|
266
|
+
const duration = parseVerifiedMediaDuration(row.variants_json)
|
|
267
|
+
if (duration !== null) durations.add(duration)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (durations.size > 1) {
|
|
271
|
+
// Multiple exact package-owned identities disagreeing is unsafe to guess.
|
|
272
|
+
throw new VerifiedMediaDurationConflictError()
|
|
273
|
+
}
|
|
274
|
+
return durations.values().next().value ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function resolveMediaDuration(
|
|
278
|
+
db: D1Database,
|
|
279
|
+
kind: VerifiedMediaKind,
|
|
280
|
+
source: string | null | undefined,
|
|
281
|
+
suppliedDuration: unknown,
|
|
282
|
+
): Promise<number | null> {
|
|
283
|
+
assertValidMediaDuration(suppliedDuration)
|
|
284
|
+
const verifiedDuration = await loadVerifiedMediaDuration(db, kind, source)
|
|
285
|
+
if (verifiedDuration === null) return suppliedDuration ?? null
|
|
286
|
+
if (
|
|
287
|
+
suppliedDuration !== null &&
|
|
288
|
+
suppliedDuration !== undefined &&
|
|
289
|
+
suppliedDuration !== verifiedDuration
|
|
290
|
+
) {
|
|
291
|
+
throw new VerifiedMediaDurationConflictError()
|
|
292
|
+
}
|
|
293
|
+
return verifiedDuration
|
|
294
|
+
}
|
|
295
|
+
|
|
184
296
|
export interface UpdateContentInput extends Partial<BaseContentInput> {
|
|
185
297
|
content?: Partial<
|
|
186
298
|
| ArticleInput['content']
|
|
@@ -197,6 +309,23 @@ export interface UpdateContentInput extends Partial<BaseContentInput> {
|
|
|
197
309
|
aiLockedFields?: string[]
|
|
198
310
|
}
|
|
199
311
|
|
|
312
|
+
interface VideoUpdatePlan {
|
|
313
|
+
script: string
|
|
314
|
+
videoId: string
|
|
315
|
+
durationSeconds: number | null
|
|
316
|
+
thumbnailImageId: string | null
|
|
317
|
+
sourceUrl: string | null
|
|
318
|
+
sourceKind: string | null
|
|
319
|
+
sourceChanged: boolean
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface PodcastUpdatePlan {
|
|
323
|
+
transcript: string
|
|
324
|
+
audioR2Key: string
|
|
325
|
+
durationSeconds: number | null
|
|
326
|
+
processingTriggerToken: string | null
|
|
327
|
+
}
|
|
328
|
+
|
|
200
329
|
/**
|
|
201
330
|
* The shape `getContentItem` returns from `SELECT * FROM content_items`.
|
|
202
331
|
* Note this is the engine's read-side row (nullable `featured`/timestamps as
|
|
@@ -416,6 +545,22 @@ export async function createContent(
|
|
|
416
545
|
db: D1Database,
|
|
417
546
|
input: CreateContentInput,
|
|
418
547
|
): Promise<{ id: string; slug: string }> {
|
|
548
|
+
const resolvedMediaDuration =
|
|
549
|
+
input.type === 'video'
|
|
550
|
+
? await resolveMediaDuration(
|
|
551
|
+
db,
|
|
552
|
+
'video',
|
|
553
|
+
input.content.processingSourceUrl,
|
|
554
|
+
input.content.durationSeconds,
|
|
555
|
+
)
|
|
556
|
+
: input.type === 'podcast'
|
|
557
|
+
? await resolveMediaDuration(
|
|
558
|
+
db,
|
|
559
|
+
'podcast',
|
|
560
|
+
input.content.audioR2Key,
|
|
561
|
+
input.content.durationSeconds,
|
|
562
|
+
)
|
|
563
|
+
: null
|
|
419
564
|
const id = crypto.randomUUID()
|
|
420
565
|
const slug = await ensureUniqueSlug(db, input.slug)
|
|
421
566
|
const visibility = input.visibility || 'free'
|
|
@@ -502,7 +647,7 @@ export async function createContent(
|
|
|
502
647
|
id,
|
|
503
648
|
input.content.script,
|
|
504
649
|
input.content.videoId,
|
|
505
|
-
|
|
650
|
+
resolvedMediaDuration,
|
|
506
651
|
input.content.thumbnailImageId || null,
|
|
507
652
|
initialVideoProcessingState,
|
|
508
653
|
input.content.processingSourceUrl || null,
|
|
@@ -517,12 +662,7 @@ export async function createContent(
|
|
|
517
662
|
`INSERT INTO podcast_content (content_id, transcript, audio_r2_key, duration_seconds)
|
|
518
663
|
VALUES (?, ?, ?, ?)`,
|
|
519
664
|
)
|
|
520
|
-
.bind(
|
|
521
|
-
id,
|
|
522
|
-
input.content.transcript,
|
|
523
|
-
input.content.audioR2Key,
|
|
524
|
-
input.content.durationSeconds || null,
|
|
525
|
-
)
|
|
665
|
+
.bind(id, input.content.transcript, input.content.audioR2Key, resolvedMediaDuration)
|
|
526
666
|
.run()
|
|
527
667
|
}
|
|
528
668
|
|
|
@@ -552,6 +692,123 @@ export async function updateContentItem(
|
|
|
552
692
|
if (!existing) return null
|
|
553
693
|
|
|
554
694
|
const hasOwn = (source: object, key: string) => Object.hasOwn(source, key)
|
|
695
|
+
let videoUpdatePlan: VideoUpdatePlan | null | undefined
|
|
696
|
+
let podcastUpdatePlan: PodcastUpdatePlan | null | undefined
|
|
697
|
+
|
|
698
|
+
// Resolve all media identity/duration invariants before the base content row,
|
|
699
|
+
// tags, or relations can be mutated. D1 does not provide a transaction on the
|
|
700
|
+
// structural surface used here, so validation must be a strict preflight.
|
|
701
|
+
if (input.content && existing.type === 'video') {
|
|
702
|
+
const current = await db
|
|
703
|
+
.prepare(
|
|
704
|
+
`SELECT script, video_id, duration_seconds, thumbnail_image_id,
|
|
705
|
+
processing_source_url, processing_source_kind
|
|
706
|
+
FROM video_content
|
|
707
|
+
WHERE content_id = ?
|
|
708
|
+
LIMIT 1`,
|
|
709
|
+
)
|
|
710
|
+
.bind(id)
|
|
711
|
+
.first<{
|
|
712
|
+
script: string
|
|
713
|
+
video_id: string
|
|
714
|
+
duration_seconds: number | null
|
|
715
|
+
thumbnail_image_id: string | null
|
|
716
|
+
processing_source_url: string | null
|
|
717
|
+
processing_source_kind: string | null
|
|
718
|
+
}>()
|
|
719
|
+
|
|
720
|
+
if (current) {
|
|
721
|
+
const videoContent = input.content as Partial<VideoInput['content']>
|
|
722
|
+
const sourceUrl = hasOwn(videoContent, 'processingSourceUrl')
|
|
723
|
+
? (videoContent.processingSourceUrl ?? null)
|
|
724
|
+
: current.processing_source_url
|
|
725
|
+
const sourceKind = hasOwn(videoContent, 'processingSourceKind')
|
|
726
|
+
? (videoContent.processingSourceKind ?? null)
|
|
727
|
+
: current.processing_source_kind
|
|
728
|
+
const sourceChanged =
|
|
729
|
+
sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
|
|
730
|
+
const durationProvided = hasOwn(videoContent, 'durationSeconds')
|
|
731
|
+
const resolvedDuration = await resolveMediaDuration(
|
|
732
|
+
db,
|
|
733
|
+
'video',
|
|
734
|
+
sourceUrl,
|
|
735
|
+
durationProvided ? videoContent.durationSeconds : undefined,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
videoUpdatePlan = {
|
|
739
|
+
script: hasOwn(videoContent, 'script')
|
|
740
|
+
? (videoContent.script ?? current.script)
|
|
741
|
+
: current.script,
|
|
742
|
+
videoId: hasOwn(videoContent, 'videoId')
|
|
743
|
+
? (videoContent.videoId ?? current.video_id)
|
|
744
|
+
: current.video_id,
|
|
745
|
+
durationSeconds:
|
|
746
|
+
durationProvided || sourceChanged
|
|
747
|
+
? resolvedDuration
|
|
748
|
+
: (resolvedDuration ?? current.duration_seconds),
|
|
749
|
+
thumbnailImageId: hasOwn(videoContent, 'thumbnailImageId')
|
|
750
|
+
? (videoContent.thumbnailImageId ?? null)
|
|
751
|
+
: current.thumbnail_image_id,
|
|
752
|
+
sourceUrl,
|
|
753
|
+
sourceKind,
|
|
754
|
+
sourceChanged,
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
videoUpdatePlan = null
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (input.content && existing.type === 'podcast') {
|
|
762
|
+
const current = await db
|
|
763
|
+
.prepare(
|
|
764
|
+
`SELECT transcript, audio_r2_key, duration_seconds, processing_trigger_token
|
|
765
|
+
FROM podcast_content
|
|
766
|
+
WHERE content_id = ?
|
|
767
|
+
LIMIT 1`,
|
|
768
|
+
)
|
|
769
|
+
.bind(id)
|
|
770
|
+
.first<{
|
|
771
|
+
transcript: string
|
|
772
|
+
audio_r2_key: string
|
|
773
|
+
duration_seconds: number | null
|
|
774
|
+
processing_trigger_token: string | null
|
|
775
|
+
}>()
|
|
776
|
+
|
|
777
|
+
if (current) {
|
|
778
|
+
const podcastContent = input.content as Partial<PodcastInput['content']>
|
|
779
|
+
const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
|
|
780
|
+
? (podcastContent.audioR2Key ?? current.audio_r2_key)
|
|
781
|
+
: current.audio_r2_key
|
|
782
|
+
const sourceChanged = audioR2Key !== current.audio_r2_key
|
|
783
|
+
const durationProvided = hasOwn(podcastContent, 'durationSeconds')
|
|
784
|
+
const resolvedDuration = await resolveMediaDuration(
|
|
785
|
+
db,
|
|
786
|
+
'podcast',
|
|
787
|
+
audioR2Key,
|
|
788
|
+
durationProvided ? podcastContent.durationSeconds : undefined,
|
|
789
|
+
)
|
|
790
|
+
const replacementTranscriptProvided =
|
|
791
|
+
hasOwn(podcastContent, 'transcript') && typeof podcastContent.transcript === 'string'
|
|
792
|
+
|
|
793
|
+
podcastUpdatePlan = {
|
|
794
|
+
transcript: sourceChanged
|
|
795
|
+
? replacementTranscriptProvided
|
|
796
|
+
? (podcastContent.transcript as string)
|
|
797
|
+
: ''
|
|
798
|
+
: replacementTranscriptProvided
|
|
799
|
+
? (podcastContent.transcript as string)
|
|
800
|
+
: current.transcript,
|
|
801
|
+
audioR2Key,
|
|
802
|
+
durationSeconds:
|
|
803
|
+
durationProvided || sourceChanged
|
|
804
|
+
? resolvedDuration
|
|
805
|
+
: (resolvedDuration ?? current.duration_seconds),
|
|
806
|
+
processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
podcastUpdatePlan = null
|
|
810
|
+
}
|
|
811
|
+
}
|
|
555
812
|
|
|
556
813
|
const slug = input.slug ? await ensureUniqueSlug(db, input.slug, id) : existing.slug
|
|
557
814
|
const visibility = input.visibility || existing.visibility
|
|
@@ -735,37 +992,7 @@ export async function updateContentItem(
|
|
|
735
992
|
}
|
|
736
993
|
|
|
737
994
|
if (existing.type === 'video') {
|
|
738
|
-
|
|
739
|
-
.prepare(
|
|
740
|
-
`
|
|
741
|
-
SELECT script, video_id, duration_seconds, thumbnail_image_id,
|
|
742
|
-
processing_source_url, processing_source_kind
|
|
743
|
-
FROM video_content
|
|
744
|
-
WHERE content_id = ?
|
|
745
|
-
LIMIT 1
|
|
746
|
-
`,
|
|
747
|
-
)
|
|
748
|
-
.bind(id)
|
|
749
|
-
.first<{
|
|
750
|
-
script: string
|
|
751
|
-
video_id: string
|
|
752
|
-
duration_seconds: number | null
|
|
753
|
-
thumbnail_image_id: string | null
|
|
754
|
-
processing_source_url: string | null
|
|
755
|
-
processing_source_kind: string | null
|
|
756
|
-
}>()
|
|
757
|
-
if (!current) return { slug }
|
|
758
|
-
|
|
759
|
-
const videoContent = input.content as Partial<VideoInput['content']>
|
|
760
|
-
const nextSourceUrl = hasOwn(videoContent, 'processingSourceUrl')
|
|
761
|
-
? (videoContent.processingSourceUrl ?? null)
|
|
762
|
-
: current.processing_source_url
|
|
763
|
-
const nextSourceKind = hasOwn(videoContent, 'processingSourceKind')
|
|
764
|
-
? (videoContent.processingSourceKind ?? null)
|
|
765
|
-
: current.processing_source_kind
|
|
766
|
-
const sourceChanged =
|
|
767
|
-
nextSourceUrl !== current.processing_source_url ||
|
|
768
|
-
nextSourceKind !== current.processing_source_kind
|
|
995
|
+
if (!videoUpdatePlan) return { slug }
|
|
769
996
|
await db
|
|
770
997
|
.prepare(
|
|
771
998
|
`
|
|
@@ -790,78 +1017,51 @@ export async function updateContentItem(
|
|
|
790
1017
|
hls_manifest_url = CASE WHEN ? THEN NULL ELSE hls_manifest_url END,
|
|
791
1018
|
hls_poster_url = CASE WHEN ? THEN NULL ELSE hls_poster_url END
|
|
792
1019
|
WHERE content_id = ?
|
|
793
|
-
|
|
1020
|
+
`,
|
|
794
1021
|
)
|
|
795
1022
|
.bind(
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
sourceChanged ? 1 : 0,
|
|
809
|
-
sourceChanged ? 1 : 0,
|
|
810
|
-
sourceChanged ? 1 : 0,
|
|
811
|
-
sourceChanged ? 1 : 0,
|
|
812
|
-
sourceChanged ? 1 : 0,
|
|
813
|
-
sourceChanged ? 1 : 0,
|
|
814
|
-
sourceChanged ? 1 : 0,
|
|
815
|
-
sourceChanged ? 1 : 0,
|
|
816
|
-
sourceChanged ? 1 : 0,
|
|
817
|
-
sourceChanged ? 1 : 0,
|
|
818
|
-
sourceChanged ? 1 : 0,
|
|
819
|
-
sourceChanged ? 1 : 0,
|
|
820
|
-
sourceChanged ? 1 : 0,
|
|
1023
|
+
videoUpdatePlan.script,
|
|
1024
|
+
videoUpdatePlan.videoId,
|
|
1025
|
+
videoUpdatePlan.durationSeconds,
|
|
1026
|
+
videoUpdatePlan.thumbnailImageId,
|
|
1027
|
+
videoUpdatePlan.sourceUrl,
|
|
1028
|
+
videoUpdatePlan.sourceKind,
|
|
1029
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1030
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1031
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1032
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1033
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1034
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1035
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1036
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1037
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1038
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1039
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1040
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1041
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
821
1042
|
id,
|
|
822
1043
|
)
|
|
823
1044
|
.run()
|
|
824
1045
|
}
|
|
825
1046
|
|
|
826
1047
|
if (existing.type === 'podcast') {
|
|
827
|
-
|
|
828
|
-
.prepare(
|
|
829
|
-
`
|
|
830
|
-
SELECT transcript, audio_r2_key, duration_seconds
|
|
831
|
-
FROM podcast_content
|
|
832
|
-
WHERE content_id = ?
|
|
833
|
-
LIMIT 1
|
|
834
|
-
`,
|
|
835
|
-
)
|
|
836
|
-
.bind(id)
|
|
837
|
-
.first<{
|
|
838
|
-
transcript: string
|
|
839
|
-
audio_r2_key: string
|
|
840
|
-
duration_seconds: number | null
|
|
841
|
-
}>()
|
|
842
|
-
if (!current) return { slug }
|
|
843
|
-
|
|
844
|
-
const podcastContent = input.content as Partial<PodcastInput['content']>
|
|
1048
|
+
if (!podcastUpdatePlan) return { slug }
|
|
845
1049
|
await db
|
|
846
1050
|
.prepare(
|
|
847
1051
|
`
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1052
|
+
UPDATE podcast_content SET
|
|
1053
|
+
transcript = ?,
|
|
1054
|
+
audio_r2_key = ?,
|
|
1055
|
+
duration_seconds = ?,
|
|
1056
|
+
processing_trigger_token = ?
|
|
1057
|
+
WHERE content_id = ?
|
|
1058
|
+
`,
|
|
854
1059
|
)
|
|
855
1060
|
.bind(
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
? (podcastContent.audioR2Key ?? current.audio_r2_key)
|
|
861
|
-
: current.audio_r2_key,
|
|
862
|
-
hasOwn(podcastContent, 'durationSeconds')
|
|
863
|
-
? (podcastContent.durationSeconds ?? null)
|
|
864
|
-
: current.duration_seconds,
|
|
1061
|
+
podcastUpdatePlan.transcript,
|
|
1062
|
+
podcastUpdatePlan.audioR2Key,
|
|
1063
|
+
podcastUpdatePlan.durationSeconds,
|
|
1064
|
+
podcastUpdatePlan.processingTriggerToken,
|
|
865
1065
|
id,
|
|
866
1066
|
)
|
|
867
1067
|
.run()
|