@growth-labs/cms 0.8.2 → 0.8.4

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 (98) hide show
  1. package/dist/engine/ai-dek-notifications.d.ts +2 -0
  2. package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
  3. package/dist/engine/ai-dek-notifications.js +11 -2
  4. package/dist/engine/ai-dek-notifications.js.map +1 -1
  5. package/dist/engine/ai-dek.d.ts +23 -0
  6. package/dist/engine/ai-dek.d.ts.map +1 -1
  7. package/dist/engine/ai-dek.js +94 -31
  8. package/dist/engine/ai-dek.js.map +1 -1
  9. package/dist/engine/foundry-dispatch.d.ts +13 -3
  10. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  11. package/dist/engine/foundry-dispatch.js +115 -39
  12. package/dist/engine/foundry-dispatch.js.map +1 -1
  13. package/dist/engine/fronts-publish-intent.d.ts +55 -0
  14. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  15. package/dist/engine/fronts-publish-intent.js +187 -57
  16. package/dist/engine/fronts-publish-intent.js.map +1 -1
  17. package/dist/engine/fronts-publish.d.ts +11 -0
  18. package/dist/engine/fronts-publish.d.ts.map +1 -1
  19. package/dist/engine/fronts-publish.js +135 -10
  20. package/dist/engine/fronts-publish.js.map +1 -1
  21. package/dist/engine/index.d.ts +2 -2
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +2 -2
  24. package/dist/engine/index.js.map +1 -1
  25. package/dist/engine/podcast-source.d.ts +3 -2
  26. package/dist/engine/podcast-source.d.ts.map +1 -1
  27. package/dist/engine/podcast-source.js +6 -4
  28. package/dist/engine/podcast-source.js.map +1 -1
  29. package/dist/engine/publication-current-guard.d.ts +7 -0
  30. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  31. package/dist/engine/publication-current-guard.js +66 -0
  32. package/dist/engine/publication-current-guard.js.map +1 -1
  33. package/dist/engine/publication.d.ts +30 -0
  34. package/dist/engine/publication.d.ts.map +1 -1
  35. package/dist/engine/publication.js +306 -34
  36. package/dist/engine/publication.js.map +1 -1
  37. package/dist/engine/publisher.d.ts.map +1 -1
  38. package/dist/engine/publisher.js +32 -19
  39. package/dist/engine/publisher.js.map +1 -1
  40. package/dist/engine/soft-delete.d.ts +3 -1
  41. package/dist/engine/soft-delete.d.ts.map +1 -1
  42. package/dist/engine/soft-delete.js +21 -5
  43. package/dist/engine/soft-delete.js.map +1 -1
  44. package/dist/engine/video-source.d.ts +4 -2
  45. package/dist/engine/video-source.d.ts.map +1 -1
  46. package/dist/engine/video-source.js +7 -4
  47. package/dist/engine/video-source.js.map +1 -1
  48. package/dist/routes/content.d.ts +14 -0
  49. package/dist/routes/content.d.ts.map +1 -1
  50. package/dist/routes/content.js +56 -28
  51. package/dist/routes/content.js.map +1 -1
  52. package/dist/routes/context.d.ts +6 -0
  53. package/dist/routes/context.d.ts.map +1 -1
  54. package/dist/routes/context.js.map +1 -1
  55. package/dist/routes/fronts-publish.d.ts +2 -0
  56. package/dist/routes/fronts-publish.d.ts.map +1 -1
  57. package/dist/routes/fronts-publish.js +178 -2
  58. package/dist/routes/fronts-publish.js.map +1 -1
  59. package/dist/schema/insights-ingest.d.ts +42 -42
  60. package/dist/schema/migrations.d.ts.map +1 -1
  61. package/dist/schema/migrations.js +8 -0
  62. package/dist/schema/migrations.js.map +1 -1
  63. package/dist/schema/types.d.ts +6 -0
  64. package/dist/schema/types.d.ts.map +1 -1
  65. package/dist/schema/types.js.map +1 -1
  66. package/dist/surveys/schema.d.ts +48 -48
  67. package/dist/ui/api/_content-config.d.ts.map +1 -1
  68. package/dist/ui/api/_content-config.js +7 -0
  69. package/dist/ui/api/_content-config.js.map +1 -1
  70. package/dist/ui/api/fronts/schedule.d.ts +2 -0
  71. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  72. package/dist/ui/api/fronts/schedule.js +2 -0
  73. package/dist/ui/api/fronts/schedule.js.map +1 -1
  74. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  75. package/dist/ui/editor/ContentForm.js +2 -2
  76. package/dist/ui/editor/ContentForm.js.map +1 -1
  77. package/migrations/0030_media_publication_guard.sql +5 -0
  78. package/package.json +1 -1
  79. package/src/engine/ai-dek-notifications.ts +19 -2
  80. package/src/engine/ai-dek.ts +131 -40
  81. package/src/engine/foundry-dispatch.ts +189 -49
  82. package/src/engine/fronts-publish-intent.ts +295 -67
  83. package/src/engine/fronts-publish.ts +167 -11
  84. package/src/engine/index.ts +4 -0
  85. package/src/engine/podcast-source.ts +6 -4
  86. package/src/engine/publication-current-guard.ts +86 -0
  87. package/src/engine/publication.ts +411 -59
  88. package/src/engine/publisher.ts +37 -17
  89. package/src/engine/soft-delete.ts +28 -8
  90. package/src/engine/video-source.ts +7 -4
  91. package/src/routes/content.ts +78 -35
  92. package/src/routes/context.ts +6 -0
  93. package/src/routes/fronts-publish.ts +213 -2
  94. package/src/schema/migrations.ts +8 -0
  95. package/src/schema/types.ts +6 -0
  96. package/src/ui/api/_content-config.ts +15 -0
  97. package/src/ui/api/fronts/schedule.ts +4 -0
  98. package/src/ui/editor/ContentForm.tsx +4 -5
