feat(youtube): subir Shorts al canal con /upload_short
Fase 3, con una corrección sobre lo que decía la §11 de la spec de fase 2. El bloqueo no es OAuth. Los vídeos subidos por videos.insert desde un proyecto de API sin auditar quedan restringidos a privado, y el candado es del proyecto, no del vídeo: no se abre desde Studio, se abre pasando la auditoría de cumplimiento de Google. Así que esto no publica. Deja el vídeo en el canal con los metadatos puestos y devuelve el enlace de Studio para que una persona lo revise y le dé a publicar — la misma forma que /publish con los borradores de Ghost, y por la misma razón: el informe de fundamento no sirve de nada si el vídeo ya está subido cuando lo lees. Comando aparte, no un paso de /generate short_en. - src/generator/youtube.py: refresco de token contra oauth2.googleapis.com, subida resumable en dos pasos y metadatos derivados del shot spec ya guardado (título, enlace al artículo, fuentes que el Short cita en pantalla, etiquetas del tema). Sin google-api-python-client: es síncrono y bloquearía el loop del bot; son dos peticiones HTTP y el repo ya firma los JWT de Ghost a mano. aiohttp con SAFE_ACCEPT_ENCODING como todo lo demás. - Scope youtube.upload y nada más: un token filtrado no puede leer ni borrar nada del canal, sólo subir. - forced_private detecta que YouTube devolvió "private" cuando se pidió otra cosa, y el aviso lo dice. Es la firma del candado, y tragárselo haría creer que salió publicado. - invalid_grant se traduce a su causa real: la pantalla de consentimiento quedó en "Testing" y Google revoca esos tokens a los siete días. Es el fallo que menos se adivina y el que más probable es encontrarse. - get_article_url ahora excluye las filas short_en. Su published_url pasa a ser la URL de YouTube, y sin el filtro el siguiente Short de la sesión enlazaría al Short anterior: un bucle silencioso, porque la URL es válida y nadie la mira dos veces. - scripts/youtube_oauth.py, sólo stdlib: corre en el portátil, no en el contenedor, y no debería exigir instalar nada. - Las tres claves van optional:true en el Deployment. Sin eso, una clave que aún no está en Infisical deja el pod en CreateContainerConfigError y tira el bot entero por una función que nadie ha pedido todavía. 30 tests nuevos contra un servidor falso. No hay test en vivo a propósito: cualquier ejecución real sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear pytest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,19 @@ SHORTSMITH_TIMEOUT=600
|
|||||||
SHORTSMITH_ENABLED=true # false = /generate short_en responde que está apagado
|
SHORTSMITH_ENABLED=true # false = /generate short_en responde que está apagado
|
||||||
SHORTS_DIR=/data/shorts # los MP4 van a disco, nunca a SQLite
|
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
|
# Processing
|
||||||
CHUNK_SIZE=800
|
CHUNK_SIZE=800
|
||||||
CHUNK_OVERLAP=100
|
CHUNK_OVERLAP=100
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
falso negativo cuesta la credibilidad del canal.
|
||||||
|
|
||||||
|
## YouTube por API: dos candados que no avisan al configurarlo
|
||||||
|
|
||||||
|
Ninguno de los dos da un error al montarlo. Los dos se descubren tarde.
|
||||||
|
|
||||||
|
**1. Los vídeos suben restringidos a privado y no se abren desde Studio.** La
|
||||||
|
documentación de [`videos.insert`](https://developers.google.com/youtube/v3/docs/videos/insert)
|
||||||
|
lo dice: *"All videos uploaded via the videos.insert endpoint from unverified API
|
||||||
|
projects created after 28 July 2020 will be restricted to private viewing mode"*.
|
||||||
|
El candado es del **proyecto de API**, no del vídeo, y se levanta pasando la
|
||||||
|
auditoría de cumplimiento de Google — no cambiando la visibilidad a mano. Por eso
|
||||||
|
`YOUTUBE_PRIVACY=public` no publica nada: `UploadedVideo.forced_private` detecta
|
||||||
|
que YouTube devolvió `private` cuando se pidió otra cosa, y el aviso de Telegram
|
||||||
|
lo dice en vez de dejar creer que salió.
|
||||||
|
|
||||||
|
**2. El refresh token caduca a los 7 días si la pantalla de consentimiento sigue
|
||||||
|
en "Testing".** Google revoca los tokens de las apps sin publicar, así que el bot
|
||||||
|
funciona una semana y luego empieza a dar `invalid_grant` sin que nada haya
|
||||||
|
cambiado. La cura es pasar la pantalla a "In production" (con el aviso de "app no
|
||||||
|
verificada" al dar permiso, que para un único usuario da igual) y volver a sacar
|
||||||
|
el token. `_auth_error()` traduce `invalid_grant` a ese texto exacto: es el fallo
|
||||||
|
que más cuesta adivinar y el que más probable es encontrarse.
|
||||||
|
|
||||||
|
Corolario de los dos juntos: `/upload_short` **no publica**, y no es una decisión
|
||||||
|
de diseño que se pueda revertir tocando una env var. Deja el vídeo en el canal
|
||||||
|
con los metadatos puestos y devuelve el enlace de Studio.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ OutputGenerator (Ollama)
|
|||||||
| `/generate podcast\|blog\|report\|thread` | Generate output |
|
| `/generate podcast\|blog\|report\|thread` | Generate output |
|
||||||
| `/generate short_en` | Vertical Short: shot spec → grounding check → MP4 |
|
| `/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 |
|
| `/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 |
|
| `/sources` | List all sources found |
|
||||||
| `/cancel` | Cancel current research |
|
| `/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 …
|
/research JAL 1628 Alaska 1986 …
|
||||||
/generate blog en → Ghost draft, article URL stored on the output row
|
/generate blog en → Ghost draft, article URL stored on the output row
|
||||||
/generate short_en → spec → grounding → render → video + claims report
|
/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
|
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.
|
**prompt leak**, not as an invention — different diagnosis, different fix.
|
||||||
Neither ever blocks the render: both are surfaced next to the video and a
|
Neither ever blocks the render: both are surfaced next to the video and a
|
||||||
human decides.
|
human decides.
|
||||||
- **It becomes a published video.** Nothing is uploaded anywhere. The MP4 lands
|
- **It becomes a published video.** `/generate short_en` uploads nothing: the
|
||||||
in Telegram for review, and in `/data/shorts/{session_id}.mp4`.
|
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
|
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
|
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`.
|
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
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -64,6 +64,28 @@ spec:
|
|||||||
value: "3"
|
value: "3"
|
||||||
- name: QUALITY_THRESHOLD
|
- name: QUALITY_THRESHOLD
|
||||||
value: "0.4"
|
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:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|||||||
@@ -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 = """<!doctype html><meta charset="utf-8">
|
||||||
|
<title>ResearchOwl</title>
|
||||||
|
<body style="font-family:system-ui;max-width:32rem;margin:6rem auto;line-height:1.6">
|
||||||
|
<h1>{heading}</h1><p>{body}</p></body>"""
|
||||||
|
|
||||||
|
|
||||||
|
class _Catcher(http.server.BaseHTTPRequestHandler):
|
||||||
|
"""Recoge el ?code= de la redirección y para."""
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||||
|
_Catcher.result = {k: v[0] for k, v in query.items()}
|
||||||
|
ok = "code" in _Catcher.result
|
||||||
|
page = _PAGE.format(
|
||||||
|
heading="✅ Listo" if ok else "❌ Algo falló",
|
||||||
|
body=("Ya puedes cerrar esta pestaña y volver a la terminal."
|
||||||
|
if ok else
|
||||||
|
f"Google devolvió: {_Catcher.result.get('error', 'sin código')}"))
|
||||||
|
body = page.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass # sin ruido de servidor en la terminal
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _post_form(url: str, data: dict) -> dict:
|
||||||
|
payload = urllib.parse.urlencode(data).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url, data=payload,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode("utf-8", "replace")
|
||||||
|
raise SystemExit(f"\n❌ Google rechazó el intercambio ({e.code}):\n{detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Obtiene el refresh token de YouTube para ResearchOwl.")
|
||||||
|
parser.add_argument("--client-id", default=os.environ.get("YOUTUBE_CLIENT_ID"))
|
||||||
|
parser.add_argument("--client-secret",
|
||||||
|
default=os.environ.get("YOUTUBE_CLIENT_SECRET"))
|
||||||
|
parser.add_argument("--no-browser", action="store_true",
|
||||||
|
help="no abrir el navegador, sólo imprimir la URL")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.client_id or not args.client_secret:
|
||||||
|
parser.error("hacen falta --client-id y --client-secret "
|
||||||
|
"(o YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET en el entorno)")
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
redirect_uri = f"http://localhost:{port}"
|
||||||
|
auth_url = f"{AUTH_URL}?" + urllib.parse.urlencode({
|
||||||
|
"client_id": args.client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": SCOPE,
|
||||||
|
# Los dos juntos, y no por gusto: sin access_type=offline no hay refresh
|
||||||
|
# token, y sin prompt=consent Google deja de mandarlo en cuanto ya diste
|
||||||
|
# permiso una vez — el fallo clásico de "me sale null la segunda vez".
|
||||||
|
"access_type": "offline",
|
||||||
|
"prompt": "consent",
|
||||||
|
})
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", port), _Catcher)
|
||||||
|
waiter = threading.Thread(target=server.handle_request, daemon=True)
|
||||||
|
waiter.start()
|
||||||
|
|
||||||
|
print(f"\nAbre esto y da permiso (verás un aviso de app no verificada; "
|
||||||
|
f"«Advanced» → continuar):\n\n {auth_url}\n")
|
||||||
|
if not args.no_browser:
|
||||||
|
webbrowser.open(auth_url)
|
||||||
|
print("Esperando la redirección… (Ctrl-C para abortar)")
|
||||||
|
|
||||||
|
waiter.join(timeout=300)
|
||||||
|
server.server_close()
|
||||||
|
if waiter.is_alive():
|
||||||
|
print("\n❌ Nadie llegó a la redirección en 5 minutos.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
code = _Catcher.result.get("code")
|
||||||
|
if not code:
|
||||||
|
print(f"\n❌ Sin código. Google dijo: "
|
||||||
|
f"{_Catcher.result.get('error', '(nada)')}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tokens = _post_form(TOKEN_URL, {
|
||||||
|
"code": code,
|
||||||
|
"client_id": args.client_id,
|
||||||
|
"client_secret": args.client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
})
|
||||||
|
|
||||||
|
refresh = tokens.get("refresh_token")
|
||||||
|
if not refresh:
|
||||||
|
print("\n❌ Google no devolvió refresh_token. Suele pasar cuando ya "
|
||||||
|
"habías dado permiso antes: revoca el acceso en "
|
||||||
|
"https://myaccount.google.com/permissions y repite.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n✅ Refresh token:\n")
|
||||||
|
print(f" {refresh}\n")
|
||||||
|
print("Mételo en Infisical (proyecto researchowl) como:\n")
|
||||||
|
print(" youtube-client-id =", args.client_id)
|
||||||
|
print(" youtube-client-secret =", args.client_secret)
|
||||||
|
print(" youtube-refresh-token =", refresh)
|
||||||
|
print("\nRecuerda: si la pantalla de consentimiento sigue en «Testing», "
|
||||||
|
"este token caduca en 7 días.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+131
@@ -3,9 +3,11 @@ ResearchOwl Telegram Bot
|
|||||||
Main user interface — all commands handled here
|
Main user interface — all commands handled here
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from zoneinfo import ZoneInfo
|
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"
|
" Extended: podcast_extended|blog_extended|report_extended\n"
|
||||||
"`/generate short_en` — Short vertical (vídeo) + informe de claims\n"
|
"`/generate short_en` — Short vertical (vídeo) + informe de claims\n"
|
||||||
"`/short_spec` — Último shot spec como fichero JSON\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"
|
"`/sources` — List all sources found\n"
|
||||||
"`/outputs` — List generated outputs\n"
|
"`/outputs` — List generated outputs\n"
|
||||||
"`/export` — Exportar último output como PDF\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()
|
await db_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_upload_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""`/upload_short [force]` — sube a YouTube el Short ya renderizado.
|
||||||
|
|
||||||
|
Comando aparte, como `/publish` con Ghost, y por la misma razón: el informe
|
||||||
|
de fundamento no sirve de nada si el vídeo ya está en el canal cuando lo
|
||||||
|
lees. Sube en privado con los metadatos puestos; publicar sigue siendo un
|
||||||
|
clic humano en Studio.
|
||||||
|
"""
|
||||||
|
if not is_authorized(update.effective_user.id):
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
force = bool(ctx.args) and ctx.args[0].lower() in ("force", "-f", "otra")
|
||||||
|
db_conn = await get_db()
|
||||||
|
db = ResearchDB(db_conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.generator.youtube import (
|
||||||
|
YouTubeDisabled, YouTubeError, YouTubeNotConfigured,
|
||||||
|
YouTubeUploader, build_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
uploader = YouTubeUploader()
|
||||||
|
if not uploader.is_configured():
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ YouTube no configurado. Faltan `YOUTUBE_CLIENT_ID`, "
|
||||||
|
"`YOUTUBE_CLIENT_SECRET` o `YOUTUBE_REFRESH_TOKEN`.\n"
|
||||||
|
"Sácalos con `python scripts/youtube_oauth.py` y mételos en "
|
||||||
|
"Infisical.", parse_mode=ParseMode.MARKDOWN)
|
||||||
|
return
|
||||||
|
|
||||||
|
session = await _session_row(db_conn, chat_id)
|
||||||
|
if not session:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"No hay sesiones. Empieza con /research <tema>")
|
||||||
|
return
|
||||||
|
session_id = session["id"]
|
||||||
|
|
||||||
|
output = await db.get_latest_output(session_id, OutputType.SHORT_EN)
|
||||||
|
if not output:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Esta sesión no tiene ningún Short. Genera uno con "
|
||||||
|
"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
|
||||||
|
return
|
||||||
|
|
||||||
|
video_path = Path(settings.shorts_dir) / f"{session_id}.mp4"
|
||||||
|
if not video_path.exists():
|
||||||
|
# El spec sobrevive al purgado; el MP4 no. Es recuperable y barato:
|
||||||
|
# renderizar de nuevo no vuelve a pagar la generación.
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"El spec está guardado pero el vídeo ya no está en disco "
|
||||||
|
f"(`{video_path.name}`). Vuelve a renderizarlo con "
|
||||||
|
f"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
|
||||||
|
return
|
||||||
|
|
||||||
|
if output.get("published_url") and not force:
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Este Short ya está subido:\n{output['published_url']}\n\n"
|
||||||
|
f"Si quieres subirlo otra vez: `/upload_short force`",
|
||||||
|
parse_mode=ParseMode.MARKDOWN)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = json.loads(output["content"])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"El spec guardado no es JSON válido; no puedo sacar los "
|
||||||
|
"metadatos. Míralo con /short_spec.")
|
||||||
|
return
|
||||||
|
|
||||||
|
article_url = await db.get_article_url(session_id)
|
||||||
|
metadata = build_metadata(spec, session["topic"], article_url)
|
||||||
|
|
||||||
|
reporter = ProgressReporter(update.message)
|
||||||
|
await reporter.start("📤 Subiendo a YouTube…")
|
||||||
|
try:
|
||||||
|
video = await uploader.upload(video_path, metadata, reporter.update)
|
||||||
|
except YouTubeDisabled:
|
||||||
|
await reporter.done(
|
||||||
|
"🚫 La subida está desactivada (`YOUTUBE_ENABLED=false`).")
|
||||||
|
return
|
||||||
|
except YouTubeNotConfigured as e:
|
||||||
|
await reporter.done(f"❌ {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.set_output_url(output["id"], video.watch_url)
|
||||||
|
await reporter.done("✅ Subido")
|
||||||
|
await update.message.reply_text(
|
||||||
|
_upload_message(video, metadata, article_url))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Upload to YouTube failed", error=str(e), exc_info=True)
|
||||||
|
await update.message.reply_text(f"❌ Subida fallida: {str(e)[:400]}")
|
||||||
|
finally:
|
||||||
|
await db_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
|
||||||
|
"""El parte de la subida. Texto plano: lleva el título del modelo, y un
|
||||||
|
Markdown desbalanceado haría que Telegram rechazara el mensaje que trae el
|
||||||
|
enlace — justo el que no puede faltar."""
|
||||||
|
lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}",
|
||||||
|
f"Enlace del vídeo: {video.watch_url}", ""]
|
||||||
|
|
||||||
|
if video.privacy_status == "private":
|
||||||
|
lines.append(
|
||||||
|
"🔒 Está PRIVADO. Los vídeos subidos por API desde un proyecto sin "
|
||||||
|
"auditar se quedan así: el candado es del proyecto, no del vídeo, y "
|
||||||
|
"no se abre desde Studio. Para levantarlo hay que pasar la auditoría "
|
||||||
|
"de cumplimiento de Google.")
|
||||||
|
else:
|
||||||
|
lines.append(f"👁 Visibilidad: {video.privacy_status}")
|
||||||
|
|
||||||
|
if video.forced_private:
|
||||||
|
lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. "
|
||||||
|
"Es exactamente la firma de ese candado.")
|
||||||
|
if video.rejection_reason:
|
||||||
|
lines.append(f"⚠️ YouTube marcó el vídeo: {video.rejection_reason}")
|
||||||
|
if not article_url:
|
||||||
|
lines.append("⚠️ Sin URL de artículo: la descripción va sin enlace. "
|
||||||
|
"Publica el blog y vuelve a subir con `/upload_short force`.")
|
||||||
|
|
||||||
|
tags = (metadata.get("snippet") or {}).get("tags") or []
|
||||||
|
lines += ["", f"Etiquetas: {', '.join(tags[:8])}"]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def cmd_sources(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def cmd_sources(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
if not is_authorized(update.effective_user.id):
|
if not is_authorized(update.effective_user.id):
|
||||||
return
|
return
|
||||||
@@ -1584,6 +1714,7 @@ def create_bot() -> Application:
|
|||||||
app.add_handler(CommandHandler("finish", cmd_finish))
|
app.add_handler(CommandHandler("finish", cmd_finish))
|
||||||
app.add_handler(CommandHandler("generate", cmd_generate))
|
app.add_handler(CommandHandler("generate", cmd_generate))
|
||||||
app.add_handler(CommandHandler("short_spec", cmd_short_spec))
|
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("sources", cmd_sources))
|
||||||
app.add_handler(CommandHandler("outputs", cmd_outputs))
|
app.add_handler(CommandHandler("outputs", cmd_outputs))
|
||||||
app.add_handler(CommandHandler("news", cmd_news))
|
app.add_handler(CommandHandler("news", cmd_news))
|
||||||
|
|||||||
@@ -79,6 +79,22 @@ class Settings(BaseSettings):
|
|||||||
# patológico el WAL — misma razón que source_contents guarda texto y ya).
|
# patológico el WAL — misma razón que source_contents guarda texto y ya).
|
||||||
shorts_dir: str = Field("/data/shorts")
|
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).
|
# SEO autofill — "off" | "on" | "dryrun" (default off).
|
||||||
# off = today's exact behavior (bare draft, no second LLM call).
|
# off = today's exact behavior (bare draft, no second LLM call).
|
||||||
# on = adds best-effort meta/OG/Twitter/tags/internal-links to the DRAFT
|
# on = adds best-effort meta/OG/Twitter/tags/internal-links to the DRAFT
|
||||||
|
|||||||
+9
-2
@@ -448,12 +448,19 @@ class ResearchDB:
|
|||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
async def get_article_url(self, session_id: int) -> Optional[str]:
|
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(
|
cursor = await self.db.execute(
|
||||||
"""SELECT published_url FROM outputs
|
"""SELECT published_url FROM outputs
|
||||||
WHERE session_id = ? AND published_url IS NOT NULL AND published_url != ''
|
WHERE session_id = ? AND published_url IS NOT NULL AND published_url != ''
|
||||||
|
AND output_type NOT IN (?)
|
||||||
ORDER BY created_at DESC LIMIT 1""",
|
ORDER BY created_at DESC LIMIT 1""",
|
||||||
(session_id,)
|
(session_id, OutputType.SHORT_EN.value)
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return row[0] if row else None
|
return row[0] if row else None
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -62,3 +62,50 @@ def test_there_is_a_report_even_when_there_was_no_spec():
|
|||||||
text = _claims_message(ShortResult(topic="Caso X"))
|
text = _claims_message(ShortResult(topic="Caso X"))
|
||||||
assert "Sin comprobación de fundamento" in text
|
assert "Sin comprobación de fundamento" in text
|
||||||
assert "Coste:" 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
|
||||||
|
|||||||
@@ -324,3 +324,40 @@ async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
|
|||||||
assert counts["shorts"] == 1
|
assert counts["shorts"] == 1
|
||||||
assert not (shorts / "1.mp4").exists()
|
assert not (shorts / "1.mp4").exists()
|
||||||
assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo"
|
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/"
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user