@growth-labs/cms 0.6.3 → 0.6.5

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.
@@ -343,12 +343,19 @@ export interface UpdateContentInput extends Partial<BaseContentInput> {
343
343
  export interface UpdateContentOptions {
344
344
  /** Atomically apply the update only while the content row has this status. */
345
345
  requiredStatus?: ContentStatus
346
+ /** Apply a resolved media source only while this exact source identity is still stored. */
347
+ expectedMediaSource?: {
348
+ sourceUrl: string | null
349
+ sourceKind: string | null
350
+ }
346
351
  }
347
352
 
348
353
  export interface UpdateContentResult {
349
354
  slug: string
350
355
  /** Present when requiredStatus lost a race before the mutation transaction. */
351
356
  statusConflict?: string
357
+ /** Present when a newer media-source update won while this request was resolving. */
358
+ mediaSourceConflict?: boolean
352
359
  }
353
360
 
354
361
  interface VideoUpdatePlan {
@@ -1113,9 +1120,10 @@ export async function updateContentItem(
1113
1120
  return { slug }
1114
1121
  }
1115
1122
 
1116
- await db
1117
- .prepare(
1118
- `
1123
+ const updateBaseContent = async () => {
1124
+ await db
1125
+ .prepare(
1126
+ `
1119
1127
  UPDATE content_items SET
1120
1128
  slug = ?,
1121
1129
  title = ?,
@@ -1145,38 +1153,40 @@ export async function updateContentItem(
1145
1153
  canonical_version = canonical_version + 1
1146
1154
  WHERE id = ?
1147
1155
  `,
1148
- )
1149
- .bind(
1150
- slug,
1151
- title,
1152
- seoTitle,
1153
- description,
1154
- excerpt,
1155
- byline,
1156
- primaryCategory,
1157
- primaryTopic,
1158
- featured,
1159
- visibility,
1160
- authorId,
1161
- heroImageId,
1162
- heroImageAlt,
1163
- heroImageCaption,
1164
- socialImageId,
1165
- canonicalUrl,
1166
- aiLockedFields,
1167
- seoFocusKeyword,
1168
- metadataJson,
1169
- id,
1170
- )
1171
- .run()
1156
+ )
1157
+ .bind(
1158
+ slug,
1159
+ title,
1160
+ seoTitle,
1161
+ description,
1162
+ excerpt,
1163
+ byline,
1164
+ primaryCategory,
1165
+ primaryTopic,
1166
+ featured,
1167
+ visibility,
1168
+ authorId,
1169
+ heroImageId,
1170
+ heroImageAlt,
1171
+ heroImageCaption,
1172
+ socialImageId,
1173
+ canonicalUrl,
1174
+ aiLockedFields,
1175
+ seoFocusKeyword,
1176
+ metadataJson,
1177
+ id,
1178
+ )
1179
+ .run()
1172
1180
 
1173
- if (input.tags !== undefined) {
1174
- await setContentTagPairs(db, id, tagPairs ?? [])
1175
- }
1181
+ if (input.tags !== undefined) {
1182
+ await setContentTagPairs(db, id, tagPairs ?? [])
1183
+ }
1176
1184
 
1177
- if (input.relations !== undefined) {
1178
- await setContentRelations(db, id, input.relations)
1185
+ if (input.relations !== undefined) {
1186
+ await setContentRelations(db, id, input.relations)
1187
+ }
1179
1188
  }
1189
+ if (!options.expectedMediaSource) await updateBaseContent()
1180
1190
 
1181
1191
  if (input.content) {
1182
1192
  if (isBodyBackedContentType(existing.type)) {
@@ -1276,7 +1286,7 @@ export async function updateContentItem(
1276
1286
 
1277
1287
  if (existing.type === 'video') {
1278
1288
  if (!videoUpdatePlan) return { slug }
1279
- await db
1289
+ const result = await db
1280
1290
  .prepare(
1281
1291
  `
1282
1292
  UPDATE video_content SET
@@ -1299,7 +1309,8 @@ export async function updateContentItem(
1299
1309
  transcript_url = CASE WHEN ? THEN NULL ELSE transcript_url END,
1300
1310
  hls_manifest_url = CASE WHEN ? THEN NULL ELSE hls_manifest_url END,
1301
1311
  hls_poster_url = CASE WHEN ? THEN NULL ELSE hls_poster_url END
1302
- WHERE content_id = ?
1312
+ WHERE content_id = ?
1313
+ AND (? = 0 OR (processing_source_url IS ? AND processing_source_kind IS ?))
1303
1314
  `,
1304
1315
  )
1305
1316
  .bind(
@@ -1323,13 +1334,19 @@ export async function updateContentItem(
1323
1334
  videoUpdatePlan.sourceChanged ? 1 : 0,
1324
1335
  videoUpdatePlan.sourceChanged ? 1 : 0,
1325
1336
  id,
1337
+ options.expectedMediaSource ? 1 : 0,
1338
+ options.expectedMediaSource?.sourceUrl ?? null,
1339
+ options.expectedMediaSource?.sourceKind ?? null,
1326
1340
  )
1327
1341
  .run()
1342
+ if (options.expectedMediaSource && (result.meta?.changes ?? 0) !== 1) {
1343
+ return { slug, mediaSourceConflict: true }
1344
+ }
1328
1345
  }
1329
1346
 
1330
1347
  if (existing.type === 'podcast') {
1331
1348
  if (!podcastUpdatePlan) return { slug }
1332
- await db
1349
+ const result = await db
1333
1350
  .prepare(
1334
1351
  `
1335
1352
  UPDATE podcast_content SET
@@ -1340,6 +1357,7 @@ export async function updateContentItem(
1340
1357
  processing_source_url = ?,
1341
1358
  processing_source_kind = ?
1342
1359
  WHERE content_id = ?
1360
+ AND (? = 0 OR (processing_source_url IS ? AND processing_source_kind IS ?))
1343
1361
  `,
1344
1362
  )
1345
1363
  .bind(
@@ -1350,10 +1368,17 @@ export async function updateContentItem(
1350
1368
  podcastUpdatePlan.sourceUrl,
1351
1369
  podcastUpdatePlan.sourceKind,
1352
1370
  id,
1371
+ options.expectedMediaSource ? 1 : 0,
1372
+ options.expectedMediaSource?.sourceUrl ?? null,
1373
+ options.expectedMediaSource?.sourceKind ?? null,
1353
1374
  )
1354
1375
  .run()
1376
+ if (options.expectedMediaSource && (result.meta?.changes ?? 0) !== 1) {
1377
+ return { slug, mediaSourceConflict: true }
1378
+ }
1355
1379
  }
1356
1380
  }
1381
+ if (options.expectedMediaSource) await updateBaseContent()
1357
1382
 
1358
1383
  return { slug }
1359
1384
  }
@@ -402,6 +402,42 @@ export interface FoundryDispatch {
402
402
  getJob(correlationId: string): Promise<{ state: string } | null>
403
403
  }
404
404
 
405
+ export type MediaSourceKind = 'video' | 'podcast'
406
+
407
+ export interface MediaSourceResolveInput {
408
+ kind: MediaSourceKind
409
+ sourceUrl: string
410
+ /** A client-side measurement may be reused only when the host can prove its source identity. */
411
+ knownDurationSeconds?: number | null
412
+ }
413
+
414
+ export interface MediaSourceResolution {
415
+ /** Echo of the exact source this result measured. Routes reject mismatched/stale results. */
416
+ requestedUrl: string
417
+ /** Stable source URL to persist and later dispatch. */
418
+ resolvedUrl: string
419
+ sourceKind: string
420
+ durationSeconds: number | null
421
+ /** Explicit reasons the source is not dispatch-ready. Empty when duration is authoritative. */
422
+ blockers: string[]
423
+ }
424
+
425
+ export interface MediaSourceProvider {
426
+ resolve(input: MediaSourceResolveInput, ctx?: unknown): Promise<MediaSourceResolution>
427
+ }
428
+
429
+ export class MediaSourceProviderError extends Error {
430
+ readonly status: number
431
+ readonly code: string
432
+
433
+ constructor(status: number, code: string, message: string) {
434
+ super(message)
435
+ this.name = 'MediaSourceProviderError'
436
+ this.status = status
437
+ this.code = code
438
+ }
439
+ }
440
+
405
441
  export interface SeoProvider {
406
442
  scoreDraft(input: SeoScoreInput): Promise<SeoScoreResult>
407
443
  }
@@ -442,4 +478,6 @@ export interface CmsProviders {
442
478
  search: SearchProvider
443
479
  settings: SettingsProvider
444
480
  surveys?: SurveyProvider
481
+ /** Optional host-owned source normalizer and duration probe. */
482
+ mediaSource?: MediaSourceProvider
445
483
  }
@@ -59,6 +59,7 @@ import { softDeleteContent } from '../engine/soft-delete.js'
59
59
  import { normalizeSourceSections } from '../engine/source-sections.js'
60
60
  import { TagInputError } from '../engine/taxonomy.js'
61
61
  import type { ValidationError } from '../engine/validator/index.js'
62
+ import type { MediaSourceResolution } from '../providers/types.js'
62
63
  import { parseLayoutEnvelope } from '../schema/layout.js'
63
64
  import { parsePortableTextEnvelope } from '../schema/portable-text.js'
64
65
  import type { ContentStatus, FrontsPublishStatus } from '../schema/types.js'
@@ -77,6 +78,62 @@ interface PodcastFoundryAttempt {
77
78
  reason: PodcastFoundryReason
78
79
  }
79
80
 
81
+ export interface MediaSourceReadiness {
82
+ requestedUrl: string
83
+ resolvedUrl: string
84
+ sourceKind: string
85
+ durationSeconds: number | null
86
+ ready: boolean
87
+ blockers: string[]
88
+ }
89
+
90
+ interface StoredMediaSource {
91
+ sourceUrl: string | null
92
+ sourceKind: string | null
93
+ durationSeconds: number | null
94
+ }
95
+
96
+ interface MutableMediaSourceContent {
97
+ processingSourceUrl?: string | null
98
+ processingSourceKind?: string | null
99
+ durationSeconds?: number | null
100
+ }
101
+
102
+ function positiveIntegerDuration(value: unknown): number | null {
103
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
104
+ return Math.ceil(value)
105
+ }
106
+
107
+ interface MediaSourceProviderFailure {
108
+ name: 'MediaSourceProviderError'
109
+ message: string
110
+ status: number
111
+ code: string
112
+ }
113
+
114
+ function asMediaSourceProviderFailure(error: unknown): MediaSourceProviderFailure | null {
115
+ if (!error || typeof error !== 'object') return null
116
+ const candidate = error as Partial<MediaSourceProviderFailure>
117
+ if (
118
+ candidate.name !== 'MediaSourceProviderError' ||
119
+ typeof candidate.message !== 'string' ||
120
+ typeof candidate.code !== 'string' ||
121
+ typeof candidate.status !== 'number' ||
122
+ !Number.isInteger(candidate.status) ||
123
+ candidate.status < 400 ||
124
+ candidate.status > 599
125
+ ) {
126
+ return null
127
+ }
128
+ return candidate as MediaSourceProviderFailure
129
+ }
130
+
131
+ function mediaSourceProviderErrorResponse(error: unknown): Response | null {
132
+ const failure = asMediaSourceProviderFailure(error)
133
+ if (!failure) return null
134
+ return json({ error: failure.message, reason: failure.code }, failure.status)
135
+ }
136
+
80
137
  function verifiedMediaDurationConflictResponse(error: unknown): Response | null {
81
138
  if (!(error instanceof VerifiedMediaDurationConflictError)) return null
82
139
  return json(
@@ -771,6 +828,159 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
771
828
  const publishReadiness = config.publishReadiness
772
829
  const activeWorkspaceId = config.activeWorkspaceId ?? 'default'
773
830
 
831
+ async function loadStoredMediaSource(
832
+ db: D1Database,
833
+ contentId: string,
834
+ type: ContentType,
835
+ ): Promise<StoredMediaSource | null> {
836
+ if (type !== 'video' && type !== 'podcast') return null
837
+ const table = type === 'video' ? 'video_content' : 'podcast_content'
838
+ const row = await db
839
+ .prepare(
840
+ `SELECT processing_source_url, processing_source_kind, duration_seconds
841
+ FROM ${table}
842
+ WHERE content_id = ?
843
+ LIMIT 1`,
844
+ )
845
+ .bind(contentId)
846
+ .first<{
847
+ processing_source_url: string | null
848
+ processing_source_kind: string | null
849
+ duration_seconds: number | null
850
+ }>()
851
+ return row
852
+ ? {
853
+ sourceUrl: row.processing_source_url,
854
+ sourceKind: row.processing_source_kind,
855
+ durationSeconds: positiveIntegerDuration(row.duration_seconds),
856
+ }
857
+ : null
858
+ }
859
+
860
+ async function resolveMediaSourceForSave(
861
+ ctx: RouteContext,
862
+ type: ContentType,
863
+ content: MutableMediaSourceContent,
864
+ stored: StoredMediaSource | null,
865
+ ): Promise<MediaSourceReadiness | null> {
866
+ const provider = resolved.providers.mediaSource
867
+ if (!provider || (type !== 'video' && type !== 'podcast')) return null
868
+ if (!Object.hasOwn(content, 'processingSourceUrl')) return null
869
+
870
+ const requestedUrl = content.processingSourceUrl?.trim() ?? ''
871
+ if (!requestedUrl) {
872
+ content.processingSourceUrl = null
873
+ content.processingSourceKind = null
874
+ content.durationSeconds = null
875
+ return null
876
+ }
877
+ const storedDuration = positiveIntegerDuration(stored?.durationSeconds)
878
+ if (stored?.sourceUrl === requestedUrl && storedDuration !== null) {
879
+ content.processingSourceUrl = stored.sourceUrl
880
+ content.processingSourceKind = stored.sourceKind
881
+ content.durationSeconds = storedDuration
882
+ return {
883
+ requestedUrl,
884
+ resolvedUrl: requestedUrl,
885
+ sourceKind: stored.sourceKind ?? content.processingSourceKind ?? 'http_url',
886
+ durationSeconds: storedDuration,
887
+ ready: true,
888
+ blockers: [],
889
+ }
890
+ }
891
+
892
+ let resolution: MediaSourceResolution
893
+ try {
894
+ resolution = await provider.resolve(
895
+ {
896
+ kind: type,
897
+ sourceUrl: requestedUrl,
898
+ knownDurationSeconds:
899
+ stored?.sourceUrl === requestedUrl
900
+ ? positiveIntegerDuration(content.durationSeconds)
901
+ : null,
902
+ },
903
+ ctx,
904
+ )
905
+ } catch (error) {
906
+ if (asMediaSourceProviderFailure(error)) throw error
907
+ content.processingSourceUrl = requestedUrl
908
+ content.durationSeconds = null
909
+ return {
910
+ requestedUrl,
911
+ resolvedUrl: requestedUrl,
912
+ sourceKind: content.processingSourceKind ?? 'http_url',
913
+ durationSeconds: null,
914
+ ready: false,
915
+ blockers: ['duration_probe_failed'],
916
+ }
917
+ }
918
+
919
+ if (resolution.requestedUrl !== requestedUrl) {
920
+ content.processingSourceUrl = requestedUrl
921
+ content.durationSeconds = null
922
+ return {
923
+ requestedUrl,
924
+ resolvedUrl: requestedUrl,
925
+ sourceKind: content.processingSourceKind ?? 'http_url',
926
+ durationSeconds: null,
927
+ ready: false,
928
+ blockers: ['stale_source_measurement'],
929
+ }
930
+ }
931
+
932
+ let resolvedUrl: string
933
+ try {
934
+ resolvedUrl = new URL(resolution.resolvedUrl).toString()
935
+ } catch {
936
+ content.processingSourceUrl = requestedUrl
937
+ content.durationSeconds = null
938
+ return {
939
+ requestedUrl,
940
+ resolvedUrl: requestedUrl,
941
+ sourceKind: content.processingSourceKind ?? 'http_url',
942
+ durationSeconds: null,
943
+ ready: false,
944
+ blockers: ['source_resolution_invalid'],
945
+ }
946
+ }
947
+
948
+ const durationSeconds = positiveIntegerDuration(resolution.durationSeconds)
949
+ const blockers =
950
+ durationSeconds === null
951
+ ? resolution.blockers.filter((blocker) => blocker.trim().length > 0)
952
+ : []
953
+ content.processingSourceUrl = resolvedUrl
954
+ content.processingSourceKind = resolution.sourceKind
955
+ content.durationSeconds = durationSeconds
956
+ return {
957
+ requestedUrl,
958
+ resolvedUrl,
959
+ sourceKind: resolution.sourceKind,
960
+ durationSeconds,
961
+ ready: durationSeconds !== null,
962
+ blockers:
963
+ durationSeconds === null && blockers.length === 0 ? ['duration_probe_failed'] : blockers,
964
+ }
965
+ }
966
+
967
+ function mediaSourceReadinessFromStored(
968
+ stored: StoredMediaSource | null,
969
+ ): MediaSourceReadiness | null {
970
+ if (!stored) return null
971
+ const sourceUrl = stored.sourceUrl?.trim()
972
+ if (!sourceUrl) return null
973
+ const durationSeconds = positiveIntegerDuration(stored.durationSeconds)
974
+ return {
975
+ requestedUrl: sourceUrl,
976
+ resolvedUrl: sourceUrl,
977
+ sourceKind: stored.sourceKind ?? 'http_url',
978
+ durationSeconds,
979
+ ready: durationSeconds !== null,
980
+ blockers: durationSeconds === null ? ['duration_unavailable'] : [],
981
+ }
982
+ }
983
+
774
984
  async function maybeDispatchPodcastToFoundry(
775
985
  ctx: RouteContext,
776
986
  contentId: string,
@@ -1116,6 +1326,22 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1116
1326
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
1117
1327
  }
1118
1328
 
1329
+ let mediaSourceReadiness: MediaSourceReadiness | null = null
1330
+ if (parsed.data.type === 'video' || parsed.data.type === 'podcast') {
1331
+ try {
1332
+ mediaSourceReadiness = await resolveMediaSourceForSave(
1333
+ ctx,
1334
+ parsed.data.type,
1335
+ parsed.data.content,
1336
+ null,
1337
+ )
1338
+ } catch (error) {
1339
+ const providerError = mediaSourceProviderErrorResponse(error)
1340
+ if (providerError) return providerError
1341
+ throw error
1342
+ }
1343
+ }
1344
+
1119
1345
  let created: Awaited<ReturnType<typeof createContent>>
1120
1346
  try {
1121
1347
  created = await createContent(ctx.db, parsed.data)
@@ -1144,6 +1370,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1144
1370
  podcastFoundryQueued: Boolean(podcastAttempt.result),
1145
1371
  podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1146
1372
  podcastFoundryReason: podcastAttempt.reason,
1373
+ ...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
1147
1374
  },
1148
1375
  201,
1149
1376
  )
@@ -1207,6 +1434,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1207
1434
  ...(thumbnailImageId ? { thumbnail_image_url: thumbnailImageUrl } : {}),
1208
1435
  }
1209
1436
  : content
1437
+ const mediaSourceReadiness =
1438
+ item.type === 'video' || item.type === 'podcast'
1439
+ ? mediaSourceReadinessFromStored(await loadStoredMediaSource(ctx.db, id, item.type))
1440
+ : null
1210
1441
 
1211
1442
  const aiLockedFields: string[] = (() => {
1212
1443
  try {
@@ -1222,6 +1453,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1222
1453
  tags,
1223
1454
  relations,
1224
1455
  aiLockedFields,
1456
+ ...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
1225
1457
  })
1226
1458
  },
1227
1459
 
@@ -1254,6 +1486,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1254
1486
  // never told is how a malformed Sources section reached a published page
1255
1487
  // (packages#329).
1256
1488
  let bodyWarnings: ValidationError[] = []
1489
+ let mediaSourceReadiness: MediaSourceReadiness | null = null
1490
+ let expectedMediaSource: StoredMediaSource | undefined
1257
1491
 
1258
1492
  if (parsed.data.content !== undefined) {
1259
1493
  const contentSchema = getContentUpdateSchema(existing.type)
@@ -1265,6 +1499,28 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1265
1499
  }
1266
1500
  parsed.data.content = contentParsed.data
1267
1501
 
1502
+ if (existing.type === 'video' || existing.type === 'podcast') {
1503
+ try {
1504
+ const storedMediaSource = await loadStoredMediaSource(ctx.db, id, existing.type)
1505
+ if (
1506
+ resolved.providers.mediaSource &&
1507
+ Object.hasOwn(contentParsed.data, 'processingSourceUrl')
1508
+ ) {
1509
+ expectedMediaSource = storedMediaSource ?? undefined
1510
+ }
1511
+ mediaSourceReadiness = await resolveMediaSourceForSave(
1512
+ ctx,
1513
+ existing.type,
1514
+ contentParsed.data as MutableMediaSourceContent,
1515
+ storedMediaSource,
1516
+ )
1517
+ } catch (error) {
1518
+ const providerError = mediaSourceProviderErrorResponse(error)
1519
+ if (providerError) return providerError
1520
+ throw error
1521
+ }
1522
+ }
1523
+
1268
1524
  // Pre-publish content lint: refuse to overwrite an article body with
1269
1525
  // content that contains known formatting artifacts. Draft saves run
1270
1526
  // the same check but allow empty bodies (the auto-save path needs to
@@ -1327,6 +1583,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1327
1583
  try {
1328
1584
  updated = await updateContentItem(ctx.db, id, parsed.data, {
1329
1585
  requiredStatus: config.updateStatusConstraint,
1586
+ ...(expectedMediaSource ? { expectedMediaSource } : {}),
1330
1587
  })
1331
1588
  } catch (error) {
1332
1589
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
@@ -1338,6 +1595,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1338
1595
  if (updated.statusConflict) {
1339
1596
  return json({ error: 'update_status_conflict', state: updated.statusConflict }, 409)
1340
1597
  }
1598
+ if (updated.mediaSourceConflict) {
1599
+ return json({ error: 'media_source_conflict' }, 409)
1600
+ }
1341
1601
  const podcastAttempt: PodcastFoundryAttempt =
1342
1602
  existing.type === 'podcast'
1343
1603
  ? await dispatchPodcastAfterSave(ctx, id)
@@ -1356,6 +1616,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1356
1616
  podcastFoundryQueued: Boolean(podcastAttempt.result),
1357
1617
  podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1358
1618
  podcastFoundryReason: podcastAttempt.reason,
1619
+ ...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
1359
1620
  // Present and empty when the body linted clean, so a client can
1360
1621
  // distinguish "no warnings" from "this build does not report them".
1361
1622
  bodyWarnings,
@@ -71,6 +71,7 @@ export {
71
71
  createContentRoutes,
72
72
  type DispatchFaqHook,
73
73
  type DispatchTakeawaysHook,
74
+ type MediaSourceReadiness,
74
75
  type PublishReadinessHook,
75
76
  } from './content.js'
76
77
  // Content Insights surface — read/dismiss/undismiss + the HMAC insights-ingest route.
@@ -29,12 +29,14 @@ import { AiAssistPanel } from './AiAssistPanel.js'
29
29
  import { AUTOSAVE_DEBOUNCE_MS, shouldSnapshot } from './autosave.js'
30
30
  import { type BodyWarning, describeBodyWarnings, sortBodyWarnings } from './body-warnings.js'
31
31
  import {
32
+ applyMediaSourceReadiness,
32
33
  buildContentCreatePayload,
33
34
  buildContentUpdatePayload,
34
35
  type ContentDraftFields,
35
36
  canCreateContentDraft,
36
37
  foundryDispatchSourceForDraft,
37
38
  initialContentMetadata,
39
+ type MediaSourceReadinessPayload,
38
40
  normalizeMediaDurationSeconds,
39
41
  slugifyTitle,
40
42
  updateContentMetadataSelection,
@@ -431,16 +433,29 @@ export function ContentForm({
431
433
  }))
432
434
  return
433
435
  }
434
- const created = (await res.json()) as { id: string }
436
+ const created = (await res.json()) as {
437
+ id: string
438
+ mediaSourceReadiness?: MediaSourceReadinessPayload
439
+ }
440
+ const latest = latestForm.current
441
+ const resolvedDraft = created.mediaSourceReadiness
442
+ ? applyMediaSourceReadiness(contentType, latest, created.mediaSourceReadiness)
443
+ : latest
444
+ const sourceResultIsCurrent = !created.mediaSourceReadiness || resolvedDraft !== latest
445
+ const dispatchDraft = created.mediaSourceReadiness ? resolvedDraft : draft
435
446
  setForm((prev) => ({
436
- ...prev,
447
+ ...(created.mediaSourceReadiness
448
+ ? applyMediaSourceReadiness(contentType, prev, created.mediaSourceReadiness)
449
+ : prev),
437
450
  saving: false,
438
451
  savedId: created.id,
439
452
  lastSavedAt: Date.now(),
440
453
  saveError: null,
441
454
  }))
442
455
  onDocCreated?.(created.id)
443
- void queueFoundryProcessing(created.id, draft)
456
+ if (sourceResultIsCurrent) {
457
+ void queueFoundryProcessing(created.id, dispatchDraft)
458
+ }
444
459
  } catch (err) {
445
460
  setForm((prev) => ({
446
461
  ...prev,
@@ -474,9 +489,18 @@ export function ContentForm({
474
489
  // left them invisible.
475
490
  const saved = (await res.json().catch(() => ({}))) as {
476
491
  bodyWarnings?: BodyWarning[]
492
+ mediaSourceReadiness?: MediaSourceReadinessPayload
477
493
  }
494
+ const latest = latestForm.current
495
+ const resolvedDraft = saved.mediaSourceReadiness
496
+ ? applyMediaSourceReadiness(contentType, latest, saved.mediaSourceReadiness)
497
+ : latest
498
+ const sourceResultIsCurrent = !saved.mediaSourceReadiness || resolvedDraft !== latest
499
+ const dispatchDraft = saved.mediaSourceReadiness ? resolvedDraft : draft
478
500
  setForm((prev) => ({
479
- ...prev,
501
+ ...(saved.mediaSourceReadiness
502
+ ? applyMediaSourceReadiness(contentType, prev, saved.mediaSourceReadiness)
503
+ : prev),
480
504
  bodyWarnings: Array.isArray(saved.bodyWarnings) ? saved.bodyWarnings : [],
481
505
  }))
482
506
 
@@ -501,7 +525,9 @@ export function ContentForm({
501
525
  lastSavedAt: now,
502
526
  saveError: null,
503
527
  }))
504
- void queueFoundryProcessing(id, draft)
528
+ if (sourceResultIsCurrent) {
529
+ void queueFoundryProcessing(id, dispatchDraft)
530
+ }
505
531
  } catch (err) {
506
532
  setForm((prev) => ({
507
533
  ...prev,
@@ -656,6 +682,7 @@ export function ContentForm({
656
682
  ...latestForm.current,
657
683
  podcastSourceUrl: value,
658
684
  podcastSourceKind: value.trim() ? 'https' : null,
685
+ durationSeconds: null,
659
686
  }
660
687
  setForm(next)
661
688
  publishDraft(next)