Skip to content

Draft document JSON, platform upload document, and manual upload testing

For the dashboard workflow (metadata modal, upload history, YouTube import), see Uploads, Livestreams & Distribution.

This guide covers:

  1. How MongoDB stores draft and platform-upload payloads in the document field (stringified JSON).
  2. The draft JSON shape for frontend and API use, with a short sample for quick tests and a long sample for full UI wiring.
  3. Field reference (YouTube, Vimeo, Facebook, SermonAudio; backup destinations: Google Drive, SFTP, SMB).
  4. End-to-end manual steps: presign → PUT to R2 with curl → complete → distribute.

Canonical TypeScript types live in types/index.ts (Draft, DraftPlatforms, YouTubeDraftFields, VimeoDraftFields, FacebookDraftFields, SermonAudioDraftFields). Parsing and merge helpers live in lib/draft-upload-metadata.ts.


document in MongoDB

drafts collection

  • Each document includes a document field: a single JSON string (validated and bounded by application limits).
  • That string must deserialize to an object with at least:
    • targets: one or more of "youtube", "vimeo", "facebook", "sermon_audio", "google_drive", "sftp", "smb" (order preserved, deduped by the API when saving).
    • title, description, visibility (public | unlisted | private).
    • tags: string array — one shared list for every platform (not per-platform), unless a platform override is set.
    • platforms: optional nested objects for publish targets (youtube, vimeo, facebook, sermon_audio) and backup title overrides (google_drive, sftp, smb).
    • Optional document-root fields: labels (VideoSphere-only tags), backupNaming (filename rules for backup targets), thumbnailR2Key / thumbnailContentType (default thumbnail for distribution).

The app’s draft APIs (POST/PATCH /api/drafts, GET /api/drafts, …) read and write this structure; the repository persists it as document on the drafts collection.

platform_uploads collection

  • Each document has a document field: JSON string snapshot at distribution time.
  • Typical contents: title, description, tags, visibility, platform-specific fields (YouTube categoryId, Vimeo categoryUris, etc.), and optional audit copies of the draft platform slices (draftYoutube, draftVimeo, …) captured when distribute ran.
  • Purpose: debugging, support, and correlating what was sent to each platform without re-reading the draft.

See lib/platform-upload-document.ts for the stored shape and size limit (MAX_PLATFORM_UPLOAD_DOCUMENT_CHARS).


Frontend: wiring the editor to the draft

  1. Load: GET /api/drafts or GET /api/drafts/[id] returns a Draft with targets, title, description, tags, visibility, and platforms already parsed from document (you do not manually parse raw MongoDB documents in the client if you use these routes).
  2. Save: POST /api/drafts or PATCH /api/drafts/[id] with a JSON body using the same keys as the stored document: targets, title, description, visibility, tags, platforms (partial updates on PATCH merge per server rules).
  3. Validate in UI using the types in types/index.ts so platforms.* slices stay consistent with the server.

The Uploads UI (/dashboard/uploads) opens DraftMetadataModal for editing. Deep-link with ?editDraft=[id] or ?createDraftId=[id] after creating a draft from the dashboard.


Short draft JSON (smoke / manual testing)

Use this when you only need minimal fields; adjust draftId in the upload flow to match a draft saved with this content.

json
{
  "targets": ["youtube", "vimeo"],
  "title": "Example video title (max 100 chars for YouTube snippet)",
  "description": "Shared description for every platform. Can include links and longer copy.",
  "visibility": "private",
  "tags": ["example", "smoke-test", "multi-platform"],
  "platforms": {
    "youtube": {
      "categoryId": "22",
      "madeForKids": true
    },
    "vimeo": {
      "categoryUris": ["/categories/animation"]
    }
  }
}

Long draft JSON (fuller frontend / API example)

Valid for drafts.document and for POST /api/drafts / PATCH /api/drafts/[id] bodies (with the usual required fields on POST). Omit any optional key you do not need.

json
{
  "targets": ["youtube", "vimeo"],
  "title": "Example video title (max 100 chars for YouTube snippet)",
  "description": "Shared description for every platform. Can include links and longer copy.",
  "visibility": "private",
  "tags": ["example", "smoke-test", "multi-platform"],
  "platforms": {
    "youtube": {
      "categoryId": "22",
      "madeForKids": false,
      "defaultLanguage": "en",
      "defaultAudioLanguage": "en",
      "embeddable": true,
      "license": "youtube",
      "playlistTitles": ["Example playlist title"],
      "playlistIds": []
    },
    "vimeo": {
      "categoryUris": ["/categories/animation"],
      "license": "by",
      "contentRating": ["safe"]
    }
  }
}

