@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.
- package/dist/engine/ai-dek-notifications.d.ts +2 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
- package/dist/engine/ai-dek-notifications.js +11 -2
- package/dist/engine/ai-dek-notifications.js.map +1 -1
- package/dist/engine/ai-dek.d.ts +23 -0
- package/dist/engine/ai-dek.d.ts.map +1 -1
- package/dist/engine/ai-dek.js +94 -31
- package/dist/engine/ai-dek.js.map +1 -1
- package/dist/engine/foundry-dispatch.d.ts +13 -3
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +115 -39
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +55 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +187 -57
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +11 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +135 -10
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +3 -2
- package/dist/engine/podcast-source.d.ts.map +1 -1
- package/dist/engine/podcast-source.js +6 -4
- package/dist/engine/podcast-source.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +7 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +66 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publication.d.ts +30 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +306 -34
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +32 -19
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.d.ts +3 -1
- package/dist/engine/soft-delete.d.ts.map +1 -1
- package/dist/engine/soft-delete.js +21 -5
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/engine/video-source.d.ts +4 -2
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +7 -4
- package/dist/engine/video-source.js.map +1 -1
- package/dist/routes/content.d.ts +14 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +56 -28
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +6 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +178 -2
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +42 -42
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +8 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/schema.d.ts +48 -48
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +7 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts +2 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +2 -2
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/migrations/0030_media_publication_guard.sql +5 -0
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +19 -2
- package/src/engine/ai-dek.ts +131 -40
- package/src/engine/foundry-dispatch.ts +189 -49
- package/src/engine/fronts-publish-intent.ts +295 -67
- package/src/engine/fronts-publish.ts +167 -11
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +6 -4
- package/src/engine/publication-current-guard.ts +86 -0
- package/src/engine/publication.ts +411 -59
- package/src/engine/publisher.ts +37 -17
- package/src/engine/soft-delete.ts +28 -8
- package/src/engine/video-source.ts +7 -4
- package/src/routes/content.ts +78 -35
- package/src/routes/context.ts +6 -0
- package/src/routes/fronts-publish.ts +213 -2
- package/src/schema/migrations.ts +8 -0
- package/src/schema/types.ts +6 -0
- package/src/ui/api/_content-config.ts +15 -0
- package/src/ui/api/fronts/schedule.ts +4 -0
- package/src/ui/editor/ContentForm.tsx +4 -5
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// on `processing_trigger_token` and deduplicated on `event_id`.
|
|
11
11
|
|
|
12
12
|
import type { FoundryDispatch } from '../providers/types.js'
|
|
13
|
-
import type { D1Database } from './d1.js'
|
|
13
|
+
import type { D1Database, D1PreparedStatement, D1Result } from './d1.js'
|
|
14
14
|
import { validPodcastSourceUrl } from './podcast-source.js'
|
|
15
|
+
import { mediaPublicationCaptureGuard } from './publication-current-guard.js'
|
|
15
16
|
|
|
16
17
|
interface PodcastDispatchSnapshot {
|
|
17
18
|
content_id: string
|
|
@@ -35,8 +36,10 @@ export type ProcessingState =
|
|
|
35
36
|
const TERMINAL_STATES: ProcessingState[] = ['ready', 'failed']
|
|
36
37
|
|
|
37
38
|
export interface DispatchResult {
|
|
38
|
-
|
|
39
|
+
/** Podcast capture reuse has no persisted correlation to return. */
|
|
40
|
+
correlationId: string | null
|
|
39
41
|
triggerToken: string
|
|
42
|
+
skipped?: boolean
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export class FoundryDispatchPrerequisiteError extends Error {
|
|
@@ -99,9 +102,23 @@ function inferVideoSourceKind(sourceUrl: string): string {
|
|
|
99
102
|
return 'http_url'
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Mint a fresh source identity for the caller's source-snapshot CAS. Capture
|
|
107
|
+
* time records initiation, not worker admission; this helper does not dispatch
|
|
108
|
+
* work or change readiness. The raw token stays in the private capture record.
|
|
109
|
+
*/
|
|
110
|
+
export function createFoundrySourceCaptureToken(
|
|
111
|
+
capturedAtSeconds = Math.floor(Date.now() / 1000),
|
|
112
|
+
): string {
|
|
113
|
+
if (!Number.isSafeInteger(capturedAtSeconds) || capturedAtSeconds <= 0) {
|
|
114
|
+
throw new RangeError('Capture time must be positive safe integer Unix seconds')
|
|
115
|
+
}
|
|
116
|
+
return `fronts-source-v2:${capturedAtSeconds}:${crypto.randomUUID()}`
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
/**
|
|
103
120
|
* Dispatch a video or podcast content item to Foundry via the injected hook.
|
|
104
|
-
* Mints a fresh `processing_trigger_token` (UUID), calls
|
|
121
|
+
* Mints a fresh versioned `processing_trigger_token` (capture time + UUID), calls
|
|
105
122
|
* `hook.dispatchVideo({ contentId, sourceUrl, kind, durationSeconds })`, and writes
|
|
106
123
|
* `processing_correlation_id` + advances to `'queued'` in either
|
|
107
124
|
* `video_content` or `podcast_content`.
|
|
@@ -112,10 +129,13 @@ export async function dispatchToFoundry(
|
|
|
112
129
|
db: D1Database,
|
|
113
130
|
contentId: string,
|
|
114
131
|
hook: Pick<FoundryDispatch, 'dispatchVideo'>,
|
|
132
|
+
options: { ifUncaptured?: boolean } = {},
|
|
115
133
|
): Promise<DispatchResult> {
|
|
116
134
|
const content = await db
|
|
117
135
|
.prepare(
|
|
118
|
-
|
|
136
|
+
`SELECT id, type, slug, title, description, canonical_version, status, deleted_at,
|
|
137
|
+
visibility, published_revision_id, published_at, publish_at, publish_tz,
|
|
138
|
+
fronts_publish_status, fronts_publish_trigger_token FROM content_items WHERE id = ? LIMIT 1`,
|
|
119
139
|
)
|
|
120
140
|
.bind(contentId)
|
|
121
141
|
.first<{
|
|
@@ -127,6 +147,13 @@ export async function dispatchToFoundry(
|
|
|
127
147
|
canonical_version: number
|
|
128
148
|
status: string
|
|
129
149
|
deleted_at: number | null
|
|
150
|
+
visibility: string
|
|
151
|
+
published_revision_id: string | null
|
|
152
|
+
published_at: number | null
|
|
153
|
+
publish_at: number | null
|
|
154
|
+
publish_tz: string | null
|
|
155
|
+
fronts_publish_status: string | null
|
|
156
|
+
fronts_publish_trigger_token: string | null
|
|
130
157
|
}>()
|
|
131
158
|
if (!content) {
|
|
132
159
|
throw new Error(`dispatchToFoundry: content ${contentId} not found`)
|
|
@@ -135,7 +162,7 @@ export async function dispatchToFoundry(
|
|
|
135
162
|
// Determine which media table owns this content id.
|
|
136
163
|
const videoRow = await db
|
|
137
164
|
.prepare(
|
|
138
|
-
'SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind FROM video_content WHERE content_id = ?',
|
|
165
|
+
'SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token, processing_correlation_id FROM video_content WHERE content_id = ?',
|
|
139
166
|
)
|
|
140
167
|
.bind(contentId)
|
|
141
168
|
.first<{
|
|
@@ -144,6 +171,8 @@ export async function dispatchToFoundry(
|
|
|
144
171
|
duration_seconds: number | null
|
|
145
172
|
processing_source_url: string | null
|
|
146
173
|
processing_source_kind: string | null
|
|
174
|
+
processing_trigger_token: string | null
|
|
175
|
+
processing_correlation_id: string | null
|
|
147
176
|
}>()
|
|
148
177
|
|
|
149
178
|
let table: 'video_content' | 'podcast_content'
|
|
@@ -155,6 +184,20 @@ export async function dispatchToFoundry(
|
|
|
155
184
|
let podcastSnapshot: PodcastDispatchSnapshot | null = null
|
|
156
185
|
|
|
157
186
|
if (videoRow) {
|
|
187
|
+
if (content.type !== 'video' || content.deleted_at != null || content.status === 'archived') {
|
|
188
|
+
throw new Error(`dispatchToFoundry: video ${contentId} is inactive`)
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
options.ifUncaptured &&
|
|
192
|
+
videoRow.processing_trigger_token &&
|
|
193
|
+
videoRow.processing_correlation_id
|
|
194
|
+
) {
|
|
195
|
+
return {
|
|
196
|
+
correlationId: videoRow.processing_correlation_id,
|
|
197
|
+
triggerToken: videoRow.processing_trigger_token,
|
|
198
|
+
skipped: true,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
158
201
|
table = 'video_content'
|
|
159
202
|
sourceUrl = videoRow.processing_source_url?.trim() ?? ''
|
|
160
203
|
if (!sourceUrl) {
|
|
@@ -182,12 +225,21 @@ export async function dispatchToFoundry(
|
|
|
182
225
|
throw new Error(`dispatchToFoundry: podcast ${contentId} is inactive`)
|
|
183
226
|
}
|
|
184
227
|
podcastSnapshot = podcastRow
|
|
185
|
-
|
|
228
|
+
const selectedSource = podcastRow.processing_source_url?.trim()
|
|
229
|
+
if (
|
|
230
|
+
selectedSource &&
|
|
231
|
+
(podcastRow.processing_source_kind !== 'https' || !validPodcastSourceUrl(selectedSource))
|
|
232
|
+
) {
|
|
233
|
+
throw new Error(`dispatchToFoundry: content ${contentId} has an invalid podcast source URL`)
|
|
234
|
+
}
|
|
235
|
+
sourceUrl = selectedSource || podcastRow.audio_r2_key?.trim() || ''
|
|
186
236
|
if (!sourceUrl) {
|
|
187
|
-
throw new Error(
|
|
237
|
+
throw new Error(
|
|
238
|
+
`dispatchToFoundry: content ${contentId} has no podcast source or audio R2 key`,
|
|
239
|
+
)
|
|
188
240
|
}
|
|
189
241
|
kind = 'podcast'
|
|
190
|
-
sourceKind = 'r2_key'
|
|
242
|
+
sourceKind = selectedSource ? 'https' : 'r2_key'
|
|
191
243
|
videoId = normalizeVideoId(content.slug, content.slug)
|
|
192
244
|
durationSeconds = podcastRow.duration_seconds
|
|
193
245
|
}
|
|
@@ -199,9 +251,24 @@ export async function dispatchToFoundry(
|
|
|
199
251
|
) {
|
|
200
252
|
throw new FoundryDispatchPrerequisiteError(contentId)
|
|
201
253
|
}
|
|
254
|
+
if (
|
|
255
|
+
options.ifUncaptured &&
|
|
256
|
+
podcastSnapshot?.processing_trigger_token?.trim() &&
|
|
257
|
+
podcastSnapshot.processing_source_kind === 'https' &&
|
|
258
|
+
validPodcastSourceUrl(podcastSnapshot.processing_source_url)
|
|
259
|
+
) {
|
|
260
|
+
// Source edits clear the token. This source/token pair is the CMS's
|
|
261
|
+
// committed capture evidence; a metadata save need not call the host again.
|
|
262
|
+
return {
|
|
263
|
+
correlationId: null,
|
|
264
|
+
triggerToken: podcastSnapshot.processing_trigger_token,
|
|
265
|
+
skipped: true,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
202
268
|
|
|
203
|
-
const triggerToken =
|
|
269
|
+
const triggerToken = createFoundrySourceCaptureToken()
|
|
204
270
|
const processingTriggerTokenHash = `sha256:${await sha256Hex(triggerToken)}`
|
|
271
|
+
const publicationGuard = mediaPublicationCaptureGuard(content, processingTriggerTokenHash)
|
|
205
272
|
const { correlationId, podcastSource } = await hook.dispatchVideo({
|
|
206
273
|
contentId,
|
|
207
274
|
sourceUrl,
|
|
@@ -217,7 +284,8 @@ export async function dispatchToFoundry(
|
|
|
217
284
|
const now = Math.floor(Date.now() / 1000)
|
|
218
285
|
|
|
219
286
|
if (table === 'video_content') {
|
|
220
|
-
|
|
287
|
+
if (!videoRow) throw new Error('Video dispatch snapshot is missing')
|
|
288
|
+
const written = await db
|
|
221
289
|
.prepare(
|
|
222
290
|
`UPDATE video_content
|
|
223
291
|
SET processing_state = 'queued',
|
|
@@ -225,6 +293,7 @@ export async function dispatchToFoundry(
|
|
|
225
293
|
processing_source_kind = ?,
|
|
226
294
|
processing_correlation_id = ?,
|
|
227
295
|
processing_trigger_token = ?,
|
|
296
|
+
processing_publication_guard = ?,
|
|
228
297
|
processing_started_at = ?,
|
|
229
298
|
processing_completed_at = NULL,
|
|
230
299
|
processing_last_event_at = NULL,
|
|
@@ -235,10 +304,44 @@ export async function dispatchToFoundry(
|
|
|
235
304
|
transcript_url = NULL,
|
|
236
305
|
hls_manifest_url = NULL,
|
|
237
306
|
hls_poster_url = NULL
|
|
238
|
-
WHERE content_id =
|
|
307
|
+
WHERE content_id = ? AND video_id IS ? AND duration_seconds IS ?
|
|
308
|
+
AND processing_source_url IS ? AND processing_source_kind IS ?
|
|
309
|
+
AND processing_trigger_token IS ? AND processing_correlation_id IS ?
|
|
310
|
+
AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = video_content.content_id
|
|
311
|
+
AND ci.type = 'video' AND ci.slug = ? AND ci.canonical_version = ?
|
|
312
|
+
AND ci.status = ? AND ci.deleted_at IS NULL
|
|
313
|
+
AND ci.visibility IS ? AND ci.published_revision_id IS ? AND ci.published_at IS ?
|
|
314
|
+
AND ci.publish_at IS ? AND ci.publish_tz IS ?
|
|
315
|
+
AND ci.fronts_publish_status IS ? AND ci.fronts_publish_trigger_token IS ?)`,
|
|
316
|
+
)
|
|
317
|
+
.bind(
|
|
318
|
+
videoId,
|
|
319
|
+
sourceKind,
|
|
320
|
+
correlationId,
|
|
321
|
+
triggerToken,
|
|
322
|
+
publicationGuard,
|
|
323
|
+
now,
|
|
324
|
+
contentId,
|
|
325
|
+
videoRow.video_id,
|
|
326
|
+
videoRow.duration_seconds,
|
|
327
|
+
videoRow.processing_source_url,
|
|
328
|
+
videoRow.processing_source_kind,
|
|
329
|
+
videoRow.processing_trigger_token,
|
|
330
|
+
videoRow.processing_correlation_id,
|
|
331
|
+
content.slug,
|
|
332
|
+
content.canonical_version,
|
|
333
|
+
content.status,
|
|
334
|
+
content.visibility,
|
|
335
|
+
content.published_revision_id,
|
|
336
|
+
content.published_at,
|
|
337
|
+
content.publish_at,
|
|
338
|
+
content.publish_tz,
|
|
339
|
+
content.fronts_publish_status,
|
|
340
|
+
content.fronts_publish_trigger_token,
|
|
239
341
|
)
|
|
240
|
-
.bind(videoId, sourceKind, correlationId, triggerToken, now, contentId)
|
|
241
342
|
.run()
|
|
343
|
+
if (written.meta?.changes !== 1)
|
|
344
|
+
throw new Error('Video source changed before dispatch acknowledgement')
|
|
242
345
|
} else {
|
|
243
346
|
if (!podcastSnapshot) throw new Error('Podcast dispatch snapshot is missing')
|
|
244
347
|
if (
|
|
@@ -259,16 +362,20 @@ export async function dispatchToFoundry(
|
|
|
259
362
|
// source/audio/editor snapshot is still current. No stale acknowledgement
|
|
260
363
|
// can restore a replaced source or overwrite a newer processing token.
|
|
261
364
|
const written = await db
|
|
262
|
-
.prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token =
|
|
365
|
+
.prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?, processing_publication_guard = ?, processing_state = 'queued', transcript = ''
|
|
263
366
|
WHERE content_id = ? AND audio_r2_key IS ? AND duration_seconds IS ? AND transcript IS ?
|
|
264
367
|
AND processing_source_url IS ? AND processing_source_kind IS ? AND processing_trigger_token IS ?
|
|
265
368
|
AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = podcast_content.content_id
|
|
266
369
|
AND ci.type = 'podcast' AND ci.slug = ? AND ci.canonical_version = ?
|
|
267
|
-
AND ci.status = ? AND ci.deleted_at IS NULL
|
|
370
|
+
AND ci.status = ? AND ci.deleted_at IS NULL
|
|
371
|
+
AND ci.visibility IS ? AND ci.published_revision_id IS ? AND ci.published_at IS ?
|
|
372
|
+
AND ci.publish_at IS ? AND ci.publish_tz IS ?
|
|
373
|
+
AND ci.fronts_publish_status IS ? AND ci.fronts_publish_trigger_token IS ?)`)
|
|
268
374
|
.bind(
|
|
269
375
|
capturedUrl,
|
|
270
376
|
capturedKind,
|
|
271
377
|
triggerToken,
|
|
378
|
+
publicationGuard,
|
|
272
379
|
contentId,
|
|
273
380
|
podcastSnapshot.audio_r2_key,
|
|
274
381
|
podcastSnapshot.duration_seconds,
|
|
@@ -279,6 +386,13 @@ export async function dispatchToFoundry(
|
|
|
279
386
|
content.slug,
|
|
280
387
|
content.canonical_version,
|
|
281
388
|
content.status,
|
|
389
|
+
content.visibility,
|
|
390
|
+
content.published_revision_id,
|
|
391
|
+
content.published_at,
|
|
392
|
+
content.publish_at,
|
|
393
|
+
content.publish_tz,
|
|
394
|
+
content.fronts_publish_status,
|
|
395
|
+
content.fronts_publish_trigger_token,
|
|
282
396
|
)
|
|
283
397
|
.run()
|
|
284
398
|
if (written.meta?.changes !== 1)
|
|
@@ -352,14 +466,19 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
|
|
|
352
466
|
return false // no matching job for this trigger token
|
|
353
467
|
}
|
|
354
468
|
|
|
355
|
-
//
|
|
469
|
+
// A failed event is consumed atomically with terminal state and guard revocation.
|
|
470
|
+
// Otherwise a transient media write failure would make its retry a no-op.
|
|
356
471
|
const now = Math.floor(Date.now() / 1000)
|
|
357
|
-
|
|
472
|
+
const eventStatement = db
|
|
358
473
|
.prepare(
|
|
359
474
|
'INSERT INTO foundry_callback_events (event_id, event_kind, correlation_id, received_at) VALUES (?, ?, ?, ?)',
|
|
360
475
|
)
|
|
361
476
|
.bind(eventId, eventKind, correlationId, now)
|
|
362
|
-
|
|
477
|
+
if (nextState !== 'failed') await eventStatement.run()
|
|
478
|
+
const applyMediaUpdate = async (statement: D1PreparedStatement): Promise<D1Result> =>
|
|
479
|
+
nextState === 'failed'
|
|
480
|
+
? (await db.batch([statement, eventStatement]))[0]!
|
|
481
|
+
: await statement.run()
|
|
363
482
|
|
|
364
483
|
// Advance the state machine.
|
|
365
484
|
const isTerminal = (TERMINAL_STATES as string[]).includes(nextState)
|
|
@@ -371,35 +490,44 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
|
|
|
371
490
|
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds)
|
|
372
491
|
? Math.max(0, Math.floor(durationSeconds))
|
|
373
492
|
: null
|
|
374
|
-
|
|
375
|
-
await
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
493
|
+
{
|
|
494
|
+
const updated = await applyMediaUpdate(
|
|
495
|
+
db
|
|
496
|
+
.prepare(
|
|
497
|
+
`UPDATE podcast_content
|
|
498
|
+
SET processing_state = ?, processing_publication_guard = CASE WHEN ? = 'failed' THEN NULL ELSE processing_publication_guard END,
|
|
499
|
+
transcript = CASE WHEN ? THEN ? ELSE transcript END,
|
|
379
500
|
duration_seconds = CASE WHEN ? THEN ? ELSE duration_seconds END
|
|
380
|
-
WHERE content_id = ?`,
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
501
|
+
WHERE content_id = ? AND processing_trigger_token = ?`,
|
|
502
|
+
)
|
|
503
|
+
.bind(
|
|
504
|
+
nextState,
|
|
505
|
+
nextState,
|
|
506
|
+
nextTranscript !== null ? 1 : 0,
|
|
507
|
+
nextTranscript,
|
|
508
|
+
nextDurationSeconds !== null ? 1 : 0,
|
|
509
|
+
nextDurationSeconds,
|
|
510
|
+
podcastRow.content_id,
|
|
511
|
+
triggerToken,
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
if (updated.meta?.changes !== 1) return false
|
|
390
515
|
}
|
|
391
516
|
|
|
392
517
|
const nextDescription =
|
|
393
518
|
typeof description === 'string' && description.trim() ? description.trim() : null
|
|
394
519
|
const nextExcerpt = typeof excerpt === 'string' && excerpt.trim() ? excerpt.trim() : null
|
|
395
520
|
if (nextDescription !== null || nextExcerpt !== null) {
|
|
396
|
-
await db
|
|
521
|
+
const updated = await db
|
|
397
522
|
.prepare(
|
|
398
523
|
`UPDATE content_items
|
|
399
524
|
SET description = CASE WHEN ? THEN ? ELSE description END,
|
|
400
525
|
excerpt = CASE WHEN ? THEN ? ELSE excerpt END,
|
|
401
526
|
updated_at = unixepoch()
|
|
402
|
-
WHERE id =
|
|
527
|
+
WHERE id = ? AND EXISTS (
|
|
528
|
+
SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
|
|
529
|
+
AND p.processing_trigger_token = ?
|
|
530
|
+
)`,
|
|
403
531
|
)
|
|
404
532
|
.bind(
|
|
405
533
|
nextDescription !== null ? 1 : 0,
|
|
@@ -407,50 +535,62 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
|
|
|
407
535
|
nextExcerpt !== null ? 1 : 0,
|
|
408
536
|
nextExcerpt,
|
|
409
537
|
podcastRow.content_id,
|
|
538
|
+
triggerToken,
|
|
410
539
|
)
|
|
411
540
|
.run()
|
|
541
|
+
if (updated.meta?.changes !== 1) return false
|
|
412
542
|
}
|
|
413
543
|
|
|
414
|
-
return
|
|
544
|
+
return Boolean(
|
|
545
|
+
await db
|
|
546
|
+
.prepare(
|
|
547
|
+
'SELECT content_id FROM podcast_content WHERE content_id = ? AND processing_trigger_token = ?',
|
|
548
|
+
)
|
|
549
|
+
.bind(podcastRow.content_id, triggerToken)
|
|
550
|
+
.first(),
|
|
551
|
+
)
|
|
415
552
|
}
|
|
416
553
|
|
|
417
554
|
if (!videoRow) return false
|
|
418
555
|
const row = videoRow
|
|
556
|
+
let updated: D1Result
|
|
419
557
|
|
|
420
558
|
if (nextState === 'failed') {
|
|
421
|
-
await
|
|
422
|
-
|
|
423
|
-
|
|
559
|
+
updated = await applyMediaUpdate(
|
|
560
|
+
db
|
|
561
|
+
.prepare(
|
|
562
|
+
`UPDATE video_content
|
|
424
563
|
SET processing_state = ?,
|
|
564
|
+
processing_publication_guard = NULL,
|
|
425
565
|
processing_last_event_at = ?,
|
|
426
566
|
processing_completed_at = ?,
|
|
427
567
|
processing_error = ?
|
|
428
|
-
WHERE content_id = ?`,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
568
|
+
WHERE content_id = ? AND processing_trigger_token = ?`,
|
|
569
|
+
)
|
|
570
|
+
.bind(nextState, now, now, error ?? null, row.content_id, triggerToken),
|
|
571
|
+
)
|
|
432
572
|
} else if (isTerminal) {
|
|
433
573
|
// 'ready'
|
|
434
|
-
await db
|
|
574
|
+
updated = await db
|
|
435
575
|
.prepare(
|
|
436
576
|
`UPDATE video_content
|
|
437
577
|
SET processing_state = ?,
|
|
438
578
|
processing_last_event_at = ?,
|
|
439
579
|
processing_completed_at = ?
|
|
440
|
-
WHERE content_id = ?`,
|
|
580
|
+
WHERE content_id = ? AND processing_trigger_token = ?`,
|
|
441
581
|
)
|
|
442
|
-
.bind(nextState, now, now, row.content_id)
|
|
582
|
+
.bind(nextState, now, now, row.content_id, triggerToken)
|
|
443
583
|
.run()
|
|
444
584
|
} else {
|
|
445
|
-
await db
|
|
585
|
+
updated = await db
|
|
446
586
|
.prepare(
|
|
447
|
-
'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ?',
|
|
587
|
+
'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ? AND processing_trigger_token = ?',
|
|
448
588
|
)
|
|
449
|
-
.bind(nextState, now, row.content_id)
|
|
589
|
+
.bind(nextState, now, row.content_id, triggerToken)
|
|
450
590
|
.run()
|
|
451
591
|
}
|
|
452
592
|
|
|
453
|
-
return
|
|
593
|
+
return updated.meta?.changes === 1
|
|
454
594
|
}
|
|
455
595
|
|
|
456
596
|
/**
|