diff --git a/src/bot/bot.py b/src/bot/bot.py index da5e423..8a91b5a 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -854,7 +854,13 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str: lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}", f"Enlace del vídeo: {video.watch_url}", ""] - if video.privacy_status == "private": + if video.visibility_contradiction: + # Primera línea del mensaje, no una nota al pie: si esto pasa, el vídeo + # ya está en la calle mientras lees el informe de fundamento. + lines.insert(0, "🚨 EL VÍDEO SE VE SIN INICIAR SESIÓN, aunque YouTube " + "dijo que lo subía en privado. Ocúltalo en Studio antes " + "de nada — el enlace de abajo lleva ahí.\n") + elif 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 " @@ -863,6 +869,14 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str: else: lines.append(f"👁 Visibilidad: {video.privacy_status}") + # Lo comprobado, aparte de lo que dijo la API: son dos cosas distintas y el + # 2026-08-12 se demostró que conviene no confundirlas. + if video.reachable is False: + lines.append("✔ Comprobado desde fuera: no se ve sin sesión.") + elif video.reachable is None: + lines.append("⚠️ No se pudo comprobar la visibilidad desde fuera; me " + "queda sólo lo que dijo la API. Míralo en Studio.") + if video.forced_private: lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. " "Es exactamente la firma de ese candado.") diff --git a/src/generator/youtube.py b/src/generator/youtube.py index ba7f487..fbc9029 100644 --- a/src/generator/youtube.py +++ b/src/generator/youtube.py @@ -45,6 +45,15 @@ UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos" #: 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" +#: Con `youtube.upload` no se le puede PREGUNTAR a la API por el estado de un +#: vídeo, así que la visibilidad se comprueba desde fuera y sin credenciales: +#: oEmbed contesta 200 a un vídeo que se ve sin sesión y 401/404 a uno que no. +#: Es la única forma de contrastar lo que dice la respuesta de la subida sin +#: cambiar un token que sólo sabe subir por uno que puede vaciar el canal. +OEMBED_URL = "https://www.youtube.com/oembed" +#: Segunda pasada por si YouTube aún no había indexado el vídeo recién subido. +_VISIBILITY_RECHECK_DELAY = 3.0 + #: 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 @@ -97,6 +106,16 @@ class UploadedVideo: upload_status: str = "" #: Por qué YouTube marcó el vídeo como no reproducible, si lo hizo. rejection_reason: str = "" + #: Si el vídeo se ve sin iniciar sesión, comprobado desde fuera en vez de + #: creerle a la respuesta de la subida. None = no se pudo comprobar. + reachable: Optional[bool] = None + + @property + def visibility_contradiction(self) -> bool: + """La API dice privado y el vídeo se ve. Es el caso que hay que gritar: + todo el flujo de revisión — informe de fundamento primero, publicar + después — descansa en que subir NO publica.""" + return self.reachable is True and self.privacy_status == "private" @property def watch_url(self) -> str: @@ -336,11 +355,62 @@ class YouTubeUploader: rejection_reason=(status.get("rejectionReason") or status.get("failureReason") or ""), ) + await _report(on_progress, "🔎 Comprobando la visibilidad…") + result.reachable = await self.reachable(result.video_id) + logger.info("Short subido a YouTube", video_id=result.video_id, privacy=result.privacy_status, - forced_private=result.forced_private) + forced_private=result.forced_private, + reachable=result.reachable) + if result.visibility_contradiction: + logger.error("El vídeo se ve sin sesión y la API lo dio por privado", + video_id=result.video_id) return result + async def reachable(self, video_id: str) -> Optional[bool]: + """¿Se ve este vídeo sin haber iniciado sesión? + + True = cualquiera con el enlace lo ve. False = no. None = no se pudo + averiguar. + + **La asimetría es deliberada.** Un 200 PRUEBA que el vídeo es accesible; + un 404 no prueba que sea privado, porque también lo devuelve un vídeo + que YouTube todavía no ha terminado de indexar segundos después de + subirlo. Por eso sólo el 200 dispara un aviso, y por eso el negativo se + reintenta una vez antes de darlo por bueno. + + Nunca levanta: esto contrasta un dato, no lo produce. Si la red falla, el + vídeo ya está subido y lo que toca es decir que no se pudo comprobar — + no convertir una comprobación en el motivo de que la subida parezca + haber fallado. + """ + if not video_id: + return None + params = {"url": f"https://www.youtube.com/watch?v={video_id}", + "format": "json"} + seen: Optional[bool] = None + for attempt in (1, 2): + try: + async with self._session(20) as sess: + async with sess.get(OEMBED_URL, params=params) as resp: + status = resp.status + except (aiohttp.ClientError, OSError) as e: + logger.warning("No se pudo comprobar la visibilidad", + video_id=video_id, error=str(e)) + return seen + if status == 200: + return True + if status in (401, 403, 404): + seen = False + else: + logger.warning("oEmbed contestó algo inesperado", + video_id=video_id, status=status) + return seen + if attempt == 1: + import asyncio + await asyncio.sleep(_VISIBILITY_RECHECK_DELAY) + return seen + async def _start(self, token: str, metadata: dict, size: int) -> str: """Paso 1: los metadatos. Devuelve la URL de subida (cabecera Location).""" headers = { diff --git a/tests/test_bot_short_report.py b/tests/test_bot_short_report.py index c68f514..cac0d35 100644 --- a/tests/test_bot_short_report.py +++ b/tests/test_bot_short_report.py @@ -103,6 +103,40 @@ def test_upload_message_warns_when_the_description_has_no_article(): assert "force" in text +def test_upload_message_shouts_when_the_video_is_already_watchable(): + """El caso serio, y va PRIMERO en el mensaje. + + Si subir publica, el informe de fundamento se lee cuando el vídeo ya está en + la calle — el orden entero del flujo deja de significar nada. Enterarse + tiene que costar cero atención: en la primera línea o no sirve. + """ + from src.bot.bot import _upload_message + text = _upload_message(_uploaded(reachable=True), {}, "https://x.test/") + + assert "SE VE SIN INICIAR SESIÓN" in text.split("\n")[0] + assert "studio.youtube.com/video/abc123/edit" in text + # Y no se cuenta a la vez el cuento tranquilizador del candado. + assert "auditoría" not in text + + +def test_upload_message_confirms_a_video_nobody_can_watch(): + from src.bot.bot import _upload_message + text = _upload_message(_uploaded(reachable=False), {}, "https://x.test/") + + assert "no se ve sin sesión" in text + assert "SE VE SIN INICIAR SESIÓN" not in text + + +def test_upload_message_admits_when_it_could_not_check(): + """No haber podido comprobar no es haber comprobado que no. Decir "privado" + a secas aquí sería dar por garantía lo que sólo es la palabra de la API.""" + from src.bot.bot import _upload_message + text = _upload_message(_uploaded(reachable=None), {}, "https://x.test/") + + assert "No se pudo comprobar" in text + assert "Míralo en Studio" 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.""" diff --git a/tests/test_youtube.py b/tests/test_youtube.py index d861117..056e5e9 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -6,6 +6,7 @@ sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear """ import json +import aiohttp import pytest from src.generator import youtube as yt @@ -75,6 +76,9 @@ class FakeSession: def put(self, url, **kw): return self._next("PUT", url) + def get(self, url, **kw): + return self._next("GET", url) + @pytest.fixture(autouse=True) def clean_token_cache(): @@ -83,14 +87,33 @@ def clean_token_cache(): yt._token_cache.clear() +@pytest.fixture(autouse=True) +def no_recheck_delay(monkeypatch): + """La segunda pasada de la comprobación espera 3 s en producción, que es lo + que tarda YouTube en indexar. Aquí no se espera a nada.""" + monkeypatch.setattr(yt, "_VISIBILITY_RECHECK_DELAY", 0) + + @pytest.fixture def uploader(): return YouTubeUploader(client_id="cid", client_secret="secret", refresh_token="refresh") +#: Lo que oEmbed contesta de un vídeo que no se ve sin sesión. +OEMBED_HIDDEN = FakeResp(404, body="Not Found") +#: Y de uno que sí. +OEMBED_VISIBLE = FakeResp(200, {"title": "JAL 1628", "type": "video"}) + + def patch(client, routes): - session = FakeSession(routes) + """El servidor falso, con la comprobación de visibilidad ya enrutada. + + `upload()` la hace siempre, así que todo test que suba pasa por oEmbed. Por + defecto contesta "no se ve", que es lo que se espera de un vídeo privado; el + test que quiera el caso malo pone su propia ruta `/oembed`. + """ + session = FakeSession({"/oembed": OEMBED_HIDDEN, **routes}) client._session = lambda total: session return session @@ -170,8 +193,10 @@ async def test_upload_does_metadata_then_bytes(uploader, video): 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" + # Token, metadatos, bytes, y la comprobación de visibilidad — que se + # reintenta porque el primer 404 puede ser YouTube todavía indexando. + assert [c[0] for c in session.calls] == ["POST", "POST", "PUT", "GET", "GET"] + assert len(seen) == 4, "cada etapa avisa: autenticar, abrir, subir, comprobar" @pytest.mark.asyncio @@ -339,3 +364,84 @@ 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" + + +# --- la visibilidad, comprobada en vez de creída ---------------------------- + +@pytest.mark.asyncio +async def test_a_video_anyone_can_watch_is_detected(uploader, video): + """El caso que existe para pillar: la API dice privado y el vídeo se ve. + + Todo el flujo de revisión — informe de fundamento primero, publicar después + — descansa en que subir NO publique. Si eso deja de ser cierto hay que + enterarse por el parte de la subida, no por una visita al canal. + """ + patch(uploader, { + "/token": TOKEN_OK, + "/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}), + "https://up/x": FakeResp(200, VIDEO_OK), + "/oembed": OEMBED_VISIBLE, + }) + + result = await uploader.upload(video, build_metadata(SPEC, "x")) + + assert result.privacy_status == "private", "la API sigue diciendo privado" + assert result.reachable is True + assert result.visibility_contradiction + + +@pytest.mark.asyncio +async def test_a_private_video_reports_no_contradiction(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")) + + assert result.reachable is False + assert not result.visibility_contradiction + + +@pytest.mark.asyncio +async def test_a_video_visible_only_on_the_second_look_still_counts(uploader): + """Segundos después de subirlo, oEmbed devuelve 404 de un vídeo que sí se + ve: aún no está indexado. Un solo vistazo daría por privado justo el vídeo + que hay que gritar.""" + session = patch(uploader, {"/oembed": [OEMBED_HIDDEN, OEMBED_VISIBLE]}) + + assert await uploader.reachable("abc123") is True + assert len(session.calls) == 2 + + +@pytest.mark.asyncio +async def test_two_hidden_looks_are_enough_to_stop_asking(uploader): + session = patch(uploader, {"/oembed": OEMBED_HIDDEN}) + + assert await uploader.reachable("abc123") is False + assert len(session.calls) == 2 + + +@pytest.mark.asyncio +async def test_a_network_failure_is_not_knowing_rather_than_privacy(uploader): + """No se pudo comprobar NO es lo mismo que no se ve. Devolver False aquí + sería inventarse una garantía a partir de un fallo de red.""" + class Broken: + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + + def get(self, url, **kw): + raise aiohttp.ClientError("sin red") + + uploader._session = lambda total: Broken() + + assert await uploader.reachable("abc123") is None + + +@pytest.mark.asyncio +async def test_an_upload_without_an_id_is_not_checked(uploader): + session = patch(uploader, {"/oembed": OEMBED_VISIBLE}) + + assert await uploader.reachable("") is None + assert session.calls == []