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
+13
View File
@@ -32,6 +32,19 @@ SHORTSMITH_TIMEOUT=600
SHORTSMITH_ENABLED=true # false = /generate short_en responde que está apagado
SHORTS_DIR=/data/shorts # los MP4 van a disco, nunca a SQLite
# YouTube (/upload_short) — sin credenciales el comando contesta que no está
# configurado y no rompe nada más. Se sacan con scripts/youtube_oauth.py.
YOUTUBE_ENABLED=true
YOUTUBE_CLIENT_ID=
YOUTUBE_CLIENT_SECRET=
YOUTUBE_REFRESH_TOKEN=
# OJO: los vídeos subidos por API desde un proyecto sin auditar quedan privados
# los pidas como los pidas. Poner "public" aquí no publica nada; sólo hace que
# el aviso de Telegram diga que YouTube te forzó la visibilidad.
YOUTUBE_PRIVACY=private
YOUTUBE_CATEGORY_ID=27 # 27 = Education
YOUTUBE_TIMEOUT=300
# Processing
CHUNK_SIZE=800
CHUNK_OVERLAP=100
+26
View File
@@ -125,3 +125,29 @@ prompt, no contra los 126 de la sesión. Es deliberado — la pregunta es "¿lo
sacó de lo que le dimos?" — pero produce falsos positivos cuando el dato existe
en la sesión y no entró en el top-k. Un falso positivo cuesta un vistazo; un
falso negativo cuesta la credibilidad del canal.
## YouTube por API: dos candados que no avisan al configurarlo
Ninguno de los dos da un error al montarlo. Los dos se descubren tarde.
**1. Los vídeos suben restringidos a privado y no se abren desde Studio.** La
documentación de [`videos.insert`](https://developers.google.com/youtube/v3/docs/videos/insert)
lo dice: *"All videos uploaded via the videos.insert endpoint from unverified API
projects created after 28 July 2020 will be restricted to private viewing mode"*.
El candado es del **proyecto de API**, no del vídeo, y se levanta pasando la
auditoría de cumplimiento de Google — no cambiando la visibilidad a mano. Por eso
`YOUTUBE_PRIVACY=public` no publica nada: `UploadedVideo.forced_private` detecta
que YouTube devolvió `private` cuando se pidió otra cosa, y el aviso de Telegram
lo dice en vez de dejar creer que salió.
**2. El refresh token caduca a los 7 días si la pantalla de consentimiento sigue
en "Testing".** Google revoca los tokens de las apps sin publicar, así que el bot
funciona una semana y luego empieza a dar `invalid_grant` sin que nada haya
cambiado. La cura es pasar la pantalla a "In production" (con el aviso de "app no
verificada" al dar permiso, que para un único usuario da igual) y volver a sacar
el token. `_auth_error()` traduce `invalid_grant` a ese texto exacto: es el fallo
que más cuesta adivinar y el que más probable es encontrarse.
Corolario de los dos juntos: `/upload_short` **no publica**, y no es una decisión
de diseño que se pueda revertir tocando una env var. Deja el vídeo en el canal
con los metadatos puestos y devuelve el enlace de Studio.
+41 -3
View File
@@ -41,6 +41,7 @@ OutputGenerator (Ollama)
| `/generate podcast\|blog\|report\|thread` | Generate output |
| `/generate short_en` | Vertical Short: shot spec → grounding check → MP4 |
| `/short_spec` | Last shot spec as a JSON file, to hand-edit and re-render |
| `/upload_short` | Upload the rendered Short to YouTube (private, for review) |
| `/sources` | List all sources found |
| `/cancel` | Cancel current research |
@@ -54,7 +55,8 @@ MP4. The bot sends the video and, in a separate message, a **claims report**.
/research JAL 1628 Alaska 1986 …
/generate blog en → Ghost draft, article URL stored on the output row
/generate short_en → spec → grounding → render → video + claims report
(YouTube upload is deliberately manual)
/upload_short → uploads to YouTube as PRIVATE, with metadata filled
in; publishing stays a human click in Studio
```
Three things make this different from generating text, and each has its own
@@ -74,8 +76,9 @@ mitigation:
**prompt leak**, not as an invention — different diagnosis, different fix.
Neither ever blocks the render: both are surfaced next to the video and a
human decides.
- **It becomes a published video.** Nothing is uploaded anywhere. The MP4 lands
in Telegram for review, and in `/data/shorts/{session_id}.mp4`.
- **It becomes a published video.** `/generate short_en` uploads nothing: the
MP4 lands in Telegram for review and in `/data/shorts/{session_id}.mp4`.
Getting it onto the channel is a separate, explicit `/upload_short`.
Fallbacks hold throughout: if shortsmith is unreachable, the job errors, or the
spec never validates, the spec JSON comes back as a file. The expensive part is
@@ -83,6 +86,41 @@ the generation, not the render.
Full spec of the phase: `docs/shortsmith-phase2-spec.md`.
## YouTube (`/upload_short`)
Uploads `/data/shorts/{session_id}.mp4` to the channel with the title from the
spec, a description carrying the article link and the sources the Short cites on
screen, and tags derived from the topic. The YouTube URL is written back to the
output row, so a second `/upload_short` on the same session refuses unless you
say `/upload_short force`.
**Read this before setting it up.** Videos uploaded through `videos.insert` from
an **unaudited API project** are [restricted to private viewing
mode](https://developers.google.com/youtube/v3/docs/videos/insert). The lock
belongs to the API project, not to the video — you do not unlock it from Studio,
you unlock it by passing Google's compliance audit. So this command does not
publish. It puts the video on the channel with the metadata already filled in
and hands back the Studio link; a person reviews and presses publish. That is
the same shape as `/publish`, which only ever writes Ghost drafts.
One-time setup, in [console.cloud.google.com](https://console.cloud.google.com):
1. Enable **YouTube Data API v3** on a project.
2. OAuth consent screen → External → **publish it to "In production"**. Leaving
it in "Testing" makes Google revoke the refresh token after seven days, and
the bot dies on its own the following Tuesday.
3. Credentials → OAuth client ID → **Desktop app**.
4. `python scripts/youtube_oauth.py --client-id … --client-secret …`, which
opens a browser, catches the redirect on localhost and prints the refresh
token.
5. Put `youtube-client-id`, `youtube-client-secret` and `youtube-refresh-token`
into Infisical (they arrive as `researchowl-secrets-infisical`).
The scope requested is `youtube.upload` only: a leaked token cannot read or
delete anything on the channel — the worst it can do is upload. Quota is not a
concern (1 unit per upload, 100 uploads a day). `YOUTUBE_ENABLED=false` is the
kill switch.
## Local Development
```bash
+22
View File
@@ -64,6 +64,28 @@ spec:
value: "3"
- name: QUALITY_THRESHOLD
value: "0.4"
# YouTube (/upload_short). `optional: true` a propósito: sin las
# claves el pod arranca igual y el comando contesta "no configurado".
# Sin optional, una clave que aún no existe deja el Deployment en
# CreateContainerConfigError y tira el bot entero.
- name: YOUTUBE_CLIENT_ID
valueFrom:
secretKeyRef:
name: researchowl-secrets
key: youtube-client-id
optional: true
- name: YOUTUBE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: researchowl-secrets
key: youtube-client-secret
optional: true
- name: YOUTUBE_REFRESH_TOKEN
valueFrom:
secretKeyRef:
name: researchowl-secrets
key: youtube-refresh-token
optional: true
volumeMounts:
- name: data
mountPath: /data
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Saca el refresh token de YouTube. Se ejecuta UNA vez, en tu máquina.
python scripts/youtube_oauth.py --client-id XXX --client-secret YYY
Abre el navegador, te pide permiso para subir vídeos a tu canal, y escupe el
refresh token para meterlo en Infisical como `youtube-refresh-token`.
Sólo stdlib: esto corre fuera del contenedor, en el portátil de quien lo lance,
y no debería exigir instalar nada.
--------------------------------------------------------------------------
Antes de ejecutarlo, en https://console.cloud.google.com:
1. Crea un proyecto (o usa uno) y habilita **YouTube Data API v3**.
2. Pantalla de consentimiento OAuth External.
3. **Pásala a «In production»**, no la dejes en «Testing». En Testing, Google
revoca los refresh tokens a los SIETE DÍAS y el bot se cae solo el martes que
viene. Al publicarla verás un aviso de "app no verificada" al dar permiso;
con «Advanced continuar» basta, porque el único usuario eres .
4. Credenciales Crear ID de cliente OAuth tipo **Aplicación de escritorio**.
Ese tipo acepta redirecciones a localhost en cualquier puerto, que es lo que
usa este script.
Y lo que conviene saber antes de invertir la tarde: los vídeos subidos por API
desde un proyecto sin auditar quedan **restringidos a privado**, y el candado es
del proyecto, no del vídeo. Se levanta pasando la auditoría de cumplimiento de
Google, no desde Studio. Con esto el bot te deja el vídeo en el canal con los
metadatos puestos; publicarlo sigue siendo un clic tuyo.
"""
import argparse
import http.server
import json
import os
import socket
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
SCOPE = "https://www.googleapis.com/auth/youtube.upload"
_PAGE = """<!doctype html><meta charset="utf-8">
<title>ResearchOwl</title>
<body style="font-family:system-ui;max-width:32rem;margin:6rem auto;line-height:1.6">
<h1>{heading}</h1><p>{body}</p></body>"""
class _Catcher(http.server.BaseHTTPRequestHandler):
"""Recoge el ?code= de la redirección y para."""
result: dict = {}
def do_GET(self):
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
_Catcher.result = {k: v[0] for k, v in query.items()}
ok = "code" in _Catcher.result
page = _PAGE.format(
heading="✅ Listo" if ok else "❌ Algo falló",
body=("Ya puedes cerrar esta pestaña y volver a la terminal."
if ok else
f"Google devolvió: {_Catcher.result.get('error', 'sin código')}"))
body = page.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass # sin ruido de servidor en la terminal
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _post_form(url: str, data: dict) -> dict:
payload = urllib.parse.urlencode(data).encode()
request = urllib.request.Request(
url, data=payload,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(request, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")
raise SystemExit(f"\n❌ Google rechazó el intercambio ({e.code}):\n{detail}")
def main() -> int:
parser = argparse.ArgumentParser(
description="Obtiene el refresh token de YouTube para ResearchOwl.")
parser.add_argument("--client-id", default=os.environ.get("YOUTUBE_CLIENT_ID"))
parser.add_argument("--client-secret",
default=os.environ.get("YOUTUBE_CLIENT_SECRET"))
parser.add_argument("--no-browser", action="store_true",
help="no abrir el navegador, sólo imprimir la URL")
args = parser.parse_args()
if not args.client_id or not args.client_secret:
parser.error("hacen falta --client-id y --client-secret "
"(o YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET en el entorno)")
port = _free_port()
redirect_uri = f"http://localhost:{port}"
auth_url = f"{AUTH_URL}?" + urllib.parse.urlencode({
"client_id": args.client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": SCOPE,
# Los dos juntos, y no por gusto: sin access_type=offline no hay refresh
# token, y sin prompt=consent Google deja de mandarlo en cuanto ya diste
# permiso una vez — el fallo clásico de "me sale null la segunda vez".
"access_type": "offline",
"prompt": "consent",
})
server = http.server.HTTPServer(("127.0.0.1", port), _Catcher)
waiter = threading.Thread(target=server.handle_request, daemon=True)
waiter.start()
print(f"\nAbre esto y da permiso (verás un aviso de app no verificada; "
f"«Advanced» → continuar):\n\n {auth_url}\n")
if not args.no_browser:
webbrowser.open(auth_url)
print("Esperando la redirección… (Ctrl-C para abortar)")
waiter.join(timeout=300)
server.server_close()
if waiter.is_alive():
print("\n❌ Nadie llegó a la redirección en 5 minutos.", file=sys.stderr)
return 1
code = _Catcher.result.get("code")
if not code:
print(f"\n❌ Sin código. Google dijo: "
f"{_Catcher.result.get('error', '(nada)')}", file=sys.stderr)
return 1
tokens = _post_form(TOKEN_URL, {
"code": code,
"client_id": args.client_id,
"client_secret": args.client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
})
refresh = tokens.get("refresh_token")
if not refresh:
print("\n❌ Google no devolvió refresh_token. Suele pasar cuando ya "
"habías dado permiso antes: revoca el acceso en "
"https://myaccount.google.com/permissions y repite.",
file=sys.stderr)
return 1
print("\n✅ Refresh token:\n")
print(f" {refresh}\n")
print("Mételo en Infisical (proyecto researchowl) como:\n")
print(" youtube-client-id =", args.client_id)
print(" youtube-client-secret =", args.client_secret)
print(" youtube-refresh-token =", refresh)
print("\nRecuerda: si la pantalla de consentimiento sigue en «Testing», "
"este token caduca en 7 días.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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))
+16
View File
@@ -79,6 +79,22 @@ class Settings(BaseSettings):
# patológico el WAL — misma razón que source_contents guarda texto y ya).
shorts_dir: str = Field("/data/shorts")
# YouTube (subida de Shorts) — todo opt-in: sin credenciales, /upload_short
# contesta que no está configurado y no rompe nada más.
#
# OJO con youtube_privacy: los vídeos subidos por API desde un proyecto sin
# auditar quedan restringidos a privado por Google, sea cual sea lo que se
# pida aquí. Poner "public" sin haber pasado la auditoría no publica nada;
# sólo hace que el aviso de Telegram diga que YouTube te lo forzó.
youtube_enabled: bool = Field(True)
youtube_client_id: Optional[str] = Field(None)
youtube_client_secret: Optional[str] = Field(None)
#: Se saca una vez con scripts/youtube_oauth.py y vive en Infisical.
youtube_refresh_token: Optional[str] = Field(None)
youtube_privacy: str = Field("private")
youtube_category_id: str = Field("27") # 27 = Education
youtube_timeout: float = Field(300.0)
# SEO autofill — "off" | "on" | "dryrun" (default off).
# off = today's exact behavior (bare draft, no second LLM call).
# on = adds best-effort meta/OG/Twitter/tags/internal-links to the DRAFT
+9 -2
View File
@@ -448,12 +448,19 @@ class ResearchDB:
return dict(row) if row else None
async def get_article_url(self, session_id: int) -> Optional[str]:
"""La URL del artículo publicado más reciente de la sesión, si la hay."""
"""La URL del artículo publicado más reciente de la sesión, si la hay.
Excluye las filas `short_en`: su `published_url` es la del vídeo en
YouTube, no la de un artículo. Sin este filtro, subir un Short y volver
a generar otro haría que el segundo enlazara al primero un bucle
silencioso, porque la URL es válida y nadie la miraría dos veces.
"""
cursor = await self.db.execute(
"""SELECT published_url FROM outputs
WHERE session_id = ? AND published_url IS NOT NULL AND published_url != ''
AND output_type NOT IN (?)
ORDER BY created_at DESC LIMIT 1""",
(session_id,)
(session_id, OutputType.SHORT_EN.value)
)
row = await cursor.fetchone()
return row[0] if row else None
+450
View File
@@ -0,0 +1,450 @@
"""Subida de un Short a YouTube vía Data API v3.
**Lo primero que hay que saber, porque cambia lo que esta pieza puede
prometer:** los vídeos subidos con `videos.insert` desde un proyecto de API sin
auditar (creados después del 28-jul-2020) 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 a publicar. Ese paso humano no es una limitación
que estemos aceptando a regañadientes; es el mismo que defiende `/publish` con
los borradores de Ghost.
Sin `google-api-python-client` a propósito: es síncrono (bloquearía el loop del
bot), arrastra httplib2 y protobuf, y lo que necesitamos son dos peticiones
HTTP. El repo ya firma los JWT de Ghost a mano por la misma razón.
El token de refresco NO se guarda aquí ni en la DB: llega por entorno desde
Infisical. El de acceso vive en memoria y dura una hora.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
import aiohttp
import structlog
from src.config import SAFE_ACCEPT_ENCODING, settings
logger = structlog.get_logger()
__all__ = [
"YouTubeUploader", "UploadedVideo", "build_metadata",
"YouTubeError", "YouTubeNotConfigured", "YouTubeAuthError",
"YouTubeQuotaExceeded", "YouTubeRejected", "YouTubeDisabled",
]
TOKEN_URL = "https://oauth2.googleapis.com/token"
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
#: El único scope que hace falta. `youtube.upload` no puede leer ni borrar nada
#: del canal: si el token se filtra, lo peor que se puede hacer con él es subir.
SCOPE = "https://www.googleapis.com/auth/youtube.upload"
#: Márgen antes de que caduque el token de acceso (dura 3600 s).
_TOKEN_MARGIN = 120.0
#: Tokens de acceso en memoria por client_id. El bot crea un uploader nuevo en
#: cada comando; sin esto, cada subida pagaría un refresco.
_token_cache: dict[str, tuple[str, float]] = {}
# Límites de la API. Pasarse no da un error bonito: da un 400 genérico.
MAX_TITLE = 100
MAX_DESCRIPTION = 5000
MAX_TAGS_CHARS = 460 # el tope real es 500; dejamos aire para las comas
MAX_TAG = 60 # una etiqueta suelta más larga que esto no la busca nadie
class YouTubeError(Exception):
"""Cualquier fallo hablando con YouTube."""
class YouTubeDisabled(YouTubeError):
"""YOUTUBE_ENABLED=false. El interruptor, igual que SHORTSMITH_ENABLED."""
class YouTubeNotConfigured(YouTubeError):
"""Faltan client id / secret / refresh token."""
class YouTubeAuthError(YouTubeError):
"""El refresh token no sirve: caducado, revocado o de otro cliente."""
class YouTubeQuotaExceeded(YouTubeError):
"""Cuota diaria agotada. Se reinicia a medianoche hora del Pacífico."""
class YouTubeRejected(YouTubeError):
"""YouTube rechazó los metadatos o el fichero."""
def __init__(self, message: str, reason: str = ""):
super().__init__(message)
self.reason = reason
@dataclass
class UploadedVideo:
video_id: str
title: str
privacy_status: str
#: True si YouTube ignoró el privacy_status pedido y lo dejó en privado.
#: Es la firma del candado del proyecto sin auditar.
forced_private: bool = False
upload_status: str = ""
#: Por qué YouTube marcó el vídeo como no reproducible, si lo hizo.
rejection_reason: str = ""
@property
def watch_url(self) -> str:
return f"https://youtube.com/shorts/{self.video_id}"
@property
def studio_url(self) -> str:
return f"https://studio.youtube.com/video/{self.video_id}/edit"
# --------------------------------------------------------------------------
# Metadatos
# --------------------------------------------------------------------------
#: Etiquetas de partida del canal. Las del tema se añaden detrás.
BASE_TAGS = ["UAP", "UFO", "declassified", "documentary", "shorts"]
#: Palabras que no aportan nada como etiqueta.
_STOPWORDS = {
"the", "a", "an", "of", "in", "on", "at", "to", "for", "and", "or",
"el", "la", "los", "las", "de", "del", "en", "y", "o", "un", "una",
}
#: De dónde sale una cita de fuente dentro de los props de un shot. Son los
#: campos que el spec usa para atribuir, no para rotular.
_CITATION_KEYS = ("source", "attribution")
def _clean(text: str) -> str:
return re.sub(r"\s+", " ", str(text)).strip()
def _tags_from(topic: str, spec_id: str = "") -> list[str]:
"""Etiquetas del tema, sin repetir las de base y sin pasarse de los 500
caracteres que YouTube cuenta sumando toda la lista.
El tema entero va primero como una sola etiqueta: partido en palabras deja
cosas como "New" y "Mexico" sueltas, que no buscan igual que "Socorro New
Mexico 1964". Las palabras sueltas van detrás igualmente, que cuestan poco.
"""
seen = {t.casefold() for t in BASE_TAGS}
tags = list(BASE_TAGS)
phrase = _clean(topic)[:MAX_TAG]
if phrase and phrase.casefold() not in seen:
seen.add(phrase.casefold())
tags.append(phrase)
words = re.findall(r"[\w'-]+", f"{topic} {spec_id.replace('_', ' ')}")
for word in words:
low = word.casefold()
if low in seen or low in _STOPWORDS or len(word) < 3:
continue
seen.add(low)
tags.append(word)
kept, size = [], 0
for tag in tags:
if size + len(tag) + 1 > MAX_TAGS_CHARS:
break
kept.append(tag)
size += len(tag) + 1
return kept
def _citations(spec: dict) -> list[str]:
"""Las atribuciones que el propio Short enseña en pantalla.
Verbatim, sin tocar mayúsculas: vienen en caja alta del spec y cualquier
intento de suavizarlas convierte FAA en Faa. Van a una descripción que un
humano revisa antes de publicar; que las edite él si quiere.
"""
out: list[str] = []
for shot in spec.get("shots") or []:
props = shot.get("props") or {}
if not isinstance(props, dict):
continue
for key in _CITATION_KEYS:
value = props.get(key)
if isinstance(value, str) and _clean(value):
text = _clean(value).lstrip("—- ")
if text and text not in out:
out.append(text)
return out
def build_metadata(spec: dict, topic: str, article_url: Optional[str] = None,
privacy_status: Optional[str] = None,
category_id: Optional[str] = None) -> dict:
"""El cuerpo de `videos.insert`, construido desde el shot spec ya guardado.
Deliberadamente corto. La descripción no busca posicionar en Shorts eso lo
hace el título sino ahorrarle a quien revisa teclear el enlace al artículo
y las fuentes. Lo que falte se edita en Studio, que es donde va a estar de
todas formas.
"""
meta = spec.get("meta") or {}
title = _clean(meta.get("title") or topic)[:MAX_TITLE]
parts: list[str] = [_clean(topic)]
if article_url:
parts.append(f"Full investigation → {article_url}")
cited = _citations(spec)
if cited:
parts.append("Sources cited in this short:\n"
+ "\n".join(f"{c}" for c in cited))
# #Shorts no es obligatorio (YouTube clasifica solo por formato vertical y
# duración) pero tampoco estorba, y quita la duda cuando el render cambia.
parts.append("#Shorts #UAP #UFO")
description = "\n\n".join(p for p in parts if p)[:MAX_DESCRIPTION]
return {
"snippet": {
"title": title,
"description": description,
"tags": _tags_from(topic, str(meta.get("id") or "")),
"categoryId": str(category_id or settings.youtube_category_id),
"defaultLanguage": "en",
"defaultAudioLanguage": "en",
},
"status": {
"privacyStatus": privacy_status or settings.youtube_privacy,
# Obligatorio declararlo. Sin esto la subida puede quedar en un
# limbo de "falta información" que no se ve desde la API.
"selfDeclaredMadeForKids": False,
"embeddable": True,
},
}
# --------------------------------------------------------------------------
# Cliente
# --------------------------------------------------------------------------
class YouTubeUploader:
def __init__(self, client_id: Optional[str] = None,
client_secret: Optional[str] = None,
refresh_token: Optional[str] = None,
timeout: Optional[float] = None):
self.client_id = client_id or settings.youtube_client_id or ""
self.client_secret = client_secret or settings.youtube_client_secret or ""
self.refresh_token = refresh_token or settings.youtube_refresh_token or ""
self.timeout = timeout or settings.youtube_timeout
def is_configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.refresh_token)
def _session(self, total: float) -> aiohttp.ClientSession:
return aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=total),
# Nunca heredar el default de aiohttp (KNOWN-ISSUES.md, 2026-07-04).
headers={"Accept-Encoding": SAFE_ACCEPT_ENCODING},
)
# --- auth --------------------------------------------------------------
async def access_token(self, force: bool = False) -> str:
"""Un token de acceso vivo, refrescando sólo cuando hace falta."""
if not self.is_configured():
raise YouTubeNotConfigured(
"Faltan YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET / "
"YOUTUBE_REFRESH_TOKEN. Sácalos con scripts/youtube_oauth.py.")
cached = _token_cache.get(self.client_id)
if cached and not force and cached[1] > time.time() + _TOKEN_MARGIN:
return cached[0]
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
"grant_type": "refresh_token",
}
try:
async with self._session(30) as sess:
async with sess.post(TOKEN_URL, data=payload) as resp:
body = await resp.text()
if resp.status != 200:
raise _auth_error(resp.status, body)
data = json.loads(body)
except aiohttp.ClientError as e:
raise YouTubeError(f"no se pudo hablar con el token endpoint: {e}") from e
token = data.get("access_token")
if not token:
raise YouTubeAuthError(f"respuesta de token sin access_token: {body[:200]}")
expiry = time.time() + float(data.get("expires_in", 3600))
_token_cache[self.client_id] = (token, expiry)
logger.info("Token de YouTube refrescado", expires_in=data.get("expires_in"))
return token
# --- subida ------------------------------------------------------------
async def upload(self, video_path: str | Path, metadata: dict,
on_progress: Optional[Callable[[str], Any]] = None
) -> UploadedVideo:
"""Sube el fichero y devuelve el vídeo creado.
Resumable en dos pasos aunque un Short quepa de sobra en una petición:
es el camino documentado para vídeo, separa el rechazo de los metadatos
(falla en el paso 1, barato) del de los bytes, y deja la puerta abierta
a reanudar si algún día los ficheros crecen.
"""
if not settings.youtube_enabled:
raise YouTubeDisabled(
"YOUTUBE_ENABLED=false — la subida está apagada a propósito")
path = Path(video_path)
if not path.exists():
raise YouTubeError(f"no existe el vídeo: {path}")
size = path.stat().st_size
if size == 0:
raise YouTubeError(f"el vídeo está vacío: {path}")
await _report(on_progress, "🔑 Autenticando…")
token = await self.access_token()
await _report(on_progress, "📡 Abriendo sesión de subida…")
location = await self._start(token, metadata, size)
await _report(on_progress, f"⬆️ Subiendo {size / 1_048_576:.1f} MB…")
video = await self._put(location, path, size)
requested = (metadata.get("status") or {}).get("privacyStatus", "private")
status = video.get("status") or {}
actual = status.get("privacyStatus", requested)
result = UploadedVideo(
video_id=video.get("id", ""),
title=((video.get("snippet") or {}).get("title")
or (metadata.get("snippet") or {}).get("title", "")),
privacy_status=actual,
forced_private=(requested != "private" and actual == "private"),
upload_status=status.get("uploadStatus", ""),
rejection_reason=(status.get("rejectionReason")
or status.get("failureReason") or ""),
)
logger.info("Short subido a YouTube", video_id=result.video_id,
privacy=result.privacy_status,
forced_private=result.forced_private)
return result
async def _start(self, token: str, metadata: dict, size: int) -> str:
"""Paso 1: los metadatos. Devuelve la URL de subida (cabecera Location)."""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Length": str(size),
"X-Upload-Content-Type": "video/mp4",
}
params = {"uploadType": "resumable", "part": "snippet,status"}
try:
async with self._session(60) as sess:
async with sess.post(UPLOAD_URL, params=params, headers=headers,
json=metadata) as resp:
if resp.status not in (200, 201):
raise _api_error(resp.status, await resp.text())
location = resp.headers.get("Location")
except aiohttp.ClientError as e:
raise YouTubeError(f"no se pudo abrir la subida: {e}") from e
if not location:
raise YouTubeError(
"YouTube aceptó los metadatos pero no devolvió Location: "
"sin esa URL no hay dónde mandar los bytes")
return location
async def _put(self, location: str, path: Path, size: int) -> dict:
"""Paso 2: los bytes, de una vez. Un Short son pocos MB."""
headers = {"Content-Type": "video/mp4", "Content-Length": str(size)}
try:
with path.open("rb") as handle:
async with self._session(self.timeout) as sess:
async with sess.put(location, data=handle,
headers=headers) as resp:
body = await resp.text()
if resp.status not in (200, 201):
raise _api_error(resp.status, body)
except aiohttp.ClientError as e:
raise YouTubeError(f"la subida se cortó: {e}") from e
try:
return json.loads(body)
except ValueError as e:
raise YouTubeError(
f"YouTube aceptó el fichero pero devolvió algo que no es JSON: "
f"{body[:200]}") from e
# --------------------------------------------------------------------------
def _google_error(body: str) -> tuple[str, str]:
"""(mensaje, reason) del cuerpo de error de Google, que anida el motivo."""
try:
error = (json.loads(body) or {}).get("error") or {}
except ValueError:
return body[:300], ""
if isinstance(error, str): # el token endpoint usa el formato OAuth plano
return error, error
message = error.get("message") or ""
reasons = error.get("errors") or []
reason = reasons[0].get("reason", "") if reasons else ""
return (message or body[:300]), reason
def _auth_error(status: int, body: str) -> YouTubeError:
"""El fallo del refresco, traducido a algo accionable.
`invalid_grant` es casi siempre lo mismo y casi nunca es obvio: la pantalla
de consentimiento se quedó en "Testing", y Google revoca los refresh tokens
de apps sin publicar a los 7 días. Decirlo aquí ahorra la tarde de buscarlo.
"""
message, reason = _google_error(body)
if "invalid_grant" in (message + reason + body).lower():
return YouTubeAuthError(
"El refresh token ya no vale (invalid_grant). La causa habitual es "
"que la pantalla de consentimiento de OAuth siga en «Testing»: "
"Google revoca esos tokens a los 7 días. Pásala a «In production» "
"en la consola de Google Cloud y vuelve a sacar el token con "
"scripts/youtube_oauth.py.")
return YouTubeAuthError(f"refresco rechazado ({status}): {message}")
def _api_error(status: int, body: str) -> YouTubeError:
message, reason = _google_error(body)
if status == 401:
return YouTubeAuthError(f"token no aceptado (401): {message}")
if status == 403 and reason in ("quotaExceeded", "uploadLimitExceeded",
"rateLimitExceeded"):
return YouTubeQuotaExceeded(
f"cuota agotada ({reason}): {message}. Se reinicia a medianoche "
f"hora del Pacífico.")
if status == 403:
return YouTubeRejected(
f"YouTube denegó la subida ({reason or 403}): {message}", reason)
if status == 400:
return YouTubeRejected(f"metadatos rechazados: {message}", reason)
return YouTubeError(f"YouTube devolvió {status}: {message}")
async def _report(callback: Optional[Callable[[str], Any]], text: str) -> None:
if not callback:
return
try:
value = callback(text)
if hasattr(value, "__await__"):
await value
except Exception as e:
logger.warning("Progreso de subida no enviado", error=str(e))
+47
View File
@@ -62,3 +62,50 @@ def test_there_is_a_report_even_when_there_was_no_spec():
text = _claims_message(ShortResult(topic="Caso X"))
assert "Sin comprobación de fundamento" in text
assert "Coste:" in text
# --- el parte de la subida a YouTube ----------------------------------------
def _uploaded(**kw):
from src.generator.youtube import UploadedVideo
base = dict(video_id="abc123", title="X", privacy_status="private")
base.update(kw)
return UploadedVideo(**base)
def test_upload_message_leads_with_the_studio_link():
"""El enlace de Studio es la acción; el de watch es sólo comprobación."""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(), {"snippet": {"tags": ["UAP"]}},
"https://theexclusionzone.com/x/")
assert "https://studio.youtube.com/video/abc123/edit" in text
assert "https://youtube.com/shorts/abc123" in text
def test_upload_message_explains_the_private_lock():
"""Que esté privado no es un fallo del bot, y hay que decir por qué."""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(), {}, "https://x.test/")
assert "PRIVADO" in text
assert "auditoría" in text
def test_upload_message_flags_a_forced_privacy_change():
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(forced_private=True), {}, "https://x.test/")
assert "forzó" in text
def test_upload_message_warns_when_the_description_has_no_article():
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(), {}, None)
assert "Sin URL de artículo" in text
assert "force" in text
def test_upload_message_is_plain_text():
"""Va sin parse_mode: lleva el título del modelo, y un Markdown roto haría
que Telegram rechazara justo el mensaje que trae el enlace."""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(title="JAL 1628: *three* radars_"), {}, None)
assert "*three*" in text and "radars_" in text
+37
View File
@@ -324,3 +324,40 @@ async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
assert counts["shorts"] == 1
assert not (shorts / "1.mp4").exists()
assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo"
@pytest.mark.asyncio
async def test_the_youtube_url_never_passes_for_an_article_url(tmp_path):
"""Subir un Short escribe su URL de YouTube en `published_url`. Si
`get_article_url` no filtrara las filas short_en, el siguiente Short de esa
sesión enlazaría al Short anterior: un bucle silencioso, porque la URL es
válida y nadie la mira dos veces."""
import time
import aiosqlite
from src.db import database
from src.db.database import OutputType, ResearchDB
conn = await aiosqlite.connect(tmp_path / "urls.db")
conn.row_factory = aiosqlite.Row
await conn.executescript(database.SCHEMA)
now = time.time()
await conn.execute(
"INSERT INTO research_sessions (id, topic, status, telegram_chat_id,"
" created_at, updated_at) VALUES (1,'x','saturated',1,?,?)", (now, now))
await conn.execute(
"INSERT INTO outputs (session_id, output_type, content, created_at,"
" published_url) VALUES (1,?,'...',?,?)",
(OutputType.BLOG.value, now, "https://theexclusionzone.com/x/"))
# El Short, subido DESPUÉS: es la fila más reciente con URL.
await conn.execute(
"INSERT INTO outputs (session_id, output_type, content, created_at,"
" published_url) VALUES (1,?,'{}',?,?)",
(OutputType.SHORT_EN.value, now + 60, "https://youtube.com/shorts/abc"))
await conn.commit()
url = await ResearchDB(conn).get_article_url(1)
await conn.close()
assert url == "https://theexclusionzone.com/x/"
+341
View File
@@ -0,0 +1,341 @@
"""Subida a YouTube: auth, los dos pasos del resumable, y los metadatos.
Todo contra un servidor falso. No hay test en vivo: cualquier ejecución real
sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear
`pytest`.
"""
import json
import pytest
from src.generator import youtube as yt
from src.generator.youtube import (
UploadedVideo, YouTubeAuthError, YouTubeDisabled, YouTubeError,
YouTubeNotConfigured, YouTubeQuotaExceeded, YouTubeRejected,
YouTubeUploader, build_metadata,
)
SPEC = {
"meta": {"id": "jal1628", "title": "JAL 1628: Three Radars, One Object"},
"shots": [
{"template": "scale_bars", "props": {
"headline": "REPORTED SCALE",
"attribution": "— CAPT. TERAUCHI, ESTIMATE"}},
{"template": "document_quote", "props": {
"source": "FAA · 5 MARCH 1987", "quote_a": "“SPLIT RADAR IMAGE”"}},
{"template": "counter_close", "props": {"count_to": 1500}},
],
}
class FakeResp:
def __init__(self, status, payload=None, body=None, headers=None):
self.status = status
self.headers = headers or {}
if body is None:
body = json.dumps(payload) if payload is not None else ""
self._body = body
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def text(self):
return self._body
async def json(self):
return json.loads(self._body)
class FakeSession:
def __init__(self, routes):
self.routes = routes
self.calls = []
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def _next(self, method, url):
self.calls.append((method, url))
for pattern, responses in self.routes.items():
if pattern in url:
if isinstance(responses, list):
return responses.pop(0) if len(responses) > 1 else responses[0]
return responses
raise AssertionError(f"ruta no simulada: {method} {url}")
def post(self, url, **kw):
return self._next("POST", url)
def put(self, url, **kw):
return self._next("PUT", url)
@pytest.fixture(autouse=True)
def clean_token_cache():
yt._token_cache.clear()
yield
yt._token_cache.clear()
@pytest.fixture
def uploader():
return YouTubeUploader(client_id="cid", client_secret="secret",
refresh_token="refresh")
def patch(client, routes):
session = FakeSession(routes)
client._session = lambda total: session
return session
@pytest.fixture
def video(tmp_path):
path = tmp_path / "166.mp4"
path.write_bytes(b"\x00\x00\x00 ftypisom" + b"\x00" * 4096)
return path
TOKEN_OK = FakeResp(200, {"access_token": "at-1", "expires_in": 3600})
VIDEO_OK = {"id": "abc123", "snippet": {"title": "JAL 1628"},
"status": {"privacyStatus": "private", "uploadStatus": "uploaded"}}
# --- auth ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_access_token_is_cached_across_instances(uploader):
session = patch(uploader, {"/token": TOKEN_OK})
assert await uploader.access_token() == "at-1"
# Otro uploader, mismo client_id: el bot construye uno nuevo por comando y
# no debe pagar un refresco cada vez.
twin = YouTubeUploader(client_id="cid", client_secret="s", refresh_token="r")
patch(twin, {}) # sin rutas: si intentara pedirlo, reventaría
assert await twin.access_token() == "at-1"
assert len(session.calls) == 1
@pytest.mark.asyncio
async def test_expired_token_is_refreshed(uploader):
patch(uploader, {"/token": [
FakeResp(200, {"access_token": "at-1", "expires_in": 0}),
FakeResp(200, {"access_token": "at-2", "expires_in": 3600}),
]})
assert await uploader.access_token() == "at-1"
assert await uploader.access_token() == "at-2"
@pytest.mark.asyncio
async def test_invalid_grant_points_at_the_testing_screen(uploader):
"""El fallo que se va a encontrar de verdad, y el que menos se adivina."""
patch(uploader, {"/token": FakeResp(
400, body=json.dumps({"error": "invalid_grant",
"error_description": "Token has been expired or revoked."}))})
with pytest.raises(YouTubeAuthError) as exc:
await uploader.access_token()
message = str(exc.value)
assert "Testing" in message and "7 días" in message
@pytest.mark.asyncio
async def test_unconfigured_uploader_says_so():
bare = YouTubeUploader(client_id="", client_secret="", refresh_token="")
assert not bare.is_configured()
with pytest.raises(YouTubeNotConfigured):
await bare.access_token()
# --- subida ----------------------------------------------------------------
@pytest.mark.asyncio
async def test_upload_does_metadata_then_bytes(uploader, video):
session = patch(uploader, {
"/token": TOKEN_OK,
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/xyz"}),
"https://up/xyz": FakeResp(200, VIDEO_OK),
})
seen = []
result = await uploader.upload(video, build_metadata(SPEC, "JAL 1628"),
on_progress=lambda t: seen.append(t))
assert result.video_id == "abc123"
assert result.watch_url == "https://youtube.com/shorts/abc123"
assert result.studio_url.endswith("/abc123/edit")
assert [c[0] for c in session.calls] == ["POST", "POST", "PUT"]
assert len(seen) == 3, "cada etapa avisa: autenticar, abrir, subir"
@pytest.mark.asyncio
async def test_missing_location_header_is_fatal(uploader, video):
"""Sin Location no hay dónde mandar los bytes. Falla claro en vez de
intentar un PUT contra la nada."""
patch(uploader, {"/token": TOKEN_OK,
"/upload/youtube": FakeResp(200, {})})
with pytest.raises(YouTubeError, match="Location"):
await uploader.upload(video, build_metadata(SPEC, "x"))
@pytest.mark.asyncio
async def test_forced_private_is_detected(uploader, video):
"""Se pide público, YouTube devuelve privado: la firma del candado del
proyecto sin auditar. Hay que verlo, no tragárselo."""
patch(uploader, {
"/token": TOKEN_OK,
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}),
"https://up/x": FakeResp(200, VIDEO_OK),
})
meta = build_metadata(SPEC, "x", privacy_status="public")
result = await uploader.upload(video, meta)
assert result.privacy_status == "private"
assert result.forced_private
@pytest.mark.asyncio
async def test_private_request_is_not_reported_as_forced(uploader, video):
patch(uploader, {
"/token": TOKEN_OK,
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}),
"https://up/x": FakeResp(200, VIDEO_OK),
})
result = await uploader.upload(video, build_metadata(SPEC, "x",
privacy_status="private"))
assert not result.forced_private
@pytest.mark.asyncio
async def test_quota_exceeded_is_its_own_error(uploader, video):
patch(uploader, {"/token": TOKEN_OK, "/upload/youtube": FakeResp(403, {
"error": {"code": 403, "message": "The request cannot be completed.",
"errors": [{"reason": "quotaExceeded"}]}})})
with pytest.raises(YouTubeQuotaExceeded, match="Pacífico"):
await uploader.upload(video, build_metadata(SPEC, "x"))
@pytest.mark.asyncio
async def test_bad_metadata_is_rejected_with_the_reason(uploader, video):
patch(uploader, {"/token": TOKEN_OK, "/upload/youtube": FakeResp(400, {
"error": {"code": 400, "message": "Invalid video title.",
"errors": [{"reason": "invalidTitle"}]}})})
with pytest.raises(YouTubeRejected) as exc:
await uploader.upload(video, build_metadata(SPEC, "x"))
assert exc.value.reason == "invalidTitle"
assert "Invalid video title" in str(exc.value)
@pytest.mark.asyncio
async def test_401_on_upload_is_an_auth_error(uploader, video):
patch(uploader, {"/token": TOKEN_OK, "/upload/youtube": FakeResp(401, {
"error": {"code": 401, "message": "Invalid Credentials"}})})
with pytest.raises(YouTubeAuthError):
await uploader.upload(video, build_metadata(SPEC, "x"))
@pytest.mark.asyncio
async def test_missing_and_empty_files_never_reach_the_network(uploader, tmp_path):
patch(uploader, {}) # cualquier petición reventaría
with pytest.raises(YouTubeError, match="no existe"):
await uploader.upload(tmp_path / "nope.mp4", {})
empty = tmp_path / "empty.mp4"
empty.write_bytes(b"")
with pytest.raises(YouTubeError, match="vacío"):
await uploader.upload(empty, {})
@pytest.mark.asyncio
async def test_kill_switch(uploader, video, monkeypatch):
monkeypatch.setattr(yt.settings, "youtube_enabled", False)
patch(uploader, {})
with pytest.raises(YouTubeDisabled):
await uploader.upload(video, {})
# --- metadatos -------------------------------------------------------------
def test_metadata_carries_the_article_link_and_the_citations():
meta = build_metadata(SPEC, "JAL 1628 Alaska sighting",
article_url="https://theexclusionzone.com/jal-1628/")
description = meta["snippet"]["description"]
assert "https://theexclusionzone.com/jal-1628/" in description
# Verbatim: suavizar mayúsculas convierte FAA en Faa.
assert "FAA · 5 MARCH 1987" in description
assert "CAPT. TERAUCHI, ESTIMATE" in description
# El guión de la atribución no se duplica con el de la lista.
assert "— — " not in description
def test_metadata_without_article_url_still_builds():
description = build_metadata(SPEC, "JAL 1628")["snippet"]["description"]
assert "Full investigation" not in description
assert "JAL 1628" in description
def test_title_comes_from_the_spec_and_is_truncated():
long_spec = {"meta": {"title": "A" * 200}, "shots": []}
assert len(build_metadata(long_spec, "x")["snippet"]["title"]) == 100
def test_title_falls_back_to_the_topic():
assert build_metadata({"shots": []}, "Socorro 1964")["snippet"]["title"] \
== "Socorro 1964"
def test_tags_drop_stopwords_and_duplicates_and_respect_the_limit():
tags = build_metadata(SPEC, "The Landing of the UFO in Socorro New Mexico"
)["snippet"]["tags"]
lowered = [t.casefold() for t in tags]
assert "the" not in lowered and "of" not in lowered and "in" not in lowered
assert len(lowered) == len(set(lowered))
assert "socorro" in lowered
assert sum(len(t) + 1 for t in tags) <= yt.MAX_TAGS_CHARS
def test_the_whole_topic_is_one_tag():
"""Partido en palabras deja "New" y "Mexico" sueltas, que no buscan igual."""
tags = build_metadata(SPEC, "Socorro New Mexico 1964")["snippet"]["tags"]
assert "Socorro New Mexico 1964" in tags
assert "Socorro" in tags, "las sueltas también, que cuestan poco"
def test_tags_stay_under_the_limit_with_an_absurd_topic():
tags = build_metadata(SPEC, " ".join(f"palabra{i}" for i in range(200))
)["snippet"]["tags"]
assert sum(len(t) + 1 for t in tags) <= yt.MAX_TAGS_CHARS
def test_made_for_kids_is_declared():
"""Sin declararlo la subida puede quedar en un limbo que no se ve por API."""
assert build_metadata(SPEC, "x")["status"]["selfDeclaredMadeForKids"] is False
def test_privacy_defaults_to_private():
assert build_metadata(SPEC, "x")["status"]["privacyStatus"] == "private"
def test_description_is_capped():
spec = {"meta": {"title": "t"},
"shots": [{"props": {"source": "S" * 400}} for _ in range(40)]}
description = build_metadata(spec, "x")["snippet"]["description"]
assert len(description) <= yt.MAX_DESCRIPTION
def test_uploaded_video_urls():
video = UploadedVideo(video_id="xyz", title="t", privacy_status="private")
assert video.watch_url == "https://youtube.com/shorts/xyz"
assert video.studio_url == "https://studio.youtube.com/video/xyz/edit"