@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.
- package/README.md +31 -0
- package/dist/engine/publisher.d.ts +7 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +29 -15
- package/dist/engine/publisher.js.map +1 -1
- package/dist/providers/types.d.ts +27 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +10 -0
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +196 -0
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/index.d.ts +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +26 -5
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +13 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +40 -7
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/package.json +54 -13
- package/src/engine/publisher.ts +60 -35
- package/src/providers/types.ts +38 -0
- package/src/routes/content.ts +261 -0
- package/src/routes/index.ts +1 -0
- package/src/ui/editor/ContentForm.tsx +32 -5
- package/src/ui/editor/content-payload.ts +53 -9
package/src/engine/publisher.ts
CHANGED
|
@@ -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
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1181
|
+
if (input.tags !== undefined) {
|
|
1182
|
+
await setContentTagPairs(db, id, tagPairs ?? [])
|
|
1183
|
+
}
|
|
1176
1184
|
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/providers/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/routes/content.ts
CHANGED
|
@@ -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,
|
package/src/routes/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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)
|