Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifieryoutube
Display nameYouTube
Connection modeoauth, then channelSetupTool (channel selection)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingYes. supports_pending = true; resumable upload session with check_post_status / finalize_post
AnalyticsYes. supports_analytics = true; YouTube Analytics API v2, channel level
Post analyticsYes. supports_post_analytics = true; Data API videos.list statistics
MentionsNo. supports_mentions = false
is_between_stepstrue — the OAuth channel is a placeholder until a YouTube channel is selected
convert_to_jpegfalse
max_concurrent_job200 (a semaphore, not a quota; the real limit is YouTube’s daily upload bucket)
Character limit (max_length)5000 characters (the video description)
APIYouTube Data API v3, YouTube Analytics API v2, Google OAuth 2.0
OAuth scopeshttps://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../youtube, .../youtube.force-ssl, .../youtube.readonly, .../youtube.upload, .../youtubepartner, .../yt-analytics.readonly
Token lifetimeGoogle 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"]
}
VariableRequiredDefaultPurpose
YOUTUBE_CLIENT_IDYesnoneGoogle Cloud OAuth 2.0 Web application client id.
YOUTUBE_CLIENT_SECRETYesnoneGoogle Cloud OAuth client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI. Trailing / stripped.
YOUTUBE_BASE_URLNohttps://www.googleapis.comData API and oauth2/v2/userinfo origin.
YOUTUBE_OAUTH_BASE_URLNohttps://oauth2.googleapis.comToken endpoint (/token) and /tokeninfo.
YOUTUBE_AUTH_BASE_URLNohttps://accounts.google.comAuthorization endpoint (/o/oauth2/v2/auth).
YOUTUBE_ANALYTICS_BASE_URLNohttps://youtubeanalytics.googleapis.comAnalytics 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

  1. Open https://console.cloud.google.com/projectselector2/apis/credentials and accept the terms.
  2. 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:

APIWhy
YouTube Data API v3channels.list, videos.insert, videos.list, thumbnails.set
YouTube Analytics APIv2/reports channel analytics
YouTube Reporting APIOptional; bulk reports. Postiz’s guide enables it, the Rust provider does not call it.

Open Google Auth Platform → Audience (formerly OAuth consent screen):

  1. User type: External (unless the project belongs to a Google Workspace org and only internal users need it).
  2. Fill in app name, support email, developer contact, logo, authorized domains.
  3. Add the scopes the provider requests (see below). Google classifies the YouTube scopes as sensitive; the broad youtube and youtubepartner scopes get extra scrutiny.
  4. 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).
  5. Keep the publishing status on Testing for development, or Publish app for production.

4. Create the OAuth client

  1. Google Auth Platform → Clients → Create client.

  2. Application type: Web application.

  3. Name it (e.g. Rustiz).

  4. Authorized redirect URIs — add exactly:

    {FRONTEND_URL}/integrations/social/youtube
    

    Example: 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 unless FRONTEND_URL itself produced one (it is trimmed).

  5. 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/gmb to the authorized redirect URIs, or by setting separate GOOGLE_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:

ScopeUse
userinfo.profile, userinfo.emailGET /oauth2/v2/userinfo (name, picture, account id)
youtubeBroad channel management; channels.list, metadata
youtube.force-sslWrite operations over HTTPS
youtube.readonlychannels.list for page selection / reconnect
youtube.uploadvideos.insert resumable upload
youtubepartnerContent-owner features; not needed for ordinary uploads
yt-analytics.readonlyAnalytics 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://localhost redirects (and http://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

  1. Publish the app (Google Auth Platform → Audience → Publish app). New users can then authorize, but they still see the unverified warning until verification completes.
  2. 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 youtubepartner and the full youtube scope.
  3. YouTube API Services audit / quota extension is separate. The default quota is:
    • 100 videos.insert calls per day (its own bucket);
    • 100 search.list calls per day (unused by this provider);
    • 10,000 units/day shared by everything else (channels.list 1, videos.list 1, thumbnails.set 50). To exceed the default, submit the YouTube API Services — Audit and Quota Extension Form.
  4. 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 missing youtube.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)

