@growth-labs/cms 0.8.14 → 0.8.15
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/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +13 -4
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +20 -3
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +25 -11
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/google-drive-source.d.ts +27 -0
- package/dist/engine/google-drive-source.d.ts.map +1 -0
- package/dist/engine/google-drive-source.js +87 -0
- package/dist/engine/google-drive-source.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/migration-vendor.d.ts.map +1 -1
- package/dist/migration-vendor.js +11 -0
- package/dist/migration-vendor.js.map +1 -1
- package/dist/providers/types.d.ts +6 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +59 -7
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +1 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +13 -4
- package/src/engine/fronts-publish-intent.ts +50 -15
- package/src/engine/google-drive-source.ts +99 -0
- package/src/engine/index.ts +1 -0
- package/src/migration-vendor.ts +10 -0
- package/src/providers/types.ts +6 -1
- package/src/routes/content.ts +64 -6
- package/src/ui/editor/ContentForm.tsx +4 -0
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { FoundryDispatch } from '../providers/types.js'
|
|
13
13
|
import type { D1Database, D1PreparedStatement, D1Result } from './d1.js'
|
|
14
|
+
import { classifyGoogleDriveUrl } from './google-drive-source.js'
|
|
14
15
|
import { validPodcastSourceUrl } from './podcast-source.js'
|
|
15
16
|
import { mediaPublicationCaptureGuard } from './publication-current-guard.js'
|
|
16
17
|
|
|
@@ -181,6 +182,7 @@ export async function dispatchToFoundry(
|
|
|
181
182
|
let sourceKind: string | null = null
|
|
182
183
|
let videoId: string | null = null
|
|
183
184
|
let durationSeconds: number | null
|
|
185
|
+
let googleDriveSource = false
|
|
184
186
|
let podcastSnapshot: PodcastDispatchSnapshot | null = null
|
|
185
187
|
|
|
186
188
|
if (videoRow) {
|
|
@@ -239,15 +241,22 @@ export async function dispatchToFoundry(
|
|
|
239
241
|
)
|
|
240
242
|
}
|
|
241
243
|
kind = 'podcast'
|
|
242
|
-
|
|
244
|
+
// A Google Drive source is fetched and measured by the Go plane: there is
|
|
245
|
+
// no editor-typed duration to require, and the hook is told the kind so it
|
|
246
|
+
// does not mistake the viewer URL for media. The stored kind stays 'https'
|
|
247
|
+
// (the column's only permitted value); Drive-ness is derived from the URL.
|
|
248
|
+
googleDriveSource =
|
|
249
|
+
Boolean(selectedSource) && classifyGoogleDriveUrl(selectedSource ?? '').kind === 'drive'
|
|
250
|
+
sourceKind = googleDriveSource ? 'google_drive' : selectedSource ? 'https' : 'r2_key'
|
|
243
251
|
videoId = normalizeVideoId(content.slug, content.slug)
|
|
244
252
|
durationSeconds = podcastRow.duration_seconds
|
|
245
253
|
}
|
|
246
254
|
|
|
247
255
|
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
!googleDriveSource &&
|
|
257
|
+
(typeof durationSeconds !== 'number' ||
|
|
258
|
+
!Number.isFinite(durationSeconds) ||
|
|
259
|
+
durationSeconds <= 0)
|
|
251
260
|
) {
|
|
252
261
|
throw new FoundryDispatchPrerequisiteError(contentId)
|
|
253
262
|
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
// other. Real audio HLS is a separate media-pipeline change.
|
|
41
41
|
import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
|
|
42
42
|
import type { D1Database } from './d1.js'
|
|
43
|
+
import { classifyGoogleDriveUrl } from './google-drive-source.js'
|
|
43
44
|
import {
|
|
44
45
|
frontsPodcastSourceGeneration,
|
|
45
46
|
type PodcastPublishBlockerCode,
|
|
@@ -130,12 +131,17 @@ export interface FrontsPublishPodcastAudioRef {
|
|
|
130
131
|
* `podcast_audio_hls`. Populated from `podcast_content.processing_source_url`
|
|
131
132
|
* / `processing_source_kind` (migration 0028) -- the same "captured at
|
|
132
133
|
* dispatch time" pattern `video_content.processing_source_url` already uses.
|
|
133
|
-
* `kind` is
|
|
134
|
-
*
|
|
134
|
+
* `kind` is `'https'` (a direct URL, duration verified by the CMS) or
|
|
135
|
+
* `'google_drive'` (a Drive file the Go plane fetches and measures itself; see
|
|
136
|
+
* `FrontsPublishGoogleDrivePodcastSourceRef`). Present only while the
|
|
135
137
|
* podcast is incomplete. An existing audio object with a missing transcript
|
|
136
138
|
* still needs production; it is not a publishable audio reference yet.
|
|
137
139
|
*/
|
|
138
|
-
export
|
|
140
|
+
export type FrontsPublishPodcastSourceRef =
|
|
141
|
+
| FrontsPublishHttpsPodcastSourceRef
|
|
142
|
+
| FrontsPublishGoogleDrivePodcastSourceRef
|
|
143
|
+
|
|
144
|
+
export interface FrontsPublishHttpsPodcastSourceRef {
|
|
139
145
|
kind: 'https'
|
|
140
146
|
url: string
|
|
141
147
|
/** Immutable media identity; rescheduling alone does not change it. */
|
|
@@ -144,6 +150,22 @@ export interface FrontsPublishPodcastSourceRef {
|
|
|
144
150
|
duration_seconds: number
|
|
145
151
|
}
|
|
146
152
|
|
|
153
|
+
/**
|
|
154
|
+
* A Google Drive file the editor pasted a share link for. The Go plane fetches
|
|
155
|
+
* it with the estate's Google identity and measures the duration itself, so
|
|
156
|
+
* this shape carries NO `url` and NO `duration_seconds` (Go's schema rejects
|
|
157
|
+
* either). `file_id` matches `^[A-Za-z0-9_-]{10,200}$`; `resource_key`, when the
|
|
158
|
+
* link carried one, matches `^[A-Za-z0-9_-]{1,200}$`. Derived from the stored
|
|
159
|
+
* source URL by `classifyGoogleDriveUrl`; nothing about it is stored.
|
|
160
|
+
*/
|
|
161
|
+
export interface FrontsPublishGoogleDrivePodcastSourceRef {
|
|
162
|
+
kind: 'google_drive'
|
|
163
|
+
file_id: string
|
|
164
|
+
resource_key?: string
|
|
165
|
+
/** Immutable media identity; rescheduling alone does not change it. */
|
|
166
|
+
generation: string
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
/**
|
|
148
170
|
* The video twin of `FrontsPublishPodcastSourceRef` (W1-02): the captured
|
|
149
171
|
* source of a scheduled video whose HLS is not ready, for the Go
|
|
@@ -783,10 +805,14 @@ async function podcastSourceRef(
|
|
|
783
805
|
found.processing_source_kind !== 'https'
|
|
784
806
|
)
|
|
785
807
|
return null
|
|
808
|
+
// Duration is measured by the Go plane for a Drive source; only a plain
|
|
809
|
+
// https source needs the editor-verified duration up front.
|
|
810
|
+
const drive = classifyGoogleDriveUrl(found.processing_source_url)
|
|
786
811
|
if (
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
812
|
+
drive.kind !== 'drive' &&
|
|
813
|
+
(typeof found.duration_seconds !== 'number' ||
|
|
814
|
+
!Number.isFinite(found.duration_seconds) ||
|
|
815
|
+
found.duration_seconds <= 0)
|
|
790
816
|
) {
|
|
791
817
|
throw new FrontsPublishIntentError(
|
|
792
818
|
'podcast_source_duration_unavailable',
|
|
@@ -795,18 +821,27 @@ async function podcastSourceRef(
|
|
|
795
821
|
)
|
|
796
822
|
}
|
|
797
823
|
if (found.processing_state !== 'queued' || !found.processing_trigger_token?.trim()) return null
|
|
824
|
+
const generation = await frontsPodcastSourceGeneration({
|
|
825
|
+
content_id: row.id,
|
|
826
|
+
slug: row.slug,
|
|
827
|
+
canonical_version: row.canonical_version as number,
|
|
828
|
+
processing_source_url: found.processing_source_url,
|
|
829
|
+
processing_source_kind: found.processing_source_kind,
|
|
830
|
+
processing_trigger_token: found.processing_trigger_token,
|
|
831
|
+
})
|
|
832
|
+
if (drive.kind === 'drive') {
|
|
833
|
+
return {
|
|
834
|
+
kind: 'google_drive',
|
|
835
|
+
file_id: drive.fileId,
|
|
836
|
+
...(drive.resourceKey !== undefined ? { resource_key: drive.resourceKey } : {}),
|
|
837
|
+
generation,
|
|
838
|
+
}
|
|
839
|
+
}
|
|
798
840
|
return {
|
|
799
841
|
kind: 'https',
|
|
800
842
|
url: found.processing_source_url,
|
|
801
|
-
duration_seconds: found.duration_seconds,
|
|
802
|
-
generation
|
|
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
|
-
}),
|
|
843
|
+
duration_seconds: found.duration_seconds as number,
|
|
844
|
+
generation,
|
|
810
845
|
}
|
|
811
846
|
}
|
|
812
847
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Google Drive share-link recognition for media sources.
|
|
2
|
+
//
|
|
3
|
+
// A Drive share link is an HTML viewer page, never media. The Go media plane
|
|
4
|
+
// fetches a Drive file itself (with the estate's Google identity) and measures
|
|
5
|
+
// its duration, so the CMS only has to recognise the link and hand over the
|
|
6
|
+
// file id (and the resource key that older shared files require).
|
|
7
|
+
//
|
|
8
|
+
// The link stays in the editor's own column exactly as pasted: the
|
|
9
|
+
// `processing_source_kind` column is CHECK-constrained to 'https', so Drive-ness
|
|
10
|
+
// is DERIVED from the stored URL, here, at serve/dispatch time -- one function,
|
|
11
|
+
// no second source of truth to drift.
|
|
12
|
+
|
|
13
|
+
/** Google Drive file ids are URL-safe base64-ish; the Go side enforces the same. */
|
|
14
|
+
const DRIVE_FILE_ID_RE = /^[A-Za-z0-9_-]{10,200}$/
|
|
15
|
+
const DRIVE_RESOURCE_KEY_RE = /^[A-Za-z0-9_-]{1,200}$/
|
|
16
|
+
|
|
17
|
+
const DRIVE_HOSTS = new Set(['drive.google.com', 'docs.google.com'])
|
|
18
|
+
|
|
19
|
+
const NOT_A_FILE_REASON =
|
|
20
|
+
'That is a Google Doc/Sheet/Slides/folder link, not an audio file. Share the audio file itself from Google Drive.'
|
|
21
|
+
const NO_FILE_ID_REASON =
|
|
22
|
+
'That Google Drive link has no valid file id. Copy the share link of the audio file itself from Google Drive.'
|
|
23
|
+
|
|
24
|
+
export type GoogleDriveLink =
|
|
25
|
+
| { kind: 'drive'; fileId: string; resourceKey?: string }
|
|
26
|
+
| { kind: 'drive_invalid'; reason: string }
|
|
27
|
+
| { kind: 'not_drive' }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Classify a pasted media-source URL.
|
|
31
|
+
*
|
|
32
|
+
* - `not_drive`: not on a Google Drive host (drive.google.com, docs.google.com,
|
|
33
|
+
* host compared case-insensitively). The caller handles it as any other URL.
|
|
34
|
+
* - `drive`: a recognised file link carrying a valid file id.
|
|
35
|
+
* - `drive_invalid`: on a Drive host but not usable as an audio file -- a
|
|
36
|
+
* Doc/Sheet/folder, a file link with no or a malformed id, a malformed
|
|
37
|
+
* `resourcekey`, or a shape this function does not know. The caller must
|
|
38
|
+
* refuse it: stored as a plain https URL it would be an HTML page.
|
|
39
|
+
*
|
|
40
|
+
* Recognised shapes (each optionally with `/u/<n>/` user segments):
|
|
41
|
+
* /file/d/<id>[/view|/edit|/...] /open?id=<id>
|
|
42
|
+
* /uc?id=<id>[&export=download] (docs.google.com/uc?id=<id> too)
|
|
43
|
+
*/
|
|
44
|
+
export function classifyGoogleDriveUrl(value: string): GoogleDriveLink {
|
|
45
|
+
let url: URL
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(value.trim())
|
|
48
|
+
} catch {
|
|
49
|
+
return { kind: 'not_drive' }
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { kind: 'not_drive' }
|
|
52
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, '')
|
|
53
|
+
if (!DRIVE_HOSTS.has(host)) return { kind: 'not_drive' }
|
|
54
|
+
|
|
55
|
+
if (url.protocol !== 'https:') {
|
|
56
|
+
return { kind: 'drive_invalid', reason: 'Google Drive links must use https.' }
|
|
57
|
+
}
|
|
58
|
+
if (url.username || url.password || (url.port && url.port !== '443')) {
|
|
59
|
+
return { kind: 'drive_invalid', reason: 'That Google Drive link is malformed.' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `/u/<n>/` selects the signed-in Google account; it carries nothing else.
|
|
63
|
+
const path = url.pathname.replace(/\/u\/\d+(?=\/)/g, '').replace(/\/+$/, '')
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
/^\/(?:document|spreadsheets|presentation|forms)\/d(?:\/|$)/.test(path) ||
|
|
67
|
+
/^\/(?:drive\/)?folders(?:\/|$)/.test(path) ||
|
|
68
|
+
/^\/(?:embedded)?folderview$/.test(path)
|
|
69
|
+
) {
|
|
70
|
+
return { kind: 'drive_invalid', reason: NOT_A_FILE_REASON }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let candidate: string | null = null
|
|
74
|
+
const filePath = /^\/file\/d(?:\/([^/]*))?(?:\/.*)?$/.exec(path)
|
|
75
|
+
if (filePath) {
|
|
76
|
+
candidate = filePath[1] ?? ''
|
|
77
|
+
} else if (path === '/uc' || (path === '/open' && host === 'drive.google.com')) {
|
|
78
|
+
candidate = url.searchParams.get('id') ?? ''
|
|
79
|
+
} else {
|
|
80
|
+
return {
|
|
81
|
+
kind: 'drive_invalid',
|
|
82
|
+
reason:
|
|
83
|
+
'That Google Drive link is not a file link. Copy the share link of the audio file itself from Google Drive.',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!DRIVE_FILE_ID_RE.test(candidate)) {
|
|
87
|
+
return { kind: 'drive_invalid', reason: NO_FILE_ID_REASON }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resourceKey = url.searchParams.get('resourcekey')
|
|
91
|
+
if (resourceKey === null) return { kind: 'drive', fileId: candidate }
|
|
92
|
+
if (!DRIVE_RESOURCE_KEY_RE.test(resourceKey)) {
|
|
93
|
+
return {
|
|
94
|
+
kind: 'drive_invalid',
|
|
95
|
+
reason: 'That Google Drive link has a malformed resource key. Copy the share link again.',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { kind: 'drive', fileId: candidate, resourceKey }
|
|
99
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -111,6 +111,7 @@ export {
|
|
|
111
111
|
hydrateFrontsPublishIntents,
|
|
112
112
|
setFrontsChannelTargets,
|
|
113
113
|
} from './fronts-publish-intent.js'
|
|
114
|
+
export { classifyGoogleDriveUrl, type GoogleDriveLink } from './google-drive-source.js'
|
|
114
115
|
// Import parsers — pure string→struct transforms for Markdown/Ghost/Substack.
|
|
115
116
|
export {
|
|
116
117
|
htmlToMarkdown,
|
package/src/migration-vendor.ts
CHANGED
|
@@ -341,12 +341,22 @@ function assertManifestCanAdvance(
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
/**
|
|
345
|
+
* A sequence the plan is about to allocate must not already be owned by a
|
|
346
|
+
* consumer migration. A target that is already on disk -- recorded in the
|
|
347
|
+
* receipt or adopted by hash -- is a historical fact, not a reservation: it has
|
|
348
|
+
* usually been applied, D1's ledger keys off its exact filename, and the only
|
|
349
|
+
* "fix" for a consumer file sharing its number would be a rename that re-runs
|
|
350
|
+
* applied SQL. So the guard covers new targets only; pre-existing duplicates are
|
|
351
|
+
* the consumer's to live with and never block vendoring the next migration.
|
|
352
|
+
*/
|
|
344
353
|
async function assertNoSequenceCollisions(
|
|
345
354
|
migrationsDir: string,
|
|
346
355
|
plan: PlannedMigration[],
|
|
347
356
|
): Promise<void> {
|
|
348
357
|
const existing = await readdir(migrationsDir)
|
|
349
358
|
for (const migration of plan) {
|
|
359
|
+
if (existing.includes(migration.target)) continue
|
|
350
360
|
const prefix = `${sequencePrefix(migration.sequence)}_`
|
|
351
361
|
const collisions = existing.filter(
|
|
352
362
|
(name) => name.endsWith('.sql') && name.startsWith(prefix) && name !== migration.target,
|
package/src/providers/types.ts
CHANGED
|
@@ -391,7 +391,12 @@ export interface FoundryJobSpec {
|
|
|
391
391
|
sourceUrl: string
|
|
392
392
|
sourceKind?: string
|
|
393
393
|
kind: string
|
|
394
|
-
|
|
394
|
+
/**
|
|
395
|
+
* The authoritative media duration. `null` only for a Google Drive podcast
|
|
396
|
+
* source (`sourceKind: 'google_drive'`): the Go plane fetches the file itself
|
|
397
|
+
* and measures the duration, so the CMS has none to send.
|
|
398
|
+
*/
|
|
399
|
+
durationSeconds: number | null
|
|
395
400
|
videoId?: string
|
|
396
401
|
title?: string
|
|
397
402
|
description?: string | null
|
package/src/routes/content.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
parseFrontsDefaultChannelTargets,
|
|
39
39
|
setFrontsChannelTargets,
|
|
40
40
|
} from '../engine/fronts-publish-intent.js'
|
|
41
|
+
import { classifyGoogleDriveUrl } from '../engine/google-drive-source.js'
|
|
41
42
|
import { evaluateArticleBody } from '../engine/publish-guard.js'
|
|
42
43
|
import {
|
|
43
44
|
createContent,
|
|
@@ -433,7 +434,34 @@ export function normaliseNextcloudShareUrl(value: string): string {
|
|
|
433
434
|
return url.toString()
|
|
434
435
|
}
|
|
435
436
|
|
|
436
|
-
|
|
437
|
+
// A Google Drive link is not run through the Nextcloud rewrite; it is kept as
|
|
438
|
+
// the editor pasted it. The podcast schema accepts a valid Drive file link
|
|
439
|
+
// (the Go plane fetches and measures it itself) and turns any other
|
|
440
|
+
// drive.google.com / docs.google.com URL -- a Doc, a folder, a link with no
|
|
441
|
+
// file id -- into a field-level issue, because stored as a plain https URL it
|
|
442
|
+
// is an HTML page. The video schema refuses every Drive link: the video path
|
|
443
|
+
// ingests over plain https and would fetch the viewer page.
|
|
444
|
+
const PodcastSourceUrlSchema = z
|
|
445
|
+
.string()
|
|
446
|
+
.url()
|
|
447
|
+
.superRefine((value, ctx) => {
|
|
448
|
+
const link = classifyGoogleDriveUrl(value)
|
|
449
|
+
if (link.kind === 'drive_invalid') ctx.addIssue({ code: 'custom', message: link.reason })
|
|
450
|
+
})
|
|
451
|
+
.transform(normaliseNextcloudShareUrl)
|
|
452
|
+
|
|
453
|
+
const VIDEO_DRIVE_SOURCE_UNSUPPORTED =
|
|
454
|
+
'Google Drive links are not yet supported for video; share a direct download link'
|
|
455
|
+
|
|
456
|
+
const VideoSourceUrlSchema = z
|
|
457
|
+
.string()
|
|
458
|
+
.url()
|
|
459
|
+
.superRefine((value, ctx) => {
|
|
460
|
+
if (classifyGoogleDriveUrl(value).kind !== 'not_drive') {
|
|
461
|
+
ctx.addIssue({ code: 'custom', message: VIDEO_DRIVE_SOURCE_UNSUPPORTED })
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
.transform(normaliseNextcloudShareUrl)
|
|
437
465
|
|
|
438
466
|
function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
439
467
|
const category = slugEnum(resolved.primaryCategorySlugs)
|
|
@@ -476,7 +504,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
476
504
|
videoId: z.string().min(1),
|
|
477
505
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
478
506
|
thumbnailImageId: z.string().optional().nullable(),
|
|
479
|
-
processingSourceUrl:
|
|
507
|
+
processingSourceUrl: VideoSourceUrlSchema.optional().nullable(),
|
|
480
508
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
481
509
|
}),
|
|
482
510
|
}),
|
|
@@ -496,7 +524,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
496
524
|
// there because a video always has a slug-derived identity up front.
|
|
497
525
|
audioR2Key: z.string().default(''),
|
|
498
526
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
499
|
-
processingSourceUrl:
|
|
527
|
+
processingSourceUrl: PodcastSourceUrlSchema.optional().nullable(),
|
|
500
528
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
501
529
|
}),
|
|
502
530
|
}),
|
|
@@ -561,7 +589,7 @@ const VideoContentUpdateSchema = z.object({
|
|
|
561
589
|
videoId: z.string().optional(),
|
|
562
590
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
563
591
|
thumbnailImageId: z.string().optional().nullable(),
|
|
564
|
-
processingSourceUrl:
|
|
592
|
+
processingSourceUrl: VideoSourceUrlSchema.optional().nullable(),
|
|
565
593
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
566
594
|
})
|
|
567
595
|
|
|
@@ -569,7 +597,7 @@ const PodcastContentUpdateSchema = z.object({
|
|
|
569
597
|
transcript: z.string().optional(),
|
|
570
598
|
audioR2Key: z.string().optional(),
|
|
571
599
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
572
|
-
processingSourceUrl:
|
|
600
|
+
processingSourceUrl: PodcastSourceUrlSchema.optional().nullable(),
|
|
573
601
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
574
602
|
})
|
|
575
603
|
|
|
@@ -990,6 +1018,22 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
990
1018
|
content.durationSeconds = null
|
|
991
1019
|
return null
|
|
992
1020
|
}
|
|
1021
|
+
// A Drive podcast source is fetched and measured by the Go plane, not
|
|
1022
|
+
// probed here: keep the editor's URL as pasted, store no duration and let
|
|
1023
|
+
// the host's probe stay out of it (it would fetch the HTML viewer page).
|
|
1024
|
+
if (type === 'podcast' && classifyGoogleDriveUrl(requestedUrl).kind === 'drive') {
|
|
1025
|
+
content.processingSourceUrl = requestedUrl
|
|
1026
|
+
content.processingSourceKind = 'https'
|
|
1027
|
+
content.durationSeconds = null
|
|
1028
|
+
return {
|
|
1029
|
+
requestedUrl,
|
|
1030
|
+
resolvedUrl: requestedUrl,
|
|
1031
|
+
sourceKind: 'https',
|
|
1032
|
+
durationSeconds: null,
|
|
1033
|
+
ready: true,
|
|
1034
|
+
blockers: [],
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
993
1037
|
const storedDuration = positiveIntegerDuration(stored?.durationSeconds)
|
|
994
1038
|
if (stored?.sourceUrl === requestedUrl && storedDuration !== null) {
|
|
995
1039
|
content.processingSourceUrl = stored.sourceUrl
|
|
@@ -1081,11 +1125,22 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1081
1125
|
}
|
|
1082
1126
|
|
|
1083
1127
|
function mediaSourceReadinessFromStored(
|
|
1128
|
+
type: ContentType,
|
|
1084
1129
|
stored: StoredMediaSource | null,
|
|
1085
1130
|
): MediaSourceReadiness | null {
|
|
1086
1131
|
if (!stored) return null
|
|
1087
1132
|
const sourceUrl = stored.sourceUrl?.trim()
|
|
1088
1133
|
if (!sourceUrl) return null
|
|
1134
|
+
if (type === 'podcast' && classifyGoogleDriveUrl(sourceUrl).kind === 'drive') {
|
|
1135
|
+
return {
|
|
1136
|
+
requestedUrl: sourceUrl,
|
|
1137
|
+
resolvedUrl: sourceUrl,
|
|
1138
|
+
sourceKind: 'https',
|
|
1139
|
+
durationSeconds: null,
|
|
1140
|
+
ready: true,
|
|
1141
|
+
blockers: [],
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1089
1144
|
const durationSeconds = positiveIntegerDuration(stored.durationSeconds)
|
|
1090
1145
|
return {
|
|
1091
1146
|
requestedUrl: sourceUrl,
|
|
@@ -1664,7 +1719,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1664
1719
|
: content
|
|
1665
1720
|
const mediaSourceReadiness =
|
|
1666
1721
|
item.type === 'video' || item.type === 'podcast'
|
|
1667
|
-
? mediaSourceReadinessFromStored(
|
|
1722
|
+
? mediaSourceReadinessFromStored(
|
|
1723
|
+
item.type,
|
|
1724
|
+
await loadStoredMediaSource(ctx.db, id, item.type),
|
|
1725
|
+
)
|
|
1668
1726
|
: null
|
|
1669
1727
|
|
|
1670
1728
|
const aiLockedFields: string[] = (() => {
|
|
@@ -1540,6 +1540,10 @@ function AudioBlock({
|
|
|
1540
1540
|
transcodes it, and hydrates the Audio R2 Key and duration automatically. Skip this once the
|
|
1541
1541
|
audio file is uploaded below.
|
|
1542
1542
|
</div>
|
|
1543
|
+
<div style={assetHelp}>
|
|
1544
|
+
A Google Drive share link to the audio file works too, if the file is shared with the Fronts
|
|
1545
|
+
Google account or with anyone who has the link. Google Docs and folders are not audio files.
|
|
1546
|
+
</div>
|
|
1543
1547
|
|
|
1544
1548
|
<div
|
|
1545
1549
|
style={{
|