@growth-labs/cms 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +30 -3
  2. package/dist/engine/foundry-dispatch.d.ts +5 -1
  3. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  4. package/dist/engine/foundry-dispatch.js +19 -3
  5. package/dist/engine/foundry-dispatch.js.map +1 -1
  6. package/dist/engine/index.d.ts +1 -1
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +1 -1
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/published-content.d.ts.map +1 -1
  11. package/dist/engine/published-content.js +29 -12
  12. package/dist/engine/published-content.js.map +1 -1
  13. package/dist/engine/publisher.d.ts +4 -0
  14. package/dist/engine/publisher.d.ts.map +1 -1
  15. package/dist/engine/publisher.js +190 -54
  16. package/dist/engine/publisher.js.map +1 -1
  17. package/dist/providers/types.d.ts +1 -0
  18. package/dist/providers/types.d.ts.map +1 -1
  19. package/dist/routes/content.d.ts.map +1 -1
  20. package/dist/routes/content.js +76 -16
  21. package/dist/routes/content.js.map +1 -1
  22. package/dist/routes/context.d.ts +1 -0
  23. package/dist/routes/context.d.ts.map +1 -1
  24. package/dist/routes/context.js.map +1 -1
  25. package/dist/routes/media.d.ts.map +1 -1
  26. package/dist/routes/media.js +53 -31
  27. package/dist/routes/media.js.map +1 -1
  28. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  29. package/dist/ui/editor/ContentForm.js +65 -10
  30. package/dist/ui/editor/ContentForm.js.map +1 -1
  31. package/dist/ui/editor/content-payload.d.ts +2 -0
  32. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  33. package/dist/ui/editor/content-payload.js +15 -4
  34. package/dist/ui/editor/content-payload.js.map +1 -1
  35. package/dist/ui/editor/editor-media-duration.d.ts +24 -0
  36. package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
  37. package/dist/ui/editor/editor-media-duration.js +51 -0
  38. package/dist/ui/editor/editor-media-duration.js.map +1 -0
  39. package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
  40. package/dist/ui/screens/MediaScreen.js +8 -1
  41. package/dist/ui/screens/MediaScreen.js.map +1 -1
  42. package/dist/ui/screens/media-upload.d.ts +9 -2
  43. package/dist/ui/screens/media-upload.d.ts.map +1 -1
  44. package/dist/ui/screens/media-upload.js +24 -0
  45. package/dist/ui/screens/media-upload.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/engine/foundry-dispatch.ts +32 -4
  48. package/src/engine/index.ts +1 -0
  49. package/src/engine/published-content.ts +32 -16
  50. package/src/engine/publisher.ts +297 -97
  51. package/src/providers/types.ts +1 -0
  52. package/src/routes/content.ts +91 -16
  53. package/src/routes/context.ts +1 -0
  54. package/src/routes/media.ts +63 -41
  55. package/src/ui/editor/ContentForm.tsx +121 -4
  56. package/src/ui/editor/content-payload.ts +20 -2
  57. package/src/ui/editor/editor-media-duration.ts +75 -0
  58. package/src/ui/screens/MediaScreen.tsx +13 -2
  59. 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('SELECT content_id, audio_r2_key FROM podcast_content WHERE content_id = ?')
152
+ .prepare(
153
+ 'SELECT content_id, audio_r2_key, duration_seconds FROM podcast_content WHERE content_id = ?',
154
+ )
141
155
  .bind(contentId)
142
- .first<{ content_id: string; audio_r2_key: string | null }>()
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,
@@ -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.
@@ -17,6 +17,14 @@ export const MAX_PUBLISHED_CONTENT_PAGE_SIZE = 100
17
17
  export const MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES = 4 * 1024 * 1024
18
18
  export const MAX_PUBLISHED_CONTENT_PAGE_BYTES = 4 * 1024 * 1024
19
19
 
20
+ // D1/SQLite caps a single prepared statement at SQLITE_MAX_VARIABLE_NUMBER (100)
21
+ // bound variables. The hydrate query binds two variables per selected outline
22
+ // (`ci.id` + `ci.published_revision_id`), so a page of 51+ outlines in one
23
+ // statement exceeds the cap and D1 rejects it with `too many SQL variables`.
24
+ // Hydrate in chunks of at most this many outlines (=> <=100 bound variables) so
25
+ // a full MAX_PUBLISHED_CONTENT_PAGE_SIZE page reads across multiple statements.
26
+ const MAX_HYDRATE_CHUNK_OUTLINES = 50
27
+
20
28
  export type PublishedContentReadErrorCode =