FunctionParametersReturnsNotes
pagesnonearray of {id, name, picture, username, subscriberCount}mine=true; silently [] on error.

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredBehavior
titlestring (2–100)Yessnippet.title. Measured in UTF-16 code units, matching YouTube/JS. No default.
typeenum public, private, unlistedYesstatus.privacyStatus. Sent verbatim.
selfDeclaredMadeForKidsenum yes, noNoCOPPA self-declaration. "yes" → true, anything else → false. Defaults to false when omitted.
thumbnailobject {id, path, alt?, thumbnail?}NoCustom 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.
tagsarray of {value, label}Nolabel 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.
  • title is required and must be a string; its UTF-16 length must be 2..=100. Errors: title must be at least 2 characters long, title must be at most 100 characters long.
  • type must be public, private or unlisted.
  • selfDeclaredMadeForKids, if present, must be exactly yes or no.
  • thumbnail, if present and non-null, must be an object with string id and string path; alt, if present, must be a string.
  • tags, if present and non-null, must be an array of objects; each needs a string value and a string label.
  • 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 only posts[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)

  1. Take the first media path and determine its size:

    • http(s) URL → HEAD with Accept-Encoding: identity, read Content-Length (must be > 0);
    • local path → tokio::fs::metadata.
  2. Build the video resource:

    {
      "snippet": { "title": "...", "description": "...", "tags": ["..."] },
      "status": { "privacyStatus": "public", "selfDeclaredMadeForKids": false }
    }
    
  3. POST {YOUTUBE_BASE_URL}/upload/youtube/v3/videos?uploadType=resumable&part=id,snippet,status&notifySubscribers=true with Content-Type: application/json; charset=UTF-8, X-Upload-Content-Type: video/mp4 and X-Upload-Content-Length: <size>.

  4. Read the Location header. Missing it → "Could not start the video upload, please try again".

  5. Return pending with: 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>:

ResponseMeaning
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 RedirectIncomplete. Range: bytes=0-N → uploadedBytes = N + 1; Ready.
429 / 5xxTransient; ProviderError::other so the workflow retries the probe, not the publish.
401 or a body-classified refresh errorRefreshToken.
404 / 410"The upload session expired before the video was uploaded, please post again".
otherBadBody with the classified message.
network errorPending (keep polling).

Finalize (finalize_post)

  1. If videoId is unknown, probe again.
  2. While uploadedBytes < videoSize:
    • stop and return Pending if this call has spent 4 minutes (the next call resumes from the session offset);
    • build an 8 MiB body: HTTP URL → GET with Range: bytes=start-end (must be 206 Partial Content), local file → seek + read that range;
    • PUT <uploadUri> with Content-Type: video/mp4, Content-Length and Content-Range: bytes start-end/size;
    • 308 with a Range header → advance and continue; 308 without a range → Pending (never guess the offset);
    • 200/201 → parse {id} and stop;
    • 429/5xx → transient error; 401 → RefreshToken; other → BadBody.
  3. If all bytes were sent but no id was confirmed → Pending.
  4. If a thumbnail was requested, call POST /upload/youtube/v3/thumbnails/set?videoId=<id>&uploadType=media with the streamed image body.
    • If the thumbnail call fails with BadBody and postDbId is present, return ProviderError::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.
  5. Completed with watch?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.path wins; otherwise media.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_id is a Google account id until setup finishes. AuthTokenDetails.id is userinfo.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 run channelSetupTool with {"id": "UC..."}.
  • refresh_token() returns the Google account, not the channel. It calls userinfo and returns the account id/name/picture. The engine only stores the access token and expiry, so the channel’s internal_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 missing youtube.readonly looks 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_scopes then refuses the connection. There is no partial-scope mode.
  • youtubepartner is a partner-only scope. Requesting it (and the full youtube scope) 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 / RefreshToken is the classic symptom. Publish the app and complete verification for production use.
  • No categoryId is sent. YouTube usually applies a default category, but if an upload is rejected with invalidCategoryId, this is the missing field. The same applies to defaultLanguage/localized metadata, which the port does not support.
  • notifySubscribers=true is hard-coded. Every upload notifies subscribers; there is no setting to suppress it.
  • prompt=consent forces 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.
  • tokeninfo is called with POST + bearer, not a query parameter. Google accepts both; the port uses POST. If you proxy Google OAuth, allow POST /tokeninfo.
  • Channel identity is re-checked on reconnect. reconnect() lists channels?part=id&mine=true and requires the requested channel id to be present; otherwise "Channel not found". It then refetches the channel metadata. This path only runs when root_internal_id differs from internal_id, which normal OAuth channels do not set.
  • Upload sessions are ephemeral. A session that returns 404/410 is gone; the provider fails the post with “post again” rather than retrying a dead session. A 308 without a Range header also returns Pending instead 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_stream requires 206 Partial Content. A storage backend/CDN that ignores Range (returns 200 with the whole file) fails with "The media storage did not return the requested byte range".
  • Thumbnail failures preserve the published video. PublishedWithError carries 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 missing yt-analytics.readonly). post_analytics() surfaces RefreshToken but 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, subscribersLost with dimensions=day, sorted by day. The emitted series are Estimated Minutes Watched, Average View Duration, Average View Percentage, Subscribers Gained, Subscribers Lost and Likes — views is queried but never emitted (an upstream omission). Post analytics emits Views, Likes, Comments, Favorites from videos.list.
  • Quota is the real concurrency limit. max_concurrent_job = 200 only sizes a semaphore. The default videos.insert bucket is 100/day, and each thumbnails.set costs 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_rapt has 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 though youtube.force-ssl would permit them.

References