YouTube (youtube)
youtube uploads one video per post through the YouTube Data API v3
resumable upload protocol, then optionally sets a custom thumbnail. Publishing
is resumable and restart-safe: post_pending only opens an upload session,
check_post_status asks the session how many bytes Google has
(308 + Range), and finalize_post streams the missing ranges in 8 MiB
chunks (a multiple of YouTube’s required 256 KiB) for up to four minutes per
call.
The provider is is_between_steps = true: the OAuth login authenticates a
Google account, then channelSetupTool selects one of that account’s YouTube
channels.
- Rust source:
crates/rustiz-providers/src/youtube.rs - Upstream port:
youtube.provider.ts - Editor: Normal
- Connection mode:
oauth+ a mandatory channel-selection step
Testing-mode trap. A Google Cloud OAuth consent screen in Testing mode accepts up to 100 test users, but every authorization — including its refresh token — expires after 7 days. A channel connected this way stops publishing a week later. Publish the app / complete verification for anything long-lived. See Sandbox / testing mode.
Overview
| Property | Value |
|---|---|
| Identifier | youtube |
| Display name | YouTube |
| Connection mode | oauth, then channelSetupTool (channel selection) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | Yes. supports_pending = true; resumable upload session with check_post_status / finalize_post |
| Analytics | Yes. supports_analytics = true; YouTube Analytics API v2, channel level |
| Post analytics | Yes. supports_post_analytics = true; Data API videos.list statistics |
| Mentions | No. supports_mentions = false |
is_between_steps | true — the OAuth channel is a placeholder until a YouTube channel is selected |
convert_to_jpeg | false |
max_concurrent_job | 200 (a semaphore, not a quota; the real limit is YouTube’s daily upload bucket) |
Character limit (max_length) | 5000 characters (the video description) |
| API | YouTube Data API v3, YouTube Analytics API v2, Google OAuth 2.0 |
| OAuth scopes | https://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../youtube, .../youtube.force-ssl, .../youtube.readonly, .../youtube.upload, .../youtubepartner, .../yt-analytics.readonly |
| Token lifetime | Google access token ~1 h; refresh token until revoked (7 days in Testing mode) |
Server Environment Variables
required_env() returns exactly two variables:
#![allow(unused)]
fn main() {
&["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
YOUTUBE_CLIENT_ID | Yes | none | Google Cloud OAuth 2.0 Web application client id. |
YOUTUBE_CLIENT_SECRET | Yes | none | Google Cloud OAuth client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI. Trailing / stripped. |
YOUTUBE_BASE_URL | No | https://www.googleapis.com | Data API and oauth2/v2/userinfo origin. |
YOUTUBE_OAUTH_BASE_URL | No | https://oauth2.googleapis.com | Token endpoint (/token) and /tokeninfo. |
YOUTUBE_AUTH_BASE_URL | No | https://accounts.google.com | Authorization endpoint (/o/oauth2/v2/auth). |
YOUTUBE_ANALYTICS_BASE_URL | No | https://youtubeanalytics.googleapis.com | Analytics reports (/v2/reports). |
required_env_alternatives() is not overridden. Google My Business can fall back
to these same credentials; see gmb.md.
rustiz providers --status | jq '.[] | select(.identifier=="youtube")'
Step-by-Step Developer App Setup
1. Create/select a Google Cloud project
- Open https://console.cloud.google.com/projectselector2/apis/credentials and accept the terms.
- Create project (or reuse the one you use for Google My Business — the Postiz docs explicitly say the same project/credentials can serve both).
2. Enable the APIs
Under APIs & Services → Enable APIs and Services, enable:
| API | Why |
|---|---|
| YouTube Data API v3 | channels.list, videos.insert, videos.list, thumbnails.set |
| YouTube Analytics API | v2/reports channel analytics |
| YouTube Reporting API | Optional; bulk reports. Postiz’s guide enables it, the Rust provider does not call it. |
3. Configure the OAuth consent screen
Open Google Auth Platform → Audience (formerly OAuth consent screen):
- User type: External (unless the project belongs to a Google Workspace org and only internal users need it).
- Fill in app name, support email, developer contact, logo, authorized domains.
- Add the scopes the provider requests (see below). Google classifies the
YouTube scopes as sensitive; the broad
youtubeandyoutubepartnerscopes get extra scrutiny. - Under Test users, add every Google account that will connect while the app is in Testing. Add the account that owns the target YouTube channel (or a Brand Account manager).
- Keep the publishing status on Testing for development, or Publish app for production.
4. Create the OAuth client
-
Google Auth Platform → Clients → Create client.
-
Application type: Web application.
-
Name it (e.g.
Rustiz). -
Authorized redirect URIs — add exactly:
{FRONTEND_URL}/integrations/social/youtubeExample:
https://social.example.com/integrations/social/youtube. Google requires scheme/host/port/path to match;http://localhost:5000/...is allowed for local development. No trailing slash unlessFRONTEND_URLitself produced one (it is trimmed). -
Create, then copy the Client ID and Client secret. Google shows the secret only once; download or store it immediately.
5. Set the environment
YOUTUBE_CLIENT_ID="....apps.googleusercontent.com"
YOUTUBE_CLIENT_SECRET="..."
FRONTEND_URL="https://social.example.com"
Restart Rustiz, check rustiz providers --status, then connect and select a
channel.
The same OAuth client can be reused for GMB by adding
{FRONTEND_URL}/integrations/social/gmbto the authorized redirect URIs, or by setting separateGOOGLE_GMB_CLIENT_ID/GOOGLE_GMB_CLIENT_SECRET.
Exact OAuth scopes and checks
#![allow(unused)]
fn main() {
vec![
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/youtube.readonly",
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtubepartner",
"https://www.googleapis.com/auth/yt-analytics.readonly",
]
}
The authorization URL requests them space-separated, with
access_type=offline and prompt=consent (so a refresh token is returned even
on reconnect).
After the code exchange, token_scopes() calls POST {YOUTUBE_OAUTH_BASE_URL}/tokeninfo
with the bearer token and reads either scope (space-separated string) or
scopes (array). check_scopes then requires all eight. A user who
deselects youtube.upload or yt-analytics.readonly on the granular consent
screen produces not_enough_scopes and the channel is refused.
What each scope is used for:
| Scope | Use |
|---|---|
userinfo.profile, userinfo.email | GET /oauth2/v2/userinfo (name, picture, account id) |
youtube | Broad channel management; channels.list, metadata |
youtube.force-ssl | Write operations over HTTPS |
youtube.readonly | channels.list for page selection / reconnect |
youtube.upload | videos.insert resumable upload |
youtubepartner | Content-owner features; not needed for ordinary uploads |
yt-analytics.readonly | Analytics reports |
youtubepartner and the full youtube scope are the hardest to justify in
verification. They are hard-coded in scopes(); trimming them requires a code
change.
Redirect URI details
{FRONTEND_URL}/integrations/social/youtube
Config::frontend_url()removes trailing slashes before concatenation.- No
redirectmeto.com: the literal value is used at authorize time and token exchange. Google requires an exact match, including scheme, host, port and path. - Google allows
http://localhostredirects (andhttp://127.0.0.1) as a development exception; production must be HTTPS.
Sandbox / testing mode
Google has no separate sandbox host; the OAuth consent screen’s Testing publishing status is the sandbox.
- Up to 100 test users may be listed on the consent screen. Being listed consumes the project’s test-user quota once added.
- Every test user sees an “unverified app” warning before consenting.
- Authorizations and refresh tokens expire seven days after consent. This is
the single most important limitation for a scheduler: connect a channel in
Testing mode and it will stop publishing after a week. The exception is an app
that requests only
openid/email/profile— Rustiz requests much more, so the exception does not apply. - A Brand Account can authorize if a listed test user manages it.
- The YouTube APIs themselves work in Testing mode, so you can validate the full upload/thumbnail/analytics flow before verification.
Production app review
- Publish the app (Google Auth Platform → Audience → Publish app). New users can then authorize, but they still see the unverified warning until verification completes.
- OAuth verification is mandatory for sensitive/restricted scopes. Prepare:
- a homepage that explains the app and its YouTube use;
- a privacy policy on the same verified domain;
- a demo video showing the OAuth grant and the upload flow;
- a written justification for every scope, especially
youtubepartnerand the fullyoutubescope.
- YouTube API Services audit / quota extension is separate. The default
quota is:
- 100
videos.insertcalls per day (its own bucket); - 100
search.listcalls per day (unused by this provider); - 10,000 units/day shared by everything else (
channels.list1,videos.list1,thumbnails.set50). To exceed the default, submit the YouTube API Services — Audit and Quota Extension Form.
- 100
- Custom thumbnails require a phone-verified YouTube channel. If the
channel is not verified, the video publishes and only the thumbnail fails
(
PublishedWithError, with the video id/URL preserved).
Connecting a Channel (CLI & MCP)
CLI
# 1. OAuth login. Creates an in-between-steps placeholder channel.
rustiz channels connect youtube --open
# Remote server: poll the login.
rustiz channels connect-status <connectionId>
# 2. The CLI has no channel-selection command; finish with MCP
# channelSetupTool, then list the channel.
rustiz channels list
Do not pass --fields (it switches to the custom-credentials branch) or
--external-url (unsupported).
The CLI’s in-process callback server only runs when FRONTEND_URL is
http://localhost/http://127.0.0.1; otherwise the public rustiz serve
handles the callback.
MCP
Step 1 — start OAuth:
{ "provider": "youtube" }
Returns connectionId + url (or a URL elicitation). A human opens it.
Step 2 — poll:
{ "connectionId": "abc123" }
The channel exists but has inBetweenSteps = true and cannot publish. Its
internal_id at this point is the Google account id from userinfo, not a
YouTube channel id.
Step 3 — list the channels the account can post as:
{
"integrationId": "int_...",
"methodName": "pages",
"dataSchema": []
}
pages calls:
GET /youtube/v3/channels?part=snippet,contentDetails,statistics&mine=true
and returns:
[
{
"id": "UCxxxxxxxxxxxxxxxxxxxxxx",
"name": "My Channel",
"picture": { "data": { "url": "https://yt3.example/avatar.jpg" } },
"username": "@mychannel",
"subscriberCount": "1234"
}
]
pages()logs and returns[]on any API error (including a token missingyoutube.readonly). An empty list is not necessarily “no channels”.
Step 4 — select the channel:
{
"integrationId": "int_...",
"data": { "id": "UCxxxxxxxxxxxxxxxxxxxxxx" }
}
fetch_page_information reads data["id"], calls
channels.list?id=<id> (with part=snippet,contentDetails,statistics), and
returns the channel’s title, custom URL and default thumbnail. channelSetupTool
writes those into the integration, clears inBetweenSteps, and returns
{ "success": true, "name": "My Channel" }.
Connecting the same channel twice fails with
That page is already connected as another channel.
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
pages | none | array of {id, name, picture, username, subscriberCount} | mine=true; silently [] on error. |
Post Settings & Media Validation
settings_schema()
| Field | Type | Required | Behavior |
|---|---|---|---|
title | string (2–100) | Yes | snippet.title. Measured in UTF-16 code units, matching YouTube/JS. No default. |
type | enum public, private, unlisted | Yes | status.privacyStatus. Sent verbatim. |
selfDeclaredMadeForKids | enum yes, no | No | COPPA self-declaration. "yes" → true, anything else → false. Defaults to false when omitted. |
thumbnail | object {id, path, alt?, thumbnail?} | No | Custom thumbnail to set after the upload. id and path are required strings; path may be a local file or URL. alt is validated as a string but never sent. thumbnail is accepted by the schema and ignored. |
tags | array of {value, label} | No | label is what gets sent as snippet.tags; value is validated and ignored. Combined tag length is capped at 500. |
additionalProperties: true. There is no description setting: the post’s
message becomes snippet.description. There is no categoryId,
defaultLanguage, localized, recordingDetails or publishAt — Rustiz’s own
scheduler decides when the API is called.
validate_settings() rules
- Settings must be an object.
titleis required and must be a string; its UTF-16 length must be2..=100. Errors:title must be at least 2 characters long,title must be at most 100 characters long.typemust bepublic,privateorunlisted.selfDeclaredMadeForKids, if present, must be exactlyyesorno.thumbnail, if present and non-null, must be an object with stringidand stringpath;alt, if present, must be a string.tags, if present and non-null, must be an array of objects; each needs a stringvalueand a stringlabel.- Tag length: for every tag, add the label’s UTF-16 length, plus 2 if the
label contains whitespace. If the total exceeds 500, fail with
"The maximum allowed is 500 characters in total for all tags."(This mirrors YouTube’s rule that spaces are encoded as%20.)
check_validity() rules
- Exactly one media item is required:
posts[0].len() == 1, else"You need one media". - The path must contain
mp4(case-sensitive substring check), else"Item must be a video". - Comment posts are not supported (
supports_comments = false), so onlyposts[0]matters. - No duration, codec, resolution, fps or file-size validation. YouTube’s limits (12 h / 128 GB for verified accounts, shorter for unverified ones; 256 KiB chunk alignment) are left to the API.
Media and publish behavior
Start (post_pending)
-
Take the first media path and determine its size:
http(s)URL →HEADwithAccept-Encoding: identity, readContent-Length(must be > 0);- local path →
tokio::fs::metadata.
-
Build the video resource:
{ "snippet": { "title": "...", "description": "...", "tags": ["..."] }, "status": { "privacyStatus": "public", "selfDeclaredMadeForKids": false } } -
POST {YOUTUBE_BASE_URL}/upload/youtube/v3/videos?uploadType=resumable&part=id,snippet,status¬ifySubscribers=truewithContent-Type: application/json; charset=UTF-8,X-Upload-Content-Type: video/mp4andX-Upload-Content-Length: <size>. -
Read the
Locationheader. Missing it →"Could not start the video upload, please try again". -
Return
pendingwith:uploadUri,videoSize,path,uploadedBytes: 0,thumbnail,videoId: null,postDbId.
No bytes are sent in this phase; nothing exists on the channel yet.
Probe (check_post_status)
PUT <uploadUri> with the bearer token and Content-Range: bytes */<size>:
| Response | Meaning |
|---|---|
200 / 201 + JSON {id} | Video created. If no thumbnail was requested → Completed with watch?v=<id>. If a thumbnail is pending → Ready with videoId. |
308 Permanent Redirect | Incomplete. Range: bytes=0-N → uploadedBytes = N + 1; Ready. |
429 / 5xx | Transient; ProviderError::other so the workflow retries the probe, not the publish. |
401 or a body-classified refresh error | RefreshToken. |
404 / 410 | "The upload session expired before the video was uploaded, please post again". |
| other | BadBody with the classified message. |
| network error | Pending (keep polling). |
Finalize (finalize_post)
- If
videoIdis unknown, probe again. - While
uploadedBytes < videoSize:- stop and return
Pendingif this call has spent 4 minutes (the next call resumes from the session offset); - build an 8 MiB body: HTTP URL →
GETwithRange: bytes=start-end(must be206 Partial Content), local file → seek + read that range; PUT <uploadUri>withContent-Type: video/mp4,Content-LengthandContent-Range: bytes start-end/size;308with aRangeheader → advance and continue;308without a range →Pending(never guess the offset);200/201→ parse{id}and stop;429/5xx→ transient error;401→RefreshToken; other →BadBody.
- stop and return
- If all bytes were sent but no id was confirmed →
Pending. - If a thumbnail was requested, call
POST /upload/youtube/v3/thumbnails/set?videoId=<id>&uploadType=mediawith the streamed image body.- If the thumbnail call fails with
BadBodyandpostDbIdis present, returnProviderError::PublishedWithError { release_id: videoId, release_url, ... }— the video is already live and must not be re-uploaded. - Any other error (e.g. token refresh) is returned as-is.
- If the thumbnail call fails with
Completedwithwatch?v=<videoId>.
Blocking post(): calls finalize_post in a loop with a
8 min - 4 min budget and errors with
"The video upload took too long, please try a smaller video" if it cannot
finish. The engine’s durable path can take longer across calls.
Thumbnail rules
settings.thumbnail.pathwins; otherwisemedia.thumbnail(the generated video thumbnail from the engine) is used.- JPEG or PNG; YouTube’s limit is 2 MB. The provider does not validate the size or format before sending.
- The channel must be phone-verified. If not, you get
imageFormatUnsupported/youtube.thumbnail-style errors and the video is still published.
Plugs
Neither run_plug() nor run_post_plug() is overridden. Both use the trait
defaults and return Unsupported for every method. YouTube has no auto-repost or
auto-comment plug in this port.
Implementation Notes & Gotchas
internal_idis a Google account id until setup finishes.AuthTokenDetails.idisuserinfo.id, and the channel name/picture are the Google account’s. A placeholder channel would try to upload to a non-channel and fail. Always runchannelSetupToolwith{"id": "UC..."}.refresh_token()returns the Google account, not the channel. It callsuserinfoand returns the account id/name/picture. The engine only stores the access token and expiry, so the channel’sinternal_id(the YouTube channel id) is preserved; do not use the refresh result’s id as a channel id.pages()hides errors. It logs and returns[], so an expired token or a missingyoutube.readonlylooks exactly like “this account has no channels”. Check server logs before assuming the account has none.- All eight scopes are mandatory. Google’s granular consent lets a user
uncheck individual scopes.
check_scopesthen refuses the connection. There is no partial-scope mode. youtubepartneris a partner-only scope. Requesting it (and the fullyoutubescope) makes OAuth verification harder and may be rejected for an ordinary scheduler. It is hard-coded; if verification is blocked, the provider needs a code change to drop it.- Testing-mode refresh tokens die after 7 days. This is a Google policy, not
a bug in the port. A channel that worked last week and now returns
invalid_grant/RefreshTokenis the classic symptom. Publish the app and complete verification for production use. - No
categoryIdis sent. YouTube usually applies a default category, but if an upload is rejected withinvalidCategoryId, this is the missing field. The same applies todefaultLanguage/localized metadata, which the port does not support. notifySubscribers=trueis hard-coded. Every upload notifies subscribers; there is no setting to suppress it.prompt=consentforces a refresh token on every authorization. This is deliberate and also means a reconnected channel replaces its old refresh token; Google may return the same token or a new one.- Refresh does not rotate blindly.
refresh_token()stores the old refresh token when Google omits a new one, which is the normal case. If the response contains a new one, it is stored. tokeninfois called with POST + bearer, not a query parameter. Google accepts both; the port uses POST. If you proxy Google OAuth, allowPOST /tokeninfo.- Channel identity is re-checked on reconnect.
reconnect()listschannels?part=id&mine=trueand requires the requested channel id to be present; otherwise"Channel not found". It then refetches the channel metadata. This path only runs whenroot_internal_iddiffers frominternal_id, which normal OAuth channels do not set. - Upload sessions are ephemeral. A session that returns
404/410is gone; the provider fails the post with “post again” rather than retrying a dead session. A308without aRangeheader also returnsPendinginstead of guessing, which can loop until the workflow times out — safer than corrupting the video. - 8 MiB chunks are intentional. YouTube requires all chunks except the last to be a multiple of 256 KiB; 8 MiB satisfies that and keeps each batch short.
- Remote media must support HTTP Range.
chunk_streamrequires206 Partial Content. A storage backend/CDN that ignoresRange(returns200with the whole file) fails with"The media storage did not return the requested byte range". - Thumbnail failures preserve the published video.
PublishedWithErrorcarries the video id and URL so the engine records the release even though the thumbnail failed. Do not retry the whole post; set the thumbnail manually in YouTube Studio. - Analytics swallows errors.
analytics()returns[]on any failure (including a missingyt-analytics.readonly).post_analytics()surfacesRefreshTokenbut returns[]for everything else. Empty charts are not proof that the channel has no views. - Analytics metric set is fixed. Channel reports request
views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,likes,subscribersLostwithdimensions=day, sorted by day. The emitted series are Estimated Minutes Watched, Average View Duration, Average View Percentage, Subscribers Gained, Subscribers Lost and Likes —viewsis queried but never emitted (an upstream omission). Post analytics emits Views, Likes, Comments, Favorites fromvideos.list. - Quota is the real concurrency limit.
max_concurrent_job = 200only sizes a semaphore. The defaultvideos.insertbucket is 100/day, and eachthumbnails.setcosts 50 units from the 10,000-unit shared quota. A burst of posts can exhaust the day’s uploads even with the semaphore free. invalid_rapthas a dedicated message.refresh_error_message()detects Google Workspace session-control errors and tells the admin to mark Rustiz as a trusted app / enable “Exempt trusted apps”. Other refresh failures use the generic cause.- The provider never posts comments.
supports_comments = false; YouTube comments and replies are out of scope even thoughyoutube.force-sslwould permit them.
References
- Registering an application / OAuth credentials: https://developers.google.com/youtube/registering_an_application
- Server-side web OAuth flow (offline access,
prompt=consent, redirect rules): https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps - Uploading a video (resumable protocol,
videos.insert): https://developers.google.com/youtube/v3/guides/uploading_a_video - Quota cost table (
videos.insertbucket,thumbnails.set50 units): https://developers.google.com/youtube/v3/determine_quota_cost - API compliance audit / quota extension: https://developers.google.com/youtube/v3/guides/quota_and_compliance_audits
- OAuth app audience and Testing-mode limits (100 users, 7-day expiry): https://support.google.com/cloud/answer/15549945
- Managing OAuth clients and redirect URI rules: https://support.google.com/cloud/answer/15549257
- Postiz YouTube guide: https://docs.postiz.com/self-host/providers/youtube
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview