Appearance
Draft document JSON, platform upload document, and manual upload testing
This guide covers:
- How MongoDB stores draft and platform-upload payloads in the
documentfield (stringified JSON). - The draft JSON shape for frontend and API use, with a short sample for quick tests and a long sample for full UI wiring.
- Field reference (YouTube / Vimeo / OAuth; backup destinations: Google Drive, SFTP).
- 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
documentfield: 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 optionalyoutubeandvimeonested objects (platform-only fields), and optionalsftp(empty object placeholder). Google Drive is selected viatargetsonly — there is noplatforms.google_drivekey.
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
documentfield: JSON string snapshot at distribution time. - Typical contents:
title,description,tags,visibility, optionalcategoryId/madeForKids(YouTube),vimeoCategoryUris, and optional audit copiesdraftYoutube/draftVimeo(theplatforms.youtube/platforms.vimeoslices 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
- Load:
GET /api/draftsorGET /api/drafts/[id]returns aDraftwithtargets,title,description,tags,visibility, andplatformsalready parsed fromdocument(you do not manually parse raw MongoDB documents in the client if you use these routes). - Save:
POST /api/draftsorPATCH /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). - Validate in UI using the types in
types/index.tssoplatforms.youtube/platforms.vimeostay 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.
| Field | Role |
|---|---|
categoryId | snippet.categoryId (e.g. "22" = People & Blogs; use Google’s category list). |
madeForKids | status.selfDeclaredMadeForKids |
defaultLanguage | snippet.defaultLanguage (BCP-47, e.g. en) |
defaultAudioLanguage | snippet.defaultAudioLanguage |
embeddable | status.embeddable |
license | status.license: youtube | creativeCommon |
notifySubscribers | videos.insert query parameter notifySubscribers. When false, init URL includes notifySubscribers=false (omit subscribers feed / notification). Omitted or true matches YouTube default (notify). |
publishAt | status.publishAt (ISO 8601). Upload sets status.privacyStatus to private until publish time. |
recordingDate | recordingDetails.recordingDate (RFC 3339 full-date, e.g. "2025-06-08"). Omitted from init body unless set on the draft. |
playlistTitles | Playlist 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). |
playlistIds | Optional 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).
| Field | Role |
|---|---|
categoryUris | String 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. |
license | Creative Commons codes: by | by-nc | by-nc-nd | by-nc-sa | by-nd | by-sa | cc0 |
contentRating | Optional 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.
| Destination | Connect flow | Server env |
|---|---|---|
| Google Drive | OAuth: /api/platforms/connect/drive → /api/platforms/callback/drive | GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_CLIENT_SECRET |
| SFTP | Form 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 coverplaylists.insertby 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, includingplaylists.insert(avoidsinsufficientPermissionswhen 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 withfetch). - A draft document whose
documentfield matches one of the JSON samples above; note itsdraftId(APIid).
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 asr2ObjectKey).uploadJobId— upload job id for complete.
Copying uploadUrl for curl
- After
pis printed, expand the object in the console. - Option A: Right‑click the
uploadUrlproperty → 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 theuploadUrlstring (includinghttps://…) 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.mp4Wait 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.
Related code
| Area | Location |
|---|---|
Draft document parse / merge | lib/draft-upload-metadata.ts |
Platform upload document | lib/platform-upload-document.ts |
| Presign | app/api/uploads/presign/route.ts |
| Complete | app/api/uploads/[jobId]/complete/route.ts |
| Distribute | app/api/uploads/distribute/route.ts |
| YouTube upload + playlists | lib/platforms/youtube.ts |
| Vimeo upload | lib/platforms/vimeo.ts |
| Google Drive backup | lib/platforms/google-drive.ts |
| SFTP backup | lib/platforms/sftp.ts |
| MongoDB schema notes | docs/mongodb-data-model.md |