Field reference: platforms.youtube

Mapped to YouTube Data API v3 videos.insert resumable init (part=snippet,status,recordingDetails; optional query notifySubscribers=false when draft notifySubscribers is false) plus post-upload playlist and thumbnail steps.

FieldRole
categoryIdsnippet.categoryId (e.g. "22" = People & Blogs; use Google’s category list).
madeForKidsstatus.selfDeclaredMadeForKids
defaultLanguagesnippet.defaultLanguage (BCP-47, e.g. en)
defaultAudioLanguagesnippet.defaultAudioLanguage
embeddablestatus.embeddable
licensestatus.license: youtube | creativeCommon
notifySubscribersvideos.insert query parameter notifySubscribers. When false, init URL includes notifySubscribers=false (omit subscribers feed / notification). Omitted or true matches YouTube default (notify).
publishAtstatus.publishAt (ISO 8601). Upload sets status.privacyStatus to private until publish time.
recordingDaterecordingDetails.recordingDate (RFC 3339 full-date, e.g. "2025-06-08"). Omitted from init body unless set on the draft.
playlistTitlesPlaylist titles (snippet.title). Same idea as porjo/youtubeuploader -metaJSON playlistTitles. Server: playlists.list (mine=true, paginated) → case-insensitive title match → else playlists.insert (privacy follows draft visibility) → playlistItems.insert. Duplicate strings in the array are deduped case-insensitively (first wins).
playlistIdsOptional playlist ids from playlist?list=… in the URL; each gets playlistItems.insert.

Not implemented (would need more API parts or endpoints): recordingDetails.location / location search UI, localizations, captions, monetization, liveStreamingDetails, and post-upload-only videos.update fields. Custom thumbnails use thumbnails.set after upload when thumbnailR2Key is set on the draft.


Field reference: platforms.vimeo

Sent on Vimeo POST /me/videos using snake_case on the wire where the API expects it (handled in lib/platforms/vimeo.ts).

FieldRole
categoryUrisString array parsed into batch [{ "category": "<slug>" }, …] for PUT …/videos/{id}/categories. Use /categories/animation, slug animation, or a vimeo.com category URL — not a fake numeric path.
licenseCreative Commons codes: by | by-nc | by-nc-nd | by-nc-sa | by-nd | by-sa | cc0
contentRatingOptional string[]; audience tier and mature-detail flags from GET https://api.vimeo.com/contentratings

Not implemented: spatial/360 payloads, full embed logos/color, showcase/folder membership, custom domain whitelist bodies.


Field reference: platforms.facebook

Facebook Reels upload via Graph API resumable upload + finish call. See lib/platforms/facebook.ts.

FieldRole
titleOverrideReel title when different from document-root title
descriptionOverrideReel description override
videoStatePUBLISHED (default) or SCHEDULED
scheduledPublishTimeUnix seconds; required when videoState is SCHEDULED (10 min–75 days ahead)
thumbnailR2KeyOverridePer-platform thumbnail R2 key when overriding the draft default

Connect flow: GET /api/platforms/connect/facebook → OAuth → page/profile selection.


Field reference: platforms.sermon_audio

Maps to SermonAudio POST /v2/node/sermons and follow-up publish PATCH. See lib/platforms/sermon-audio.ts and SermonAudioDraftFields in types/index.ts.

FieldRole
titleOverride, descriptionOverride, tagsOverridePer-platform copy overrides
speakerName, speakerIDSermonAudio speaker
preachDateYYYY-MM-DD preach date
eventTypeFrom GET /v2/node/filter_options/sermon_event_types
subtitle, seriesIDSeries / sub-heading
bibleText, displayTitleScripture reference and short title
languageCodeFrom GET /v2/node/languages (default en)
autoPublishOnProcessedAuto-publish after SA processing (default on)
publishTimestampScheduled publish (Unix seconds); disables auto-publish
crossPublishCross Publish settings for YouTube, Facebook, X, Instagram

Connect flow: POST /api/platforms/connect/sermon-audio (API key form; no OAuth).


Backup destinations (Google Drive, SFTP, SMB)

These targets copy the uploaded video to a connected backup account. Publish-specific fields are minimal; optional titleOverride on platforms.google_drive, platforms.sftp, or platforms.smb changes the remote filename. Document-root backupNaming controls date/series/suffix prefixes for all backup targets.

