@growth-labs/cms 0.8.14 → 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +13 -4
  3. package/dist/engine/foundry-dispatch.js.map +1 -1
  4. package/dist/engine/fronts-publish-intent.d.ts +33 -3
  5. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  6. package/dist/engine/fronts-publish-intent.js +27 -11
  7. package/dist/engine/fronts-publish-intent.js.map +1 -1
  8. package/dist/engine/google-drive-source.d.ts +27 -0
  9. package/dist/engine/google-drive-source.d.ts.map +1 -0
  10. package/dist/engine/google-drive-source.js +87 -0
  11. package/dist/engine/google-drive-source.js.map +1 -0
  12. package/dist/engine/index.d.ts +1 -0
  13. package/dist/engine/index.d.ts.map +1 -1
  14. package/dist/engine/index.js +1 -0
  15. package/dist/engine/index.js.map +1 -1
  16. package/dist/migration-vendor.d.ts.map +1 -1
  17. package/dist/migration-vendor.js +11 -0
  18. package/dist/migration-vendor.js.map +1 -1
  19. package/dist/providers/types.d.ts +6 -1
  20. package/dist/providers/types.d.ts.map +1 -1
  21. package/dist/providers/types.js.map +1 -1
  22. package/dist/routes/content.d.ts.map +1 -1
  23. package/dist/routes/content.js +59 -7
  24. package/dist/routes/content.js.map +1 -1
  25. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  26. package/dist/ui/editor/ContentForm.js +1 -1
  27. package/dist/ui/editor/ContentForm.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/engine/foundry-dispatch.ts +13 -4
  30. package/src/engine/fronts-publish-intent.ts +65 -15
  31. package/src/engine/google-drive-source.ts +99 -0
  32. package/src/engine/index.ts +1 -0
  33. package/src/migration-vendor.ts +10 -0
  34. package/src/providers/types.ts +6 -1
  35. package/src/routes/content.ts +64 -6
  36. package/src/ui/editor/ContentForm.tsx +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js"
@@ -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
- sourceKind = selectedSource ? 'https' : 'r2_key'
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
- typeof durationSeconds !== 'number' ||
249
- !Number.isFinite(durationSeconds) ||
250
- durationSeconds <= 0
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,
@@ -122,6 +123,19 @@ export interface FrontsPublishPodcastAudioRef {
122
123
  kind: 'r2-object' | 'hls-manifest'
123
124
  url: string
124
125
  duration_seconds: number | null
126
+ /**
127
+ * `content_items.visibility` when the intent is built. The Go publisher
128
+ * places a free episode's audio on the public bucket at go-live (the
129
+ * public RSS sizes enclosures from PUBLIC_MEDIA) and keeps a premium
130
+ * episode's audio off it.
131
+ */
132
+ visibility: 'free' | 'premium'
133
+ /**
134
+ * The raw R2 key behind `url` (`podcast_content.audio_r2_key`, no leading
135
+ * slash); null for a legacy row whose stored audio is a full URL rather
136
+ * than a key, which the publisher cannot place.
137
+ */
138
+ object_key: string | null
125
139
  }
126
140
 
127
141
  /**
@@ -130,12 +144,17 @@ export interface FrontsPublishPodcastAudioRef {
130
144
  * `podcast_audio_hls`. Populated from `podcast_content.processing_source_url`
131
145
  * / `processing_source_kind` (migration 0028) -- the same "captured at
132
146
  * dispatch time" pattern `video_content.processing_source_url` already uses.
133
- * `kind` is always `'https'` today: a single literal, not an open enum,
134
- * because there is exactly one supported source kind. Present only while the
147
+ * `kind` is `'https'` (a direct URL, duration verified by the CMS) or
148
+ * `'google_drive'` (a Drive file the Go plane fetches and measures itself; see
149
+ * `FrontsPublishGoogleDrivePodcastSourceRef`). Present only while the
135
150
  * podcast is incomplete. An existing audio object with a missing transcript
136
151
  * still needs production; it is not a publishable audio reference yet.
137
152
  */
