Exportar archivos a YouTube desde la línea de comandos
Puedes subir un video terminado desde tu terminal con las bibliotecas Python de código abierto de Google. La CLI siguiente selecciona un archivo local y un JSON de metadatos, te pide que autorices un canal y devuelve el ID del video después de que YouTube acepta la subida. Mantiene los videos privados y desactiva las notificaciones a suscriptores.
Limitaciones importantes de la API de YouTube
Este ejemplo sirve para una subida supervisada desde Linux con Bash y un navegador en la misma máquina. Acepta un MP4 local no vacío de hasta 64 MiB, con nombres de archivo en UTF-8. El archivo debe estar terminado y ser reproducible; el script no valida ni transcodifica su contenido. Tiene un plazo total de cinco minutos, incluidas la autorización y la transferencia, por lo que incluso un archivo inferior a ese límite puede fallar con una conexión lenta.
Una subida aceptada aún requiere procesamiento. La CLI solicita únicamente
youtube.upload,
que permite la inserción. No solicita los permisos más amplios necesarios para consultar videos
privados o eliminarlos mediante la API. Después, verifica el ID devuelto en YouTube Studio del
canal seleccionado y espera a que termine el procesamiento antes de usar el video.
Consulta la asignación actual de subidas de tu proyecto en Google Cloud. YouTube contabiliza las subidas en un cupo de cuota separado. Una pausa entre comandos no repone una asignación diaria. Esta configuración solicita explícitamente visibilidad privada, sin importar los valores predeterminados del proyecto o la cuenta.
Instala las bibliotecas Python
Usa un intérprete CPython 3.14 instalado con venv y pip disponibles.
El intérprete probado es 3.14.8; para una instalación nueva, usa el parche de seguridad actual de
la línea 3.14 con mantenimiento desde las
descargas de Python. Python 3.15 es más reciente, pero el
alcance probado de esta guía es 3.14, que el
cliente de la API de Google indica explícitamente como compatible.
Ejecuta lo siguiente desde el directorio principal donde quieras crear un nuevo directorio
youtube-cli. El subshell mantiene intactos tu directorio actual y las opciones
del shell. Si el directorio ya existe, se rechaza la operación. Si la instalación falla, revisa
el error y el directorio parcial antes de elegir otra ubicación; los comandos no eliminan
archivos existentes.
(
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
)
Estas son versiones de paquetes probadas. El cliente de Google recibe mantenimiento para corregir errores y proporciona la API de YouTube basada en descubrimiento. El pequeño transporte Requests que aparece a continuación hace explícito el comportamiento de reintentos: una inserción fallida nunca se reinicia automáticamente como otra inserción.
Configura OAuth para aplicaciones de escritorio
Usa una cuenta de Google propietaria del canal de YouTube deseado. Sigue la configuración de OAuth para aplicaciones de escritorio de Google:
- Selecciona tu proyecto en Google Cloud Console y habilita YouTube Data API v3.
- Configura la información de consentimiento y el público de la aplicación. Para una aplicación externa en fase de prueba, agrega tu cuenta de Google como usuario de prueba.
- Crea un cliente OAuth con el tipo de aplicación Desktop app.
Descarga su JSON como
youtube-cli/client_secrets.json. Mantenlo fuera del control de versiones. - Busca el ID del canal deseado en la
configuración avanzada de tu cuenta de YouTube.
Usa ese ID
UC…en el comando siguiente. Un identificador como@exampleno es un ID de canal.
El programa usa un puerto de bucle local aleatorio y PKCE. Solicita un nuevo consentimiento en cada ejecución, no almacena una caché de tokens y te pide confirmar el canal antes de insertar nada. Elige el canal deseado durante el flujo de autorización de Google, incluida la cuenta de marca correcta cuando corresponda. Escribir un ID no selecciona un canal: la cuenta autorizada determina el destino. El ID de canal devuelto se verifica después de la inserción; una discrepancia es un fallo, pero puede que ya se haya creado un video privado en el otro canal.
Gestión de metadatos del video
Guarda lo siguiente como youtube-cli/metadata.json y reemplaza el título y la descripción.
Establece madeForKids según tu público real. Esas cuatro claves constituyen toda
la interfaz de metadatos; se rechazan las claves desconocidas. La privacidad y las notificaciones
se fijan en el programa, en lugar de heredarse de un archivo auxiliar o de los valores
predeterminados de una herramienta.
{
"title": "My command-line upload",
"description": "A completed local video.\nUploaded privately for review.",
"tags": ["demo", "command-line"],
"madeForKids": false
}
Guarda y ejecuta el programa de subida
Guarda el programa completo como youtube-cli/upload.py. El programa lee el archivo sin
modificarlo ni modificar el archivo auxiliar de metadatos. Los errores de red posteriores al
inicio de la inserción se tratan como resultados inciertos. Imprime el ID devuelto en stderr
antes de verificar los metadatos de la respuesta, para que puedas investigar una discrepancia.
Evita las grabaciones compartidas de terminal: la URL de autorización y la devolución de llamada
forman parte de un inicio de sesión privado.
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)
Coloca tu archivo terminado en ./video.mp4 y reemplaza
YOUR_CHANNEL_ID por el ID de canal que anotaste. Ejecuta lo siguiente desde el mismo
directorio principal que el comando de instalación:
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
Abre el enlace de autorización impreso en tu navegador local. Completa el consentimiento en tres
minutos y luego escribe el ID del canal seleccionado en el indicador de la terminal. El único
resultado en stdout si la operación tiene éxito es un JSON que contiene un
id nativo, el channelId coincidente y
"status": "accepted_processing_unchecked". Un código de salida distinto de cero, un ID ausente o una
discrepancia en los metadatos constituyen un fallo. Conserva el ID antes de salir de tu terminal;
el programa no escribe ningún comprobante local de subida.
Sube archivos adicionales
Para varios videos, ejecuta el mismo comando una vez por cada archivo explícito y su archivo de
metadatos correspondiente. Mantén las rutas entre comillas, incluidos los espacios y los signos
de puntuación del shell; antepone ./ a los nombres de archivo que
comiencen con guion. El programa no busca archivos mediante patrones glob ni deriva títulos de
sus nombres. Cada llamada requiere un nuevo consentimiento en el navegador y la confirmación
del canal. Detente ante el primer fallo e investiga su resultado antes de iniciar otra inserción.
Integración de subidas automatizadas
Un programa productor puede invocar esta CLI después de cerrar un MP4 terminado, pero la autorización sigue siendo interactiva. No es un programador de tareas desatendido. Conservar tokens de actualización y agregar un registro persistente de tareas e IDs requeriría un diseño independiente. En particular, no incluyas este comando en un bucle de reintentos: perder la respuesta final puede dejar un video en YouTube aunque el comando indique un fallo. Este ejemplo no conserva una URL de sesión reanudable ni la recupera después de que el proceso termina.
Solución de problemas comunes
Límites de transferencia
El límite de 64 MiB corresponde a este ejemplo, no al máximo de la plataforma YouTube. El transporte Requests usa tiempos de espera de conexión de diez segundos y de lectura de 30 segundos para cada solicitud, con una alarma de proceso independiente de cinco minutos que abarca el consentimiento y la transferencia. No tiene limitador de ancho de banda. Si se agota el tiempo de espera después de que comience la inserción, revisa el canal en lugar de considerar ese tiempo de espera como prueba de que no se subió nada.
Problemas de autenticación
Usa credenciales de aplicación de escritorio y mantén el navegador en la máquina que ejecuta el
comando para que su devolución de llamada de bucle local pueda llegar al proceso de escucha. Si
se rechaza el consentimiento, verifica el público de la aplicación, el acceso de usuarios de
prueba y la API habilitada. La CLI deliberadamente no reutiliza una caché de tokens. Se rechaza
cualquier ámbito concedido que no sea exactamente youtube.upload; usa un cliente
de aplicación de escritorio dedicado a este flujo de trabajo limitado, en lugar de ampliar los
permisos solicitados.
Errores de red y de API
La validación local se realiza antes del consentimiento. Verifica primero la ruta del archivo y los tipos de metadatos. Después del mensaje de inicio de inserción, un fallo de red o de API requiere una investigación en YouTube Studio del canal seleccionado. Si se imprimió un ID devuelto, usa ese ID exacto. Un fallo de procesamiento es distinto de la aceptación de la transferencia; la API describe estos estados en el recurso de video. Corrige la causa antes de decidir si envías una nueva subida.
