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"