feat(youtube): subir Shorts al canal con /upload_short

Fase 3, con una corrección sobre lo que decía la §11 de la spec de fase 2.

El bloqueo no es OAuth. Los vídeos subidos por videos.insert desde un
proyecto de API sin auditar quedan restringidos a privado, y el candado es
del proyecto, no del vídeo: no se abre desde Studio, se abre pasando la
auditoría de cumplimiento de Google. Así que esto no publica. Deja el vídeo
en el canal con los metadatos puestos y devuelve el enlace de Studio para
que una persona lo revise y le dé a publicar — la misma forma que /publish
con los borradores de Ghost, y por la misma razón: el informe de fundamento
no sirve de nada si el vídeo ya está subido cuando lo lees.

Comando aparte, no un paso de /generate short_en.

- src/generator/youtube.py: refresco de token contra oauth2.googleapis.com,
  subida resumable en dos pasos y metadatos derivados del shot spec ya
  guardado (título, enlace al artículo, fuentes que el Short cita en
  pantalla, etiquetas del tema). Sin google-api-python-client: es síncrono
  y bloquearía el loop del bot; son dos peticiones HTTP y el repo ya firma
  los JWT de Ghost a mano. aiohttp con SAFE_ACCEPT_ENCODING como todo lo
  demás.
- Scope youtube.upload y nada más: un token filtrado no puede leer ni
  borrar nada del canal, sólo subir.
- forced_private detecta que YouTube devolvió "private" cuando se pidió
  otra cosa, y el aviso lo dice. Es la firma del candado, y tragárselo
  haría creer que salió publicado.
- invalid_grant se traduce a su causa real: la pantalla de consentimiento
  quedó en "Testing" y Google revoca esos tokens a los siete días. Es el
  fallo que menos se adivina y el que más probable es encontrarse.
- get_article_url ahora excluye las filas short_en. Su published_url pasa a
  ser la URL de YouTube, y sin el filtro el siguiente Short de la sesión
  enlazaría al Short anterior: un bucle silencioso, porque la URL es válida
  y nadie la mira dos veces.
- scripts/youtube_oauth.py, sólo stdlib: corre en el portátil, no en el
  contenedor, y no debería exigir instalar nada.
- Las tres claves van optional:true en el Deployment. Sin eso, una clave que
  aún no está en Infisical deja el pod en CreateContainerConfigError y tira
  el bot entero por una función que nadie ha pedido todavía.