21
29
  | 'published_content_input_invalid'
22
30
  | 'published_revision_missing'
@@ -534,25 +542,33 @@ LIMIT ?`,
534
542
  }
535
543
  if (selected.length === 0) return { items: [], nextCursor: null }
536
544
 
537
- const selectedPredicates = selected.map(() => '(ci.id = ? AND ci.published_revision_id = ?)')
538
- const selectedValues = selected.flatMap((outline) => [
539
- outline.content_id,
540
- outline.pointer_revision_id,
541
- ])
542
- const result = await db
543
- .prepare(
544
- `${SELECT_PUBLISHED_CONTENT}
545
+ // Hydrate the selected outlines in bound-variable-safe chunks. Each chunk
546
+ // binds two variables per outline; capping at MAX_HYDRATE_CHUNK_OUTLINES
547
+ // keeps every statement at or under D1's 100-variable limit. Rows merge
548
+ // into one pointer-keyed map, so ordering and the per-item integrity
549
+ // checks below are identical to a single-statement hydrate.
550
+ const rowsByPointer = new Map<string, PublishedContentRow>()
551
+ for (let offset = 0; offset < selected.length; offset += MAX_HYDRATE_CHUNK_OUTLINES) {
552
+ const chunk = selected.slice(offset, offset + MAX_HYDRATE_CHUNK_OUTLINES)
553
+ const chunkPredicates = chunk.map(() => '(ci.id = ? AND ci.published_revision_id = ?)')
554
+ const chunkValues = chunk.flatMap((outline) => [
555
+ outline.content_id,
556
+ outline.pointer_revision_id,
557
+ ])
558
+ const chunkResult = await db
559
+ .prepare(
560
+ `${SELECT_PUBLISHED_CONTENT}
545
561
  JOIN content_revisions cr ON cr.id = ci.published_revision_id
546
562
  WHERE ci.status = 'published'
547
563
  AND ci.deleted_at IS NULL
548
- AND (${selectedPredicates.join(' OR ')})`,
549
- )
550
- .bind(...selectedValues)
551
- .all<PublishedContentRow>()
552
- const rows = result.results ?? []
553
- const rowsByPointer = new Map(
554
- rows.map((row) => [`${row.content_id}\0${row.pointer_revision_id}`, row]),
555
- )
564
+ AND (${chunkPredicates.join(' OR ')})`,
565
+ )
566
+ .bind(...chunkValues)
567
+ .all<PublishedContentRow>()
568
+ for (const row of chunkResult.results ?? []) {
569
+ rowsByPointer.set(`${row.content_id}\0${row.pointer_revision_id}`, row)
570
+ }
571
+ }
556
572
  const items = selected.map((outline) => {
557
573
  const row = rowsByPointer.get(`${outline.content_id}\0${outline.pointer_revision_id}`)
558
574
  if (
@@ -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
- input.content.durationSeconds || null,
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
- const current = await db
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
- hasOwn(videoContent, 'script') ? (videoContent.script ?? current.script) : current.script,
797
- hasOwn(videoContent, 'videoId')
798
- ? (videoContent.videoId ?? current.video_id)
799
- : current.video_id,
800
- hasOwn(videoContent, 'durationSeconds')
801
- ? (videoContent.durationSeconds ?? null)
802
- : current.duration_seconds,
803
- hasOwn(videoContent, 'thumbnailImageId')
804
- ? (videoContent.thumbnailImageId ?? null)
805
- : current.thumbnail_image_id,
806
- nextSourceUrl,
807
- nextSourceKind,
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
- const current = await db
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
- UPDATE podcast_content SET
849
- transcript = ?,
850
- audio_r2_key = ?,
851
- duration_seconds = ?
852
- WHERE content_id = ?
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
- hasOwn(podcastContent, 'transcript')
857
- ? (podcastContent.transcript ?? current.transcript)
858
- : current.transcript,
859
- hasOwn(podcastContent, 'audioR2Key')
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()
@@ -237,6 +237,7 @@ export interface FoundryJobSpec {
237
237
  sourceUrl: string
238
238
  sourceKind?: string
239
239
  kind: string
240
+ durationSeconds: number
240
241
  videoId?: string
241
242
  title?: string
242
243
  description?: string | null