fix(youtube): a refused YouTube action answers a 422 that says what to do
The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".
One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.
"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
This commit is contained in:
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
M = TypeVar("M", bound=Base)
|
M = TypeVar("M", bound=Base)
|
||||||
|
|
||||||
@@ -51,3 +52,12 @@ def owned_or_404(
|
|||||||
if getattr(obj, owner_attr) != user.id:
|
if getattr(obj, owner_attr) != user.id:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def youtube_refusal(exc: YouTubeError) -> HTTPException:
|
||||||
|
"""The answer to an action YouTube refused: a 422 whose reason the UI translates
|
||||||
|
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
|
||||||
|
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
|
||||||
|
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
|
||||||
|
happened (`youtube.api.error`)."""
|
||||||
|
return HTTPException(status_code=422, detail={"code": "youtube", "reason": refusal_reason(exc)})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
Video,
|
Video,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||||
from app.sync.explore import explore_ingest_page
|
from app.sync.explore import explore_ingest_page
|
||||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
@@ -424,7 +424,7 @@ def explore_channel(
|
|||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
result = explore_ingest_page(db, yt, channel, page_token)
|
result = explore_ingest_page(db, yt, channel, page_token)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't load this channel: {exc}")
|
raise youtube_refusal(exc)
|
||||||
return {
|
return {
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"ingested": result["ingested"],
|
"ingested": result["ingested"],
|
||||||
@@ -583,9 +583,7 @@ def subscribe(
|
|||||||
if "subscriptionDuplicate" in exc.reason.split(","):
|
if "subscriptionDuplicate" in exc.reason.split(","):
|
||||||
yt_sub_id = ""
|
yt_sub_id = ""
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise youtube_refusal(exc)
|
||||||
status_code=422, detail=f"YouTube couldn't subscribe you: {exc}"
|
|
||||||
)
|
|
||||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
||||||
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
||||||
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
||||||
@@ -649,7 +647,7 @@ def unsubscribe(
|
|||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||||
# a transient "connection lost" gateway blip.
|
# a transient "connection lost" gateway blip.
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't unsubscribe you: {exc}")
|
raise youtube_refusal(exc)
|
||||||
db.delete(sub)
|
db.delete(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unsubscribed": channel_id}
|
return {"unsubscribed": channel_id}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.routes._common import get_or_404
|
from app.routes._common import get_or_404, youtube_refusal
|
||||||
from app.sync.videos import parse_iso8601_duration
|
from app.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
@@ -411,7 +411,7 @@ def get_video_detail(
|
|||||||
):
|
):
|
||||||
items = yt.get_videos([video_id])
|
items = yt.get_videos([video_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
raise youtube_refusal(exc)
|
||||||
if not items:
|
if not items:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from app.sync.playlists import (
|
|||||||
sync_user_playlists,
|
sync_user_playlists,
|
||||||
)
|
)
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.routes._common import get_or_404, owned_or_404
|
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
@@ -322,8 +322,12 @@ def sync_youtube(
|
|||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
raise _refused(403, "youtube_read_scope")
|
raise _refused(403, "youtube_read_scope")
|
||||||
_check_pull_cooldown(user)
|
_check_pull_cooldown(user)
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
try:
|
||||||
return sync_user_playlists(db, user)
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
|
return sync_user_playlists(db, user)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/revert-youtube")
|
@router.post("/{playlist_id}/revert-youtube")
|
||||||
@@ -344,9 +348,9 @@ def revert_youtube(
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
return repull_playlist(db, user, pl)
|
return repull_playlist(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
@@ -371,8 +375,8 @@ def push_plan(
|
|||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_merge(db, user, pl)
|
plan = plan_merge(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
plan["remaining_today"] = quota.remaining_today(db)
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||||
return plan
|
return plan
|
||||||
@@ -404,9 +408,9 @@ def push(
|
|||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
raise _refused(429, "quota_exceeded")
|
raise _refused(429, "quota_exceeded")
|
||||||
return merge_playlist(db, user, pl, live=live)
|
return merge_playlist(db, user, pl, live=live)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
@@ -496,8 +500,8 @@ def delete_playlist(
|
|||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
yt.delete_playlist(pl.yt_playlist_id)
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
except YouTubeError:
|
except YouTubeError as exc:
|
||||||
raise _refused(422, "youtube_failed")
|
raise youtube_refusal(exc)
|
||||||
db.delete(pl) # items cascade
|
db.delete(pl) # items cascade
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ from app.audit import AuditAction
|
|||||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
from app.routes._common import youtube_refusal
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.scheduler import running_job_ids
|
from app.scheduler import running_job_ids
|
||||||
|
from app.youtube.client import YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||||
|
|
||||||
@@ -32,8 +34,14 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
|||||||
)
|
)
|
||||||
if not _sync_limiter.allow(str(user.id)):
|
if not _sync_limiter.allow(str(user.id)):
|
||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
try:
|
||||||
result = import_subscriptions(db, user)
|
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||||
|
result = import_subscriptions(db, user)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
|
||||||
|
# blocking "Server error" dialog, with nothing the user could act on (E9 F1).
|
||||||
|
db.rollback()
|
||||||
|
raise youtube_refusal(exc)
|
||||||
result["quota_used_estimate"] = m.used
|
result["quota_used_estimate"] = m.used
|
||||||
result["quota_remaining_today"] = m.remaining
|
result["quota_remaining_today"] = m.remaining
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ uses OAuth.
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -108,6 +109,22 @@ def is_quota_refusal(exc: YouTubeError) -> bool:
|
|||||||
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
|
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
|
||||||
|
|
||||||
|
|
||||||
|
# What a refused YouTube action means to the person who asked for it — the three things they can
|
||||||
|
# act on differently: wait for tomorrow's budget, reconnect the account, or try again later.
|
||||||
|
YouTubeRefusal = Literal["quota", "reconnect", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
|
def refusal_reason(exc: YouTubeError) -> YouTubeRefusal:
|
||||||
|
"""Sort a failure by what the user can do about it. The token step failing (Google refused the
|
||||||
|
refresh, or there is no usable token at all — raised before any endpoint answered, so with no
|
||||||
|
path and no transport reason) means the account must be reconnected; retrying cannot help."""
|
||||||
|
if is_quota_refusal(exc):
|
||||||
|
return "quota"
|
||||||
|
if exc.path == "oauth2/token" or (not exc.path and exc.reason != "transport"):
|
||||||
|
return "reconnect"
|
||||||
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
def _chunks(items: list, size: int) -> Iterator[list]:
|
def _chunks(items: list, size: int) -> Iterator[list]:
|
||||||
for i in range(0, len(items), size):
|
for i in range(0, len(items), size):
|
||||||
yield items[i : i + size]
|
yield items[i : i + size]
|
||||||
@@ -181,7 +198,10 @@ class YouTubeClient:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise _upstream_failure("POST", "oauth2/token", resp)
|
exc = _upstream_failure("POST", "oauth2/token", resp)
|
||||||
|
if exc.reason == "invalid_grant":
|
||||||
|
self._drop_dead_grant(tok)
|
||||||
|
raise exc
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
access = data["access_token"]
|
access = data["access_token"]
|
||||||
tok.access_token = encrypt(access)
|
tok.access_token = encrypt(access)
|
||||||
@@ -191,6 +211,25 @@ class YouTubeClient:
|
|||||||
log.info("Refreshed access token for user %s", self.user.id)
|
log.info("Refreshed access token for user %s", self.user.id)
|
||||||
return access
|
return access
|
||||||
|
|
||||||
|
def _drop_dead_grant(self, tok) -> None:
|
||||||
|
"""Google answered the refresh with `invalid_grant`: the grant was revoked or expired, and no
|
||||||
|
retry will revive it. Forget it, so the account reads as NOT connected — Settings and the
|
||||||
|
onboarding wizard then offer the connect button again (a fresh consent issues a new refresh
|
||||||
|
token). Kept as "granted", the UI showed a live connection with no way to renew it while
|
||||||
|
every call failed. Committed like the refresh above: the caller rolls back on the error."""
|
||||||
|
tok.scopes = None
|
||||||
|
tok.access_token = None
|
||||||
|
tok.refresh_token_enc = None
|
||||||
|
tok.expiry = None
|
||||||
|
self.db.add(tok)
|
||||||
|
self.db.commit()
|
||||||
|
# INFO: the refusal itself is already the warning (`youtube.api.error`, invalid_grant).
|
||||||
|
log.info(
|
||||||
|
"cleared the dead YouTube grant of user %s",
|
||||||
|
self.user.id,
|
||||||
|
extra=logs.event("youtube.grant.cleared"),
|
||||||
|
)
|
||||||
|
|
||||||
# --- core request ---
|
# --- core request ---
|
||||||
def _get(
|
def _get(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -140,9 +140,9 @@ def test_a_refused_token_refresh_names_the_oauth_reason_and_not_its_prose(sink,
|
|||||||
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
|
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
|
||||||
)
|
)
|
||||||
client = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
client = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
||||||
client.db = None
|
client.db = SimpleNamespace(add=lambda obj: None, commit=lambda: None)
|
||||||
client.user = SimpleNamespace(
|
client.user = SimpleNamespace(
|
||||||
token=SimpleNamespace(access_token=None, expiry=None, refresh_token_enc=b"x")
|
id=1, token=SimpleNamespace(access_token=None, expiry=None, refresh_token_enc=b"x")
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
|
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
|
||||||
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""An action YouTube refuses answers a 422 the UI can translate — never a raw 500 — and says what the
|
||||||
|
user can do about it (`routes/_common.youtube_refusal`). E9 F1: the two manual syncs had no handler
|
||||||
|
at all, so a quota or transport failure opened the blocking "Server error" dialog."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth import has_read_scope
|
||||||
|
from app.models import OAuthToken
|
||||||
|
from app.youtube import client as yt
|
||||||
|
from app.youtube.client import YouTubeError, refusal_reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exc", "reason"),
|
||||||
|
[
|
||||||
|
(YouTubeError("m", reason="quotaExceeded", path="playlists"), "quota"),
|
||||||
|
(YouTubeError("m", reason="backendError,dailyLimitExceeded", path="videos"), "quota"),
|
||||||
|
(YouTubeError("m", reason="invalid_grant", path="oauth2/token"), "reconnect"),
|
||||||
|
(YouTubeError("No refresh token; user must re-authenticate"), "reconnect"),
|
||||||
|
(YouTubeError("m", reason="transport"), "unavailable"),
|
||||||
|
(YouTubeError("m", reason="backendError", path="subscriptions"), "unavailable"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
|
||||||
|
assert refusal_reason(exc) == reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("route", "target"),
|
||||||
|
[
|
||||||
|
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
|
||||||
|
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
|
||||||
|
db, as_user, make_user, monkeypatch, route, target
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
db.commit()
|
||||||
|
module, name = target.rsplit(".", 1)
|
||||||
|
|
||||||
|
def refused(*a, **k):
|
||||||
|
raise YouTubeError("GET subscriptions -> 403: quotaExceeded", reason="quotaExceeded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(f"{module}.has_read_scope", lambda u: True)
|
||||||
|
monkeypatch.setattr(f"{module}.{name}", refused)
|
||||||
|
|
||||||
|
r = as_user(user).post(route)
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert r.json()["detail"] == {"code": "youtube", "reason": "quota"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_revoked_grant_is_forgotten_so_the_account_can_reconnect(db, make_user, monkeypatch):
|
||||||
|
"""Google's `invalid_grant` is final. Kept, the grant read as live in Settings — with no connect
|
||||||
|
button — while every call failed; cleared, the connect button comes back."""
|
||||||
|
user = make_user()
|
||||||
|
db.add(
|
||||||
|
OAuthToken(
|
||||||
|
user_id=user.id,
|
||||||
|
refresh_token_enc="x",
|
||||||
|
scopes="openid https://www.googleapis.com/auth/youtube.readonly",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
assert has_read_scope(user)
|
||||||
|
|
||||||
|
resp = httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"error": "invalid_grant", "error_description": "Token has been expired or revoked."},
|
||||||
|
request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL),
|
||||||
|
)
|
||||||
|
c = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
||||||
|
c.db, c.user = db, user
|
||||||
|
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||||
|
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "")
|
||||||
|
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
|
||||||
|
|
||||||
|
with pytest.raises(YouTubeError) as caught:
|
||||||
|
c._access_token()
|
||||||
|
assert refusal_reason(caught.value) == "reconnect"
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert not has_read_scope(db.get(type(user), user.id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_any_other_token_refusal_keeps_the_grant(monkeypatch):
|
||||||
|
"""Only `invalid_grant` is final: a 5xx from the token endpoint is Google having a bad minute."""
|
||||||
|
resp = httpx.Response(
|
||||||
|
503, json={"error": "backend_error"}, request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL)
|
||||||
|
)
|
||||||
|
tok = SimpleNamespace(access_token=None, expiry=None, refresh_token_enc="x", scopes="s")
|
||||||
|
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
|
||||||
|
c.db = SimpleNamespace(add=lambda o: None, commit=lambda: None)
|
||||||
|
c.user = SimpleNamespace(id=1, token=tok)
|
||||||
|
monkeypatch.setattr(yt, "decrypt_optional", lambda value: None)
|
||||||
|
monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token")
|
||||||
|
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "")
|
||||||
|
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
|
||||||
|
|
||||||
|
with pytest.raises(YouTubeError):
|
||||||
|
c._access_token()
|
||||||
|
assert tok.scopes == "s"
|
||||||
@@ -49,7 +49,7 @@ const linked = { id: 4, name: "Mix", kind: "user", yt_playlist_id: "PL1" } as co
|
|||||||
describe("usePlaylistActions — deleting a list", () => {
|
describe("usePlaylistActions — deleting a list", () => {
|
||||||
it("does not toast a failure the request layer already put on screen", async () => {
|
it("does not toast a failure the request layer already put on screen", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
new Response(JSON.stringify({ detail: { code: "playlist", reason: "youtube_failed" } }), {
|
new Response(JSON.stringify({ detail: { code: "youtube", reason: "unavailable" } }), {
|
||||||
status: 422,
|
status: 422,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -91,5 +91,10 @@
|
|||||||
"reload": "Reload",
|
"reload": "Reload",
|
||||||
"notifTitle": "Something broke",
|
"notifTitle": "Something broke",
|
||||||
"notifMessage": "Unexpected error"
|
"notifMessage": "Unexpected error"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.",
|
||||||
|
"reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.",
|
||||||
|
"unavailable": "YouTube didn't accept that just now. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@
|
|||||||
"name_required": "Give the playlist a name.",
|
"name_required": "Give the playlist a name.",
|
||||||
"youtube_read_scope": "Connect YouTube (read access) to sync your playlists.",
|
"youtube_read_scope": "Connect YouTube (read access) to sync your playlists.",
|
||||||
"not_on_youtube": "This playlist isn't on YouTube.",
|
"not_on_youtube": "This playlist isn't on YouTube.",
|
||||||
"youtube_failed": "YouTube didn't accept that just now. Please try again.",
|
|
||||||
"watch_later_not_pushable": "The Watch later list can't be sent to YouTube.",
|
"watch_later_not_pushable": "The Watch later list can't be sent to YouTube.",
|
||||||
"watch_later_undeletable": "The Watch later list can't be deleted.",
|
"watch_later_undeletable": "The Watch later list can't be deleted.",
|
||||||
"youtube_editing_off": "Enable YouTube editing in Settings first.",
|
"youtube_editing_off": "Enable YouTube editing in Settings first.",
|
||||||
|
|||||||
@@ -91,5 +91,10 @@
|
|||||||
"reload": "Újratöltés",
|
"reload": "Újratöltés",
|
||||||
"notifTitle": "Valami elromlott",
|
"notifTitle": "Valami elromlott",
|
||||||
"notifMessage": "Váratlan hiba"
|
"notifMessage": "Váratlan hiba"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.",
|
||||||
|
"reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.",
|
||||||
|
"unavailable": "A YouTube most nem fogadta el. Próbáld újra."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@
|
|||||||
"name_required": "Adj nevet a lejátszási listának.",
|
"name_required": "Adj nevet a lejátszási listának.",
|
||||||
"youtube_read_scope": "A listák szinkronizálásához kapcsold össze a YouTube-ot (olvasási hozzáférés).",
|
"youtube_read_scope": "A listák szinkronizálásához kapcsold össze a YouTube-ot (olvasási hozzáférés).",
|
||||||
"not_on_youtube": "Ez a lejátszási lista nincs fent a YouTube-on.",
|
"not_on_youtube": "Ez a lejátszási lista nincs fent a YouTube-on.",
|
||||||
"youtube_failed": "A YouTube most nem fogadta el. Próbáld újra.",
|
|
||||||
"watch_later_not_pushable": "A „Megnézem később” listát nem lehet a YouTube-ra küldeni.",
|
"watch_later_not_pushable": "A „Megnézem később” listát nem lehet a YouTube-ra küldeni.",
|
||||||
"watch_later_undeletable": "A „Megnézem később” lista nem törölhető.",
|
"watch_later_undeletable": "A „Megnézem később” lista nem törölhető.",
|
||||||
"youtube_editing_off": "Előbb engedélyezd a YouTube-szerkesztést a Beállításokban.",
|
"youtube_editing_off": "Előbb engedélyezd a YouTube-szerkesztést a Beállításokban.",
|
||||||
|
|||||||
@@ -167,6 +167,23 @@ describe("req — a structured refusal reads in the user's language", () => {
|
|||||||
detail: localized,
|
detail: localized,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["quota", "reconnect", "unavailable"])(
|
||||||
|
"says what to do about an action YouTube refused (%s)",
|
||||||
|
async (reason) => {
|
||||||
|
// Waiting for tomorrow's budget, reconnecting and retrying are different advice; the manual
|
||||||
|
// syncs used to answer a raw 500 here, and the other routes the upstream's English.
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ detail: { code: "youtube", reason } }), {
|
||||||
|
status: 422,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(req("/api/sync/subscriptions", { method: "POST" })).rejects.toMatchObject({
|
||||||
|
detail: `errors.youtube.${reason}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("req — what it has already put in front of the user", () => {
|
describe("req — what it has already put in front of the user", () => {
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ function localizeDetail(d: unknown): string {
|
|||||||
return o.reason === "name_taken"
|
return o.reason === "name_taken"
|
||||||
? t("plex.collection.nameTaken")
|
? t("plex.collection.nameTaken")
|
||||||
: t("plex.collection.editFailed");
|
: t("plex.collection.editFailed");
|
||||||
|
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
|
||||||
|
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||||
|
if (o?.code === "youtube")
|
||||||
|
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||||
// An admin action on an account or an invite that the server refused: activating an address that
|
// An admin action on an account or an invite that the server refused: activating an address that
|
||||||
|
|||||||
Reference in New Issue
Block a user