Export files to YouTube from the command line
You can upload a completed video from your terminal using Google’s open source Python libraries. The CLI below selects a local file and metadata JSON, asks you to authorize a channel, and returns the video’s ID after YouTube accepts the upload. It keeps videos private and disables subscriber notifications.
Important YouTube API limitations
This example is for an attended upload from Linux with Bash and a browser on the same machine. It accepts a nonempty local MP4 up to 64 MiB, with UTF-8 filenames. The file must already be finished and playable; the script does not validate or transcode its contents. It has a five-minute total deadline, including authorization and transfer, so even a file below that limit can fail on a slow connection.
An accepted upload still needs processing. The CLI requests only
youtube.upload,
which permits insertion. It does not request the broader authority needed to inspect private videos
or delete them through the API. Afterward, check the returned ID in your selected channel’s YouTube
Studio and wait for processing before using the video.
Check your project’s current upload allocation in Google Cloud. YouTube accounts for uploads in a separate quota bucket. A delay between commands does not replenish a daily allowance. This recipe explicitly requests private visibility regardless of project or account defaults.
Install the Python libraries
Use an installed CPython 3.14 interpreter with venv and pip available. The tested interpreter is
3.14.8; use the current security patch of the maintained 3.14 line for a new installation from
Python’s downloads. Python 3.15 is newer, but this walkthrough’s
tested scope is 3.14, which the Google API client
explicitly lists as supported.
Run this from the parent directory where you want a new youtube-cli directory. The subshell keeps
your current directory and shell options intact. An existing directory is refused. If installation
fails, inspect the error and the partial directory before choosing a fresh location; the commands
do not remove existing files.
(
set -eu
command -v python3.14 >/dev/null
mkdir youtube-cli
python3.14 -m venv youtube-cli/.venv
youtube-cli/.venv/bin/python -m pip --isolated install \
google-api-python-client==2.201.0 google-auth-oauthlib==1.5.0 \
google-auth==2.62.1 httplib2==0.32.1 requests==2.34.2
)
These are tested package versions. The Google client is maintained for fixes and provides YouTube’s discovery-based API. The small Requests transport below makes retry behavior explicit: a failed insert is never automatically restarted as another insert.
Set up Desktop OAuth
Use a Google account that owns the intended YouTube channel. Following Google’s Desktop OAuth setup:
- Select your project in the Google Cloud Console and enable the YouTube Data API v3.
- Configure the app’s consent information and audience. For an external app in testing, add your Google account as a test user.
- Create an OAuth client with the Desktop app application type.
Download its JSON as
youtube-cli/client_secrets.json. Keep it out of version control. - Find the intended channel’s ID in your
YouTube advanced account settings.
Use that
UC…ID in the command below. A handle such as@exampleis not a channel ID.
The program uses a random loopback port and PKCE. It requests fresh consent on every run, stores no token cache, and asks you to confirm the channel before inserting anything. Choose the intended channel during Google’s authorization flow, including the correct Brand Account when applicable. Typing an ID does not select a channel: the authorized account determines the destination. The returned channel ID is checked after insertion; a mismatch is a failure but may already have created a private video in the other channel.
Managing video metadata
Save this as youtube-cli/metadata.json, replacing the title and description. Set madeForKids
according to your actual audience. Those four keys are the complete metadata interface; unknown
keys are refused. Privacy and notifications are fixed in the program rather than inherited from a
sidecar or a tool default.
{
"title": "My command-line upload",
"description": "A completed local video.\nUploaded privately for review.",
"tags": ["demo", "command-line"],
"madeForKids": false
}
Save and run the uploader
Save the complete program as youtube-cli/upload.py. It reads the file without changing it or the
metadata sidecar. Network errors after insertion begins are treated as uncertain outcomes. It
prints a returned ID to stderr before checking response metadata, so you can investigate a mismatch.
Avoid shared terminal recordings: the authorization URL and callback are part of a private sign-in.
import argparse
import contextlib
import json
import logging
import os
import re
import signal
import stat
import sys
from urllib.parse import urlsplit
import httplib2
import requests
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseUpload
from requests.adapters import HTTPAdapter
UPLOAD_SCOPE = 'https://www.googleapis.com/auth/youtube.upload'
MAX_BYTES = 64 * 1024 * 1024
class UserError(Exception):
pass
def stop_at_deadline(*_):
raise UserError('The five-minute deadline expired')
class UploadTransport:
def __init__(self, credentials):
self.session = requests.Session()
self.session.trust_env = False
self.session.mount('https://', HTTPAdapter(max_retries=0))
credentials.apply(self.session.headers)
def request(self, uri, method='GET', body=None, headers=None, **_):
target = urlsplit(uri)
if (target.scheme != 'https' or target.hostname not in
('www.googleapis.com', 'youtube.googleapis.com') or
target.username is not None or target.port not in (None, 443)):
raise UserError('Unexpected upload destination')
response = self.session.request(
method, uri, data=body, headers=headers,
timeout=(10, 30), allow_redirects=False)
if 300 <= response.status_code < 400 and response.status_code != 308:
raise UserError('Upload redirect refused')
return (httplib2.Response(dict(response.headers,
status=str(response.status_code))),
response.content)
def close(self):
self.session.close()
class TimedFlow(InstalledAppFlow):
def fetch_token(self, **kwargs):
return super().fetch_token(timeout=(10, 30), **kwargs)
def authorize(config):
installed = config.get('installed', {})
if (installed.get('auth_uri') != 'https://accounts.google.com/o/oauth2/auth' or
installed.get('token_uri') != 'https://oauth2.googleapis.com/token'):
raise UserError('Use the downloaded Desktop client JSON')
flow = TimedFlow.from_client_config(
config, scopes=[UPLOAD_SCOPE], autogenerate_code_verifier=True)
flow.oauth2session.trust_env = False
flow.oauth2session.mount('https://', HTTPAdapter(max_retries=0))
def refuse_token_redirect(url, headers, request_kwargs):
return url, headers, dict(request_kwargs, allow_redirects=False)
flow.oauth2session.register_compliance_hook('access_token_request', refuse_token_redirect)
try:
if flow.oauth2session.token:
raise UserError('Expected a fresh authorization flow')
with contextlib.redirect_stdout(sys.stderr):
credentials = flow.run_local_server(
host='127.0.0.1', port=0, open_browser=False, timeout_seconds=180,
prompt='consent select_account', include_granted_scopes='false',
access_type='online')
with requests.Session() as check:
check.trust_env = False
response = check.get('https://oauth2.googleapis.com/tokeninfo',
params={'access_token': credentials.token},
timeout=(10, 30), allow_redirects=False)
if response.status_code != 200:
raise UserError('Could not check the new grant')
info = response.json()
if (info.get('aud') != installed['client_id'] or
set(info.get('scope', '').split()) != {UPLOAD_SCOPE} or
int(info.get('expires_in', 0)) < 600):
raise UserError('The new grant must be upload-only and unexpired')
return credentials
finally:
flow.oauth2session.close()
def read_metadata(filename):
with open(filename, encoding='utf-8') as source:
metadata = json.load(source)
if not isinstance(metadata, dict) or set(metadata) != {
'title', 'description', 'tags', 'madeForKids'}:
raise UserError('Metadata needs title, description, tags, and madeForKids only')
title, description, tags = (metadata[key] for key in ('title', 'description', 'tags'))
if not isinstance(title, str) or not title.strip() or len(title) > 100:
raise UserError('Title must contain 1–100 characters')
if not isinstance(description, str) or not isinstance(tags, list) or not all(
isinstance(tag, str) for tag in tags):
raise UserError('Description must be text and tags must be a list of strings')
if type(metadata['madeForKids']) is not bool:
raise UserError('madeForKids must be a JSON boolean')
return {'snippet': {'title': title, 'description': description, 'tags': tags},
'status': {'privacyStatus': 'private',
'selfDeclaredMadeForKids': metadata['madeForKids']}}
def main():
parser = argparse.ArgumentParser(description='Upload one private MP4; no automatic retry')
parser.add_argument('--file', required=True)
parser.add_argument('--metadata', required=True)
parser.add_argument('--channel-id', required=True)
parser.add_argument('--client-secrets', required=True,
help='Desktop JSON filename, or - for one private JSON line on stdin')
args = parser.parse_args()
if not re.fullmatch(r'UC[A-Za-z0-9_-]{22}', args.channel_id):
raise UserError('Expected a UC channel ID')
for filename in (args.file, args.metadata, args.client_secrets):
os.fsencode(filename).decode('utf-8', errors='strict')
body = read_metadata(args.metadata)
with open(args.file, 'rb') as media:
details = os.fstat(media.fileno())
if (not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= MAX_BYTES or
not args.file.lower().endswith('.mp4')):
raise UserError('Use a completed, nonempty MP4 of at most 64 MiB')
if args.client_secrets == '-':
config = json.loads(sys.stdin.readline())
else:
with open(args.client_secrets, encoding='utf-8') as source:
config = json.load(source)
credentials = authorize(config)
print(f'Confirm the channel selected in your browser: {args.channel_id}', file=sys.stderr)
print('Type that channel ID to insert the private video:', file=sys.stderr, flush=True)
if sys.stdin.readline().strip() != args.channel_id:
raise UserError('Channel confirmation did not match; no insert attempted')
transport = UploadTransport(credentials)
try:
youtube = build('youtube', 'v3', http=transport,
static_discovery=True, cache_discovery=False)
request = youtube.videos().insert(
part='snippet,status', body=body, notifySubscribers=False,
media_body=MediaIoBaseUpload(media, mimetype='video/mp4',
chunksize=8 * 1024 * 1024, resumable=True))
print('Insert starting; inspect the channel before retrying any failure.',
file=sys.stderr, flush=True)
result = None
while result is None:
_, result = request.next_chunk(num_retries=0)
video_id = result.get('id')
if not isinstance(video_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{11}', video_id):
raise UserError('YouTube did not return a native video ID')
print(f'Returned video ID: {video_id}', file=sys.stderr, flush=True)
if (result.get('snippet', {}).get('channelId') != args.channel_id or
result.get('snippet', {}).get('title') != body['snippet']['title'] or
result.get('status', {}).get('privacyStatus') != 'private'):
raise UserError('Returned metadata did not match; inspect the returned ID')
print(json.dumps({'id': video_id, 'channelId': args.channel_id,
'status': 'accepted_processing_unchecked'}), flush=True)
finally:
transport.close()
if __name__ == '__main__':
logging.disable(logging.CRITICAL)
signal.signal(signal.SIGALRM, stop_at_deadline)
signal.alarm(300)
try:
main()
except (Exception, KeyboardInterrupt) as error:
if isinstance(error, UserError):
print(str(error), file=sys.stderr)
print('Upload not confirmed. If insertion started, the video may exist. '
'Inspect your channel in YouTube Studio before another upload. '
'Check local files, metadata, authorization, and quota.', file=sys.stderr)
sys.exit(1)
finally:
signal.alarm(0)
Place your completed file at ./video.mp4 and replace YOUR_CHANNEL_ID with the channel ID you
recorded. Run this from the same parent directory as the installation command:
youtube-cli/.venv/bin/python youtube-cli/upload.py \
--file './video.mp4' \
--metadata youtube-cli/metadata.json \
--client-secrets youtube-cli/client_secrets.json \
--channel-id YOUR_CHANNEL_ID
Open the printed authorization link in your local browser. Complete consent within three minutes,
then type the selected channel’s ID at the terminal prompt. The only stdout result on success is
JSON containing a native id, the matching channelId, and
"status": "accepted_processing_unchecked". A nonzero exit, missing ID, or metadata mismatch is a
failure. Keep the ID before leaving your terminal; the program writes no local upload receipt.
Upload additional files
For several videos, run the same command once per explicit file and matching metadata file. Keep
paths quoted, including spaces and shell punctuation; prefix a leading-hyphen filename with ./.
The program does no glob discovery and does not derive titles from filenames. Each call requires
fresh browser consent and channel confirmation. Stop at the first failure and investigate its
outcome before starting another insert.
Automated upload integration
A producer can invoke this CLI after closing a completed MP4, but authorization remains interactive. It is not an unattended scheduler. Retaining refresh tokens and adding a durable job/ID ledger would require a separate design. In particular, do not wrap this command in a retry loop: losing the final response can leave a video on YouTube even though the command returns failure. This example neither persists a resumable-session URL nor recovers it after the process exits.
Troubleshooting common issues
Transfer limits
The 64 MiB ceiling is this example’s limit, not YouTube’s platform maximum. The Requests transport uses ten-second connect and 30-second read timeouts for each request, with a separate five-minute process alarm covering consent and transfer. It has no bandwidth limiter. If it times out after insertion starts, inspect the channel rather than treating the timeout as proof that nothing was uploaded.
Authentication issues
Use Desktop credentials and keep the browser on the machine running the command so its loopback
callback can reach the listener. If consent is refused, check the app’s audience, test-user access,
and enabled API. The CLI deliberately does not reuse a token cache. A granted scope other than
exactly youtube.upload is refused; use a dedicated Desktop client for this narrow workflow rather
than expanding the requested permissions.
Network and API errors
Local validation happens before consent. Check the file path and metadata types first. After the insert-starting message, a network or API failure needs investigation in the selected channel’s YouTube Studio. If a returned ID was printed, use that exact ID. Processing failure is separate from transfer acceptance; the API describes these states in the video resource. Correct the cause before deciding whether to submit a new upload.
Rather not run this yourself?
Transloadit encodes video through one API, with no FFmpeg servers to run.