30 tests nuevos contra un servidor falso. No hay test en vivo a propósito:
cualquier ejecución real sube un vídeo a un canal de verdad, y eso no es
algo que deba pasar por teclear pytest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-03 14:43:53 +00:00
co-authored by Claude Opus 5
parent 20c8d03aa7
commit ec3e6d05c0
12 changed files with 1308 additions and 5 deletions
+131
View File
@@ -3,9 +3,11 @@ ResearchOwl Telegram Bot
Main user interface — all commands handled here
"""
import asyncio
import json
import os
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo
@@ -154,6 +156,7 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
" Extended: podcast_extended|blog_extended|report_extended\n"
"`/generate short_en` — Short vertical (vídeo) + informe de claims\n"
"`/short_spec` — Último shot spec como fichero JSON\n"
"`/upload_short` — Subir el Short a YouTube (privado, a revisar)\n"
"`/sources` — List all sources found\n"
"`/outputs` — List generated outputs\n"
"`/export` — Exportar último output como PDF\n"
@@ -600,6 +603,133 @@ async def cmd_short_spec(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close()
async def cmd_upload_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""`/upload_short [force]` — sube a YouTube el Short ya renderizado.
Comando aparte, como `/publish` con Ghost, y por la misma razón: el informe
de fundamento no sirve de nada si el vídeo ya está en el canal cuando lo
lees. Sube en privado con los metadatos puestos; publicar sigue siendo un
clic humano en Studio.
"""
if not is_authorized(update.effective_user.id):
return
chat_id = update.effective_chat.id
force = bool(ctx.args) and ctx.args[0].lower() in ("force", "-f", "otra")
db_conn = await get_db()
db = ResearchDB(db_conn)
try:
from src.generator.youtube import (
YouTubeDisabled, YouTubeError, YouTubeNotConfigured,
YouTubeUploader, build_metadata,
)
uploader = YouTubeUploader()
if not uploader.is_configured():
await update.message.reply_text(
"❌ YouTube no configurado. Faltan `YOUTUBE_CLIENT_ID`, "
"`YOUTUBE_CLIENT_SECRET` o `YOUTUBE_REFRESH_TOKEN`.\n"
"Sácalos con `python scripts/youtube_oauth.py` y mételos en "
"Infisical.", parse_mode=ParseMode.MARKDOWN)
return
session = await _session_row(db_conn, chat_id)
if not session:
await update.message.reply_text(
"No hay sesiones. Empieza con /research <tema>")
return
session_id = session["id"]
output = await db.get_latest_output(session_id, OutputType.SHORT_EN)
if not output:
await update.message.reply_text(
"Esta sesión no tiene ningún Short. Genera uno con "
"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
return
video_path = Path(settings.shorts_dir) / f"{session_id}.mp4"
if not video_path.exists():
# El spec sobrevive al purgado; el MP4 no. Es recuperable y barato:
# renderizar de nuevo no vuelve a pagar la generación.
await update.message.reply_text(
f"El spec está guardado pero el vídeo ya no está en disco "
f"(`{video_path.name}`). Vuelve a renderizarlo con "
f"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
return
if output.get("published_url") and not force:
await update.message.reply_text(
f"Este Short ya está subido:\n{output['published_url']}\n\n"
f"Si quieres subirlo otra vez: `/upload_short force`",
parse_mode=ParseMode.MARKDOWN)
return
try:
spec = json.loads(output["content"])
except ValueError:
await update.message.reply_text(
"El spec guardado no es JSON válido; no puedo sacar los "
"metadatos. Míralo con /short_spec.")
return
article_url = await db.get_article_url(session_id)
metadata = build_metadata(spec, session["topic"], article_url)
reporter = ProgressReporter(update.message)
await reporter.start("📤 Subiendo a YouTube…")
try:
video = await uploader.upload(video_path, metadata, reporter.update)
except YouTubeDisabled:
await reporter.done(
"🚫 La subida está desactivada (`YOUTUBE_ENABLED=false`).")
return
except YouTubeNotConfigured as e:
await reporter.done(f"{e}")
return
await db.set_output_url(output["id"], video.watch_url)
await reporter.done("✅ Subido")
await update.message.reply_text(
_upload_message(video, metadata, article_url))
except Exception as e:
logger.error("Upload to YouTube failed", error=str(e), exc_info=True)
await update.message.reply_text(f"❌ Subida fallida: {str(e)[:400]}")
finally:
await db_conn.close()
def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
"""El parte de la subida. Texto plano: lleva el título del modelo, y un
Markdown desbalanceado haría que Telegram rechazara el mensaje que trae el
enlace — justo el que no puede faltar."""
lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}",
f"Enlace del vídeo: {video.watch_url}", ""]
if video.privacy_status == "private":
lines.append(
"🔒 Está PRIVADO. Los vídeos subidos por API desde un proyecto sin "
"auditar se quedan así: el candado es del proyecto, no del vídeo, y "
"no se abre desde Studio. Para levantarlo hay que pasar la auditoría "
"de cumplimiento de Google.")
else:
lines.append(f"👁 Visibilidad: {video.privacy_status}")
if video.forced_private:
lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. "
"Es exactamente la firma de ese candado.")
if video.rejection_reason:
lines.append(f"⚠️ YouTube marcó el vídeo: {video.rejection_reason}")
if not article_url:
lines.append("⚠️ Sin URL de artículo: la descripción va sin enlace. "
"Publica el blog y vuelve a subir con `/upload_short force`.")
tags = (metadata.get("snippet") or {}).get("tags") or []
lines += ["", f"Etiquetas: {', '.join(tags[:8])}"]
return "\n".join(lines)
async def cmd_sources(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update.effective_user.id):
return
@@ -1584,6 +1714,7 @@ def create_bot() -> Application:
app.add_handler(CommandHandler("finish", cmd_finish))
app.add_handler(CommandHandler("generate", cmd_generate))
app.add_handler(CommandHandler("short_spec", cmd_short_spec))
app.add_handler(CommandHandler("upload_short", cmd_upload_short))
app.add_handler(CommandHandler("sources", cmd_sources))
app.add_handler(CommandHandler("outputs", cmd_outputs))
app.add_handler(CommandHandler("news", cmd_news))