Skip to content

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

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 / OAuth; backup destinations: Google Drive, SFTP).
  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). 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", "google_drive", "sftp" (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).
    • platforms: object with optional youtube and vimeo nested objects (platform-only fields), and optional sftp (empty object placeholder). Google Drive is selected via targets only — there is no platforms.google_drive key.

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, optional categoryId / madeForKids (YouTube), vimeoCategoryUris, and optional audit copies draftYoutube / draftVimeo (the platforms.youtube / platforms.vimeo slices from the draft 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.youtube / platforms.vimeo stay consistent with the server.

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.


Backup destinations (Google Drive, SFTP)

These targets copy the uploaded video to a connected backup account. Neither has publish-specific draft fields today. Include google_drive only in targets (no platforms.google_drive — it is not part of DraftPlatforms). For SFTP, you may include platforms.sftp: {} so server-side draft parsing preserves the backup target through merge; otherwise omit it.

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

Upload implementations: lib/platforms/google-drive.ts, lib/platforms/sftp.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
Google Drive backuplib/platforms/google-drive.ts
SFTP backuplib/platforms/sftp.ts
MongoDB schema notesdocs/mongodb-data-model.md