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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user