@@ -304,15 +304,53 @@ export function parseFrontsChannelTargets(
304
304
  return normalizeChannelTargets(parsed)
305
305
  }
306
306
 
307
+ /** Host defaults are opt-in; an invalid configured list must fail before a save. */
308
+ export function parseFrontsDefaultChannelTargets(
309
+ value: unknown,
310
+ ):
311
+ | { targets: string[] }
312
+ | { error: 'missing_fronts_default_channel_targets' | 'invalid_fronts_default_channel_targets' } {
313
+ if (value === undefined) return { error: 'missing_fronts_default_channel_targets' }
314
+ if (!Array.isArray(value)) return { error: 'invalid_fronts_default_channel_targets' }
315
+ const parsed = normalizeChannelTargets(value)
316
+ return 'code' in parsed ? { error: 'invalid_fronts_default_channel_targets' } : parsed
317
+ }
318
+
319
+ interface ScheduledTargetsSnapshot {
320
+ id: string
321
+ canonical_version: number | null
322
+ publish_at: number
323
+ updated_at: number | null
324
+ fronts_publish_trigger_token: string | null
325
+ fronts_publish_status: string | null
326
+ }
327
+
328
+ interface ChannelTargetsWriteOptions {
329
+ onlyIfNull?: boolean
330
+ expectedScheduled?: ScheduledTargetsSnapshot
331
+ }
332
+
307
333
  /**
308
334
  * Arm an item's publish targets. Rejects an empty or non-string list loudly
309
335
  * instead of storing something that would later hydrate as "complete".
310
336
  */