138
- export interface FrontsPublishPodcastSourceRef {
153
+ export type FrontsPublishPodcastSourceRef =
154
+ | FrontsPublishHttpsPodcastSourceRef
155
+ | FrontsPublishGoogleDrivePodcastSourceRef
156
+
157
+ export interface FrontsPublishHttpsPodcastSourceRef {
139
158
  kind: 'https'
140
159
  url: string
141
160
  /** Immutable media identity; rescheduling alone does not change it. */
@@ -144,6 +163,22 @@ export interface FrontsPublishPodcastSourceRef {
144
163
  duration_seconds: number
145
164
  }
146
165
 
166
+ /**
167
+ * A Google Drive file the editor pasted a share link for. The Go plane fetches
168
+ * it with the estate's Google identity and measures the duration itself, so
169
+ * this shape carries NO `url` and NO `duration_seconds` (Go's schema rejects
170
+ * either). `file_id` matches `^[A-Za-z0-9_-]{10,200}$`; `resource_key`, when the
171
+ * link carried one, matches `^[A-Za-z0-9_-]{1,200}$`. Derived from the stored
172
+ * source URL by `classifyGoogleDriveUrl`; nothing about it is stored.
173
+ */
174
+ export interface FrontsPublishGoogleDrivePodcastSourceRef {
175
+ kind: 'google_drive'
176
+ file_id: string
177
+ resource_key?: string
178
+ /** Immutable media identity; rescheduling alone does not change it. */
179
+ generation: string
180
+ }
181
+
147
182
  /**
148
183
  * The video twin of `FrontsPublishPodcastSourceRef` (W1-02): the captured
149
184
  * source of a scheduled video whose HLS is not ready, for the Go
@@ -783,10 +818,14 @@ async function podcastSourceRef(
783
818
  found.processing_source_kind !== 'https'
784
819
  )
785
820
  return null
821
+ // Duration is measured by the Go plane for a Drive source; only a plain
822
+ // https source needs the editor-verified duration up front.
823
+ const drive = classifyGoogleDriveUrl(found.processing_source_url)
786
824
  if (
787
- typeof found.duration_seconds !== 'number' ||
788
- !Number.isFinite(found.duration_seconds) ||
789
- found.duration_seconds <= 0
825
+ drive.kind !== 'drive' &&
826
+ (typeof found.duration_seconds !== 'number' ||
827
+ !Number.isFinite(found.duration_seconds) ||
828
+ found.duration_seconds <= 0)
790
829
  ) {
791
830
  throw new FrontsPublishIntentError(
792
831
  'podcast_source_duration_unavailable',
@@ -795,18 +834,27 @@ async function podcastSourceRef(
795
834
  )
796
835
  }
797
836
  if (found.processing_state !== 'queued' || !found.processing_trigger_token?.trim()) return null
837
+ const generation = await frontsPodcastSourceGeneration({
838
+ content_id: row.id,
839
+ slug: row.slug,
840
+ canonical_version: row.canonical_version as number,
841
+ processing_source_url: found.processing_source_url,
842
+ processing_source_kind: found.processing_source_kind,
843
+ processing_trigger_token: found.processing_trigger_token,
844
+ })
845
+ if (drive.kind === 'drive') {
846
+ return {
847
+ kind: 'google_drive',
848
+ file_id: drive.fileId,
849
+ ...(drive.resourceKey !== undefined ? { resource_key: drive.resourceKey } : {}),
850
+ generation,
851
+ }
852
+ }
798
853
  return {
799
854
  kind: 'https',
800
855
  url: found.processing_source_url,
801
- duration_seconds: found.duration_seconds,
802
- generation: await frontsPodcastSourceGeneration({
803
- content_id: row.id,
804
- slug: row.slug,
805
- canonical_version: row.canonical_version as number,
806
- processing_source_url: found.processing_source_url,
807
- processing_source_kind: found.processing_source_kind,
808
- processing_trigger_token: found.processing_trigger_token,
809
- }),
856
+ duration_seconds: found.duration_seconds as number,
857
+ generation,
810
858
  }
811
859
  }
812
860
 
@@ -1070,6 +1118,8 @@ async function buildContentRefs(
1070
1118
  kind: 'r2-object',
1071
1119
  url: objectKeyUrl(key, ctx.mediaPublicDomain),
1072
1120
  duration_seconds: found.duration_seconds ?? null,
1121
+ visibility: row.visibility === 'premium' ? 'premium' : 'free',
1122
+ object_key: /^https?:\/\//i.test(key) ? null : key.replace(/^\/+/, ''),
1073
1123
  }
1074
1124
  } else {
1075
1125
  const failure = blocker ?? {
@@ -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
+ }
@@ -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,
@@ -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,
@@ -391,7 +391,12 @@ export interface FoundryJobSpec {
391
391
  sourceUrl: string
392
392
  sourceKind?: string
393
393
  kind: string
394
- durationSeconds: number
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
@@ -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
- const MediaSourceUrlSchema = z.string().url().transform(normaliseNextcloudShareUrl)
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: MediaSourceUrlSchema.optional().nullable(),
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: MediaSourceUrlSchema.optional().nullable(),
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: MediaSourceUrlSchema.optional().nullable(),
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: MediaSourceUrlSchema.optional().nullable(),
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(await loadStoredMediaSource(ctx.db, id, item.type))
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={{