DestinationConnect flowServer env
Google DriveOAuth: /api/platforms/connect/drive/api/platforms/callback/driveGOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_CLIENT_SECRET
SFTPForm in Connected Accounts → POST /api/platforms/connect/sftp (SSH key or password)None — credentials stored encrypted per user
SMBForm in Connected Accounts → POST /api/platforms/connect/smbNone — credentials stored encrypted per user

Upload implementations: lib/platforms/google-drive.ts, lib/platforms/sftp.ts, lib/platforms/smb.ts.


OAuth scopes (YouTube)

Connect flow: app/api/platforms/connect/youtube/route.ts.

Requested scopes (space-separated in the consent URL):

  • https://www.googleapis.com/auth/youtube.upload — resumable upload (videos.insert); does not cover playlists.insert by itself.
  • https://www.googleapis.com/auth/youtube.readonly — channel info; playlist listing.
  • https://www.googleapis.com/auth/youtube.force-ssl — listed for various write operations.
  • https://www.googleapis.com/auth/youtube — broad account management, including playlists.insert (avoids insufficientPermissions when creating playlists by title).

If playlist creation fails with insufficientPermissions, disconnect and reconnect YouTube after scope changes so the stored refresh token was issued with the current consent.

Vimeo: connect still requires upload + edit-related scopes for tags/categories (see Vimeo connect route).


Manual testing: presign → R2 → complete → distribute

Prerequisites

  • Dev server running (pnpm dev) and you are logged in in the browser (session cookie sent with fetch).
  • A draft document whose document field matches one of the JSON samples above; note its draftId (API id).

1. Presign (browser DevTools → Console)

Set variables, then run:

javascript
const draftId = 'YOUR_DRAFT_ID';
const fileName = 'my-video.mp4';
const contentType = 'video/mp4';
const fileSize = 217281388; // actual bytes of the file you will upload

const p = await fetch('/api/uploads/presign', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ draftId, fileName, contentType, fileSize }),
}).then((r) => r.json());

p;

The last line prints p, which should include:

  • uploadUrl — presigned PUT URL (expires, typically 15 minutes).
  • key — R2 object key (pass to distribute as r2ObjectKey).
  • uploadJobId — upload job id for complete.

Copying uploadUrl for curl

  • After p is printed, expand the object in the console.
  • Option A: Right‑click the uploadUrl property → Copy string value (wording varies by browser).
  • Option B: Right‑click the logged object → Copy object (or store copy(p) if you use a helper), paste into a text editor, and copy the uploadUrl string (including https://…) for step 2.

Replace YOUR_PRESIGNED_URL_FROM_DEVTOOLS below with that full URL.

2. Upload file to R2 (terminal)

Use the same Content-Type you sent to presign (video/mp4 in the example):

bash
curl -v "YOUR_PRESIGNED_URL_FROM_DEVTOOLS" \
  -H "content-type: video/mp4" \
  --upload-file /path/to/my-video.mp4

Wait for a successful response (HTTP 200 from R2).

3. Complete the upload job (browser console)

Uses p.uploadJobId from step 1:

javascript
await fetch(`/api/uploads/${p.uploadJobId}/complete`, { method: 'POST' }).then((r) =>
  r.json()
);

4. Distribute (browser console)

Uses draftId, p.key, and the platforms you want (subset of draft targets is allowed per API rules):

javascript
const d = await fetch('/api/uploads/distribute', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    draftId,
    r2ObjectKey: p.key,
    platforms: ['youtube', 'vimeo'],
  }),
}).then((r) => r.json());

d;

You should get 202 with a jobId while distribution runs asynchronously. Check platform upload documents and logs if a platform fails.


AreaLocation
Draft document parse / mergelib/draft-upload-metadata.ts
Platform upload documentlib/platform-upload-document.ts
Presignapp/api/uploads/presign/route.ts
Completeapp/api/uploads/[jobId]/complete/route.ts
Distributeapp/api/uploads/distribute/route.ts
YouTube upload + playlistslib/platforms/youtube.ts
Vimeo uploadlib/platforms/vimeo.ts
Facebook Reels uploadlib/platforms/facebook.ts
SermonAudio uploadlib/platforms/sermon-audio.ts
Google Drive backuplib/platforms/google-drive.ts
SFTP backuplib/platforms/sftp.ts
SMB backuplib/platforms/smb.ts
MongoDB schema notesdocs/mongodb-data-model.md