337
+ export function setFrontsChannelTargets(
338
+ db: D1Database,
339
+ contentId: string,
340
+ targets: readonly string[],
341
+ ): Promise<void>
342
+ export function setFrontsChannelTargets(
343
+ db: D1Database,
344
+ contentId: string,
345
+ targets: readonly string[],
346
+ options: ChannelTargetsWriteOptions,
347
+ ): Promise<boolean>
311
348
  export async function setFrontsChannelTargets(
312
349
  db: D1Database,
313
350
  contentId: string,
314
351
  targets: readonly string[],
315
- ): Promise<void> {
352
+ options?: ChannelTargetsWriteOptions,
353
+ ): Promise<void | boolean> {
316
354
  const normalized = normalizeChannelTargets(targets)
317
355
  if ('code' in normalized) {
318
356
  throw new FrontsPublishIntentError(
@@ -321,10 +359,83 @@ export async function setFrontsChannelTargets(
321
359
  'targets must be a non-empty list of non-empty strings',
322
360
  )
323
361
  }
324
- await db
325
- .prepare('UPDATE content_items SET fronts_channel_targets = ? WHERE id = ?')
326
- .bind(JSON.stringify(normalized.targets), contentId)
362
+ const predicates = ['id = ?']
363
+ const values: unknown[] = [JSON.stringify(normalized.targets), contentId]
364
+ if (options?.onlyIfNull || options?.expectedScheduled) {
365
+ predicates.push('fronts_channel_targets IS NULL', 'deleted_at IS NULL')
366
+ }
367
+ if (options?.expectedScheduled) {
368
+ const expected = options.expectedScheduled
369
+ if (expected.id !== contentId)
370
+ throw new Error('Channel target snapshot belongs to different content')
371
+ predicates.push(
372
+ "status = 'scheduled'",
373
+ 'canonical_version IS ?',
374
+ 'publish_at IS ?',
375
+ 'updated_at IS ?',
376
+ 'fronts_publish_trigger_token IS ?',
377
+ 'fronts_publish_status IS ?',
378
+ )
379
+ values.push(
380
+ expected.canonical_version,
381
+ expected.publish_at,
382
+ expected.updated_at,
383
+ expected.fronts_publish_trigger_token,
384
+ expected.fronts_publish_status,
385
+ )
386
+ }
387
+ const result = await db
388
+ .prepare(
389
+ `UPDATE content_items SET fronts_channel_targets = ? WHERE ${predicates.join(' AND ')}`,
390
+ )
391
+ .bind(...values)
327
392
  .run()
393
+ // Preserve the original explicit setter contract. CAS callers need a proved
394
+ // change count, including an honest false when another writer won.
395
+ if (!options) return
396
+ const changes = result.meta?.changes
397
+ if (
398
+ result.success === false ||
399
+ typeof changes !== 'number' ||
400
+ !Number.isInteger(changes) ||
401
+ changes < 0 ||
402
+ changes > 1
403
+ ) {
404
+ throw new Error('Fronts channel target write did not report a valid change count')
405
+ }
406
+ return changes === 1
407
+ }
408
+
409
+ /** A bounded repair requested by Go; all rows keep their current publish/media identity. */
410
+ export async function backfillFrontsChannelTargets(
411
+ db: D1Database,
412
+ targets: readonly string[],
413
+ limit: number,
414
+ ): Promise<{ examined: number; armed: number; skipped: number; contentIds: string[] }> {
415
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
416
+ throw new Error('Invalid channel target backfill limit')
417
+ const selected = await db
418
+ .prepare(`SELECT id, canonical_version, publish_at, updated_at,
419
+ fronts_publish_trigger_token, fronts_publish_status
420
+ FROM content_items
421
+ WHERE status = 'scheduled' AND deleted_at IS NULL
422
+ AND publish_at IS NOT NULL AND fronts_channel_targets IS NULL
423
+ ORDER BY publish_at ASC, id ASC LIMIT ?`)
424
+ .bind(limit)
425
+ .all<ScheduledTargetsSnapshot>()
426
+ if (selected.success === false || !Array.isArray(selected.results))
427
+ throw new Error('Fronts channel target selection failed')
428
+ const contentIds: string[] = []
429
+ for (const row of selected.results) {
430
+ if (await setFrontsChannelTargets(db, row.id, targets, { expectedScheduled: row }))
431
+ contentIds.push(row.id)
432
+ }
433
+ return {
434
+ examined: selected.results.length,
435
+ armed: contentIds.length,
436
+ skipped: selected.results.length - contentIds.length,
437
+ contentIds,
438
+ }
328
439
  }
329
440
 
330
441
  // --- hydration -------------------------------------------------------------
@@ -427,6 +538,7 @@ interface VideoRefRow {
427
538
  processing_source_url: string | null
428
539
  processing_source_kind: string | null
429
540
  processing_trigger_token: string | null
541
+ processing_state: string | null
430
542
  processing_correlation_id: string | null
431
543
  }
432
544
 
@@ -438,6 +550,7 @@ interface PodcastRefRow {
438
550
  processing_source_url: string | null
439
551
  processing_source_kind: string | null
440
552
  processing_trigger_token: string | null
553
+ processing_state: string | null
441
554
  }
442
555
 
443
556
  interface MediaRow {
@@ -571,7 +684,7 @@ async function loadContentRefSources(
571
684
  .prepare(
572
685
  `SELECT content_id, hls_ready, hls_manifest_url, hls_poster_url, duration_seconds,
573
686
  thumbnail_image_id, processing_source_url, processing_source_kind,
574
- processing_trigger_token, processing_correlation_id
687
+ processing_trigger_token, processing_correlation_id, processing_state
575
688
  FROM video_content WHERE content_id IN (${placeholders(videoIds.length)})`,
576
689
  )
577
690
  .bind(...videoIds)
@@ -583,7 +696,7 @@ async function loadContentRefSources(
583
696
  if (podcastIds.length > 0) {
584
697
  const res = await db
585
698
  .prepare(
586
- `SELECT content_id, transcript, audio_r2_key, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token FROM podcast_content
699
+ `SELECT content_id, transcript, audio_r2_key, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token, processing_state FROM podcast_content
587
700
  WHERE content_id IN (${placeholders(podcastIds.length)})`,
588
701
  )
589
702
  .bind(...podcastIds)
@@ -617,6 +730,178 @@ async function loadContentRefSources(
617
730
  return { articles, videos, podcasts, media }
618
731
  }
619
732
 
733
+ /** Preparation and publication projections share the same source validation. */
734
+ async function videoSourceRef(
735
+ row: IntentCandidateRow,
736
+ found: VideoRefRow | undefined,
737
+ ): Promise<FrontsPublishVideoSourceRef | null> {
738
+ const correlation = found?.processing_correlation_id?.trim()
739
+ if (
740
+ !found ||
741
+ !validPodcastSourceUrl(found.processing_source_url) ||
742
+ !videoSourceIngestableOverHttps(found.processing_source_kind) ||
743
+ !correlation ||
744
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlation)
745
+ )
746
+ return null
747
+ if (
748
+ typeof found.duration_seconds !== 'number' ||
749
+ !Number.isFinite(found.duration_seconds) ||
750
+ found.duration_seconds <= 0
751
+ ) {
752
+ throw new FrontsPublishIntentError(
753
+ 'video_source_duration_unavailable',
754
+ row.id,
755
+ 'Video source duration is not verified.',
756
+ )
757
+ }
758
+ if (found.processing_state !== 'queued' || !found.processing_trigger_token?.trim()) return null
759
+ return {
760
+ kind: 'https',
761
+ url: found.processing_source_url,
762
+ duration_seconds: found.duration_seconds,
763
+ generation: await frontsVideoSourceGeneration({
764
+ content_id: row.id,
765
+ slug: row.slug,
766
+ canonical_version: row.canonical_version as number,
767
+ processing_source_url: found.processing_source_url,
768
+ processing_source_kind: found.processing_source_kind,
769
+ processing_trigger_token: found.processing_trigger_token,
770
+ }),
771
+ correlation_id: correlation,
772
+ visibility: row.visibility === 'premium' ? 'premium' : 'free',
773
+ }
774
+ }
775
+
776
+ async function podcastSourceRef(
777
+ row: IntentCandidateRow,
778
+ found: PodcastRefRow | undefined,
779
+ ): Promise<FrontsPublishPodcastSourceRef | null> {
780
+ if (
781
+ !found ||
782
+ !validPodcastSourceUrl(found.processing_source_url) ||
783
+ found.processing_source_kind !== 'https'
784
+ )
785
+ return null
786
+ if (
787
+ typeof found.duration_seconds !== 'number' ||
788
+ !Number.isFinite(found.duration_seconds) ||
789
+ found.duration_seconds <= 0
790
+ ) {
791
+ throw new FrontsPublishIntentError(
792
+ 'podcast_source_duration_unavailable',
793
+ row.id,
794
+ 'Podcast source duration is not verified.',
795
+ )
796
+ }
797
+ if (found.processing_state !== 'queued' || !found.processing_trigger_token?.trim()) return null
798
+ return {
799
+ kind: 'https',
800
+ url: found.processing_source_url,
801
+ duration_seconds: found.duration_seconds,
802
+ generation: await frontsPodcastSourceGeneration({
803
+ content_id: row.id,
804
+ slug: row.slug,
805
+ canonical_version: row.canonical_version as number,
806
+ processing_source_url: found.processing_source_url,
807
+ processing_source_kind: found.processing_source_kind,
808
+ processing_trigger_token: found.processing_trigger_token,
809
+ }),
810
+ }
811
+ }
812
+
813
+ /** A captured source is preparation authority, never publication authority. */
814
+ export interface FrontsMediaPreparation {
815
+ content_id: string
816
+ type: 'video' | 'podcast'
817
+ slug: string
818
+ channel: string | null
819
+ canonical_version: number
820
+ /** The actual slot, if any; never invented for a draft. */
821
+ publish_at: number | null
822
+ /** Actual capture time; unknown for a legacy token. */
823
+ captured_at: number | null
824
+ video_source: FrontsPublishVideoSourceRef | null
825
+ podcast_source: FrontsPublishPodcastSourceRef | null
826
+ }
827
+
828
+ export interface FrontsMediaPreparations {
829
+ version: 1
830
+ items: FrontsMediaPreparation[]
831
+ incomplete: Array<{ content_id: string; code: string; message: string }>
832
+ }
833
+
834
+ /**
835
+ * Extend the existing authenticated poll with captured media, independently of
836
+ * publish_at/status=scheduled. No publish token, fake deadline or new queue is
837
+ * minted by this read. A finite complete response fails loudly on overflow.
838
+ */
839
+ export async function hydrateFrontsMediaPreparations(
840
+ db: D1Database,
841
+ options: Pick<
842
+ HydrateFrontsPublishIntentsOptions,
843
+ 'limit' | 'videoSourceProductionEnabled' | 'podcastSourceProductionEnabled'
844
+ > = {},
845
+ ): Promise<FrontsMediaPreparations> {
846
+ const limit = Math.min(Math.max(1, Math.floor(options.limit ?? 200)), 1000)
847
+ const result: FrontsMediaPreparations = { version: 1, items: [], incomplete: [] }
848
+ const candidates = await db
849
+ .prepare(`SELECT ${CANDIDATE_COLUMNS} FROM content_items
850
+ WHERE deleted_at IS NULL AND status IN ('draft', 'review', 'scheduled', 'published')
851
+ AND ((type = 'video' AND ? = 1 AND EXISTS (
852
+ SELECT 1 FROM video_content v WHERE v.content_id = content_items.id
853
+ AND v.processing_trigger_token IS NOT NULL AND v.processing_state = 'queued'
854
+ AND (v.hls_ready <> 1 OR v.hls_manifest_url IS NULL OR v.transcript_ready <> 1)))
855
+ OR (type = 'podcast' AND ? = 1 AND EXISTS (
856
+ SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
857
+ AND p.processing_trigger_token IS NOT NULL AND p.processing_state = 'queued'
858
+ AND (trim(COALESCE(p.audio_r2_key, '')) = '' OR trim(COALESCE(p.transcript, '')) = ''))))
859
+ ORDER BY created_at ASC, id ASC LIMIT ?`)
860
+ .bind(
861
+ options.videoSourceProductionEnabled === false ? 0 : 1,
862
+ options.podcastSourceProductionEnabled === false ? 0 : 1,
863
+ limit + 1,
864
+ )
865
+ .all<IntentCandidateRow>()
866
+ const rows = candidates.results ?? []
867
+ if (rows.length > limit) throw new FrontsScheduleOverflowError(limit)
868
+ const refs = await loadContentRefSources(db, rows)
869
+ for (const row of rows) {
870
+ try {
871
+ if (!Number.isInteger(row.canonical_version) || (row.canonical_version ?? 0) <= 0) {
872
+ throw new FrontsPublishIntentError('invalid_canonical_version', row.id)
873
+ }
874
+ const video = row.type === 'video' ? await videoSourceRef(row, refs.videos.get(row.id)) : null
875
+ const podcast =
876
+ row.type === 'podcast' ? await podcastSourceRef(row, refs.podcasts.get(row.id)) : null
877
+ if (!video && !podcast)
878
+ throw new FrontsPublishIntentError(
879
+ row.type === 'video' ? 'video_media_not_ready' : 'missing_podcast_audio',
880
+ row.id,
881
+ 'Captured source is incomplete or not ingestable.',
882
+ )
883
+ const token = (row.type === 'video' ? refs.videos.get(row.id) : refs.podcasts.get(row.id))
884
+ ?.processing_trigger_token
885
+ const capturedAt = /^fronts-source-v2:([1-9][0-9]*):[0-9a-f-]{36}$/.exec(token ?? '')?.[1]
886
+ result.items.push({
887
+ content_id: row.id,
888
+ type: row.type as 'video' | 'podcast',
889
+ slug: row.slug,
890
+ channel: row.channel,
891
+ canonical_version: row.canonical_version as number,
892
+ publish_at: row.publish_at,
893
+ captured_at: capturedAt ? Number(capturedAt) : null,
894
+ video_source: video,
895
+ podcast_source: podcast,
896
+ })
897
+ } catch (error) {
898
+ if (!(error instanceof FrontsPublishIntentError)) throw error
899
+ result.incomplete.push({ content_id: row.id, code: error.code, message: error.message })
900
+ }
901
+ }
902
+ return result
903
+ }
904
+
620
905
  /** Build one intent, throwing FrontsPublishIntentError on any missing required value. */
621
906
  async function buildIntent(
622
907
  row: IntentCandidateRow,
@@ -739,41 +1024,8 @@ async function buildContentRefs(
739
1024
  message:
740
1025
  'a video intent requires video_content.hls_ready = 1 and a non-empty hls_manifest_url',
741
1026
  }
742
- const sourceUrl = found?.processing_source_url
743
- const correlationId = found?.processing_correlation_id?.trim()
744
- if (
745
- found &&
746
- validPodcastSourceUrl(sourceUrl) &&
747
- videoSourceIngestableOverHttps(found.processing_source_kind) &&
748
- correlationId &&
749
- /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlationId)
750
- ) {
751
- if (
752
- typeof found.duration_seconds !== 'number' ||
753
- !Number.isFinite(found.duration_seconds) ||
754
- found.duration_seconds <= 0
755
- ) {
756
- throw new FrontsPublishIntentError(
757
- 'video_source_duration_unavailable',
758
- row.id,
759
- 'Video source duration is not verified.',
760
- )
761
- }
762
- videoSource = {
763
- kind: 'https',
764
- url: sourceUrl,
765
- duration_seconds: found.duration_seconds,
766
- generation: await frontsVideoSourceGeneration({
767
- content_id: row.id,
768
- slug: row.slug,
769
- canonical_version: row.canonical_version as number,
770
- processing_source_url: found.processing_source_url,
771
- processing_source_kind: found.processing_source_kind,
772
- processing_trigger_token: found.processing_trigger_token,
773
- }),
774
- correlation_id: correlationId,
775
- visibility: row.visibility === 'premium' ? 'premium' : 'free',
776
- }
1027
+ videoSource = await videoSourceRef(row, found)
1028
+ if (videoSource) {
777
1029
  incomplete = {
778
1030
  content_id: row.id,
779
1031
  code: failure.code,
@@ -824,32 +1076,8 @@ async function buildContentRefs(
824
1076
  code: 'missing_podcast_audio' as const,
825
1077
  message: 'Podcast audio is not ready.',
826
1078
  }
827
- const sourceUrl = found?.processing_source_url
828
- if (found && validPodcastSourceUrl(sourceUrl) && found.processing_source_kind === 'https') {
829
- if (
830
- typeof found.duration_seconds !== 'number' ||
831
- !Number.isFinite(found.duration_seconds) ||
832
- found.duration_seconds <= 0
833
- ) {
834
- throw new FrontsPublishIntentError(
835
- 'podcast_source_duration_unavailable',
836
- row.id,
837
- 'Podcast source duration is not verified.',
838
- )
839
- }
840
- podcastSource = {
841
- kind: 'https',
842
- url: sourceUrl,
843
- duration_seconds: found.duration_seconds,
844
- generation: await frontsPodcastSourceGeneration({
845
- content_id: row.id,
846
- slug: row.slug,
847
- canonical_version: row.canonical_version as number,
848
- processing_source_url: found.processing_source_url,
849
- processing_source_kind: found.processing_source_kind,
850
- processing_trigger_token: found.processing_trigger_token,
851
- }),
852
- }
1079
+ podcastSource = await podcastSourceRef(row, found)
1080
+ if (podcastSource) {
853
1081
  incomplete = {
854
1082
  content_id: row.id,
855
1083
  code: failure.code,