diff --git a/.env.example b/.env.example
index fe8d312..8ba7bbd 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md
index 9d3f47a..49cccda 100644
--- a/KNOWN-ISSUES.md
+++ b/KNOWN-ISSUES.md
@@ -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.
diff --git a/README.md b/README.md
index bac29e4..a7e93fa 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
index 7583a4c..cdabde0 100644
--- a/k8s/deployment.yaml
+++ b/k8s/deployment.yaml
@@ -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
diff --git a/scripts/youtube_oauth.py b/scripts/youtube_oauth.py
new file mode 100644
index 0000000..af72d6a
--- /dev/null
+++ b/scripts/youtube_oauth.py
@@ -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 tú.
+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 = """
+
ResearchOwl
+
+{heading}
{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())
diff --git a/src/bot/bot.py b/src/bot/bot.py
index 66905cf..b1dc966 100644
--- a/src/bot/bot.py
+++ b/src/bot/bot.py
@@ -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 ")
+ 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))
diff --git a/src/config.py b/src/config.py
index 27fe613..dc9827a 100644
--- a/src/config.py
+++ b/src/config.py
@@ -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
diff --git a/src/db/database.py b/src/db/database.py
index b0edd7c..05eed1a 100644
--- a/src/db/database.py
+++ b/src/db/database.py
@@ -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
diff --git a/src/generator/youtube.py b/src/generator/youtube.py
new file mode 100644
index 0000000..ba7f487
--- /dev/null
+++ b/src/generator/youtube.py
@@ -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 dé 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))
diff --git a/tests/test_bot_short_report.py b/tests/test_bot_short_report.py
index 4afefa6..87928d4 100644
--- a/tests/test_bot_short_report.py
+++ b/tests/test_bot_short_report.py
@@ -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
diff --git a/tests/test_short_producer.py b/tests/test_short_producer.py
index 173e030..6da1994 100644
--- a/tests/test_short_producer.py
+++ b/tests/test_short_producer.py
@@ -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/"
diff --git a/tests/test_youtube.py b/tests/test_youtube.py
new file mode 100644
index 0000000..d861117
--- /dev/null
+++ b/tests/test_youtube.py
@@ -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"