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.models import User
|
||||
from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
@@ -51,3 +52,12 @@ def owned_or_404(
|
||||
if getattr(obj, owner_attr) != user.id:
|
||||
raise not_found(what)
|
||||
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,
|
||||
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.runner import backfill_channel_now, run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
@@ -424,7 +424,7 @@ def explore_channel(
|
||||
channel = db.get(Channel, channel_id)
|
||||
result = explore_ingest_page(db, yt, channel, page_token)
|
||||
except YouTubeError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"YouTube couldn't load this channel: {exc}")
|
||||
raise youtube_refusal(exc)
|
||||
return {
|
||||
"channel_id": channel_id,
|
||||
"ingested": result["ingested"],
|
||||
@@ -583,9 +583,7 @@ def subscribe(
|
||||
if "subscriptionDuplicate" in exc.reason.split(","):
|
||||
yt_sub_id = ""
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"YouTube couldn't subscribe you: {exc}"
|
||||
)
|
||||
raise youtube_refusal(exc)
|
||||
# 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
|
||||
# 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:
|
||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||
# 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.commit()
|
||||
return {"unsubscribed": channel_id}
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.models import (
|
||||
Video,
|
||||
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.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
@@ -411,7 +411,7 @@ def get_video_detail(
|
||||
):
|
||||
items = yt.get_videos([video_id])
|
||||
except YouTubeError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
||||
raise youtube_refusal(exc)
|
||||
if not items:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.sync.playlists import (
|
||||
sync_user_playlists,
|
||||
)
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
@@ -322,8 +322,12 @@ def sync_youtube(
|
||||
if not has_read_scope(user):
|
||||
raise _refused(403, "youtube_read_scope")
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
try:
|
||||
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")
|
||||
@@ -344,9 +348,9 @@ def revert_youtube(
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
except YouTubeError:
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise _refused(422, "youtube_failed")
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
@@ -371,8 +375,8 @@ def push_plan(
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
plan = plan_merge(db, user, pl)
|
||||
except YouTubeError:
|
||||
raise _refused(422, "youtube_failed")
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc)
|
||||
plan["remaining_today"] = quota.remaining_today(db)
|
||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||
return plan
|
||||
@@ -404,9 +408,9 @@ def push(
|
||||
if plan["units_estimate"] > quota.remaining_today(db):
|
||||
raise _refused(429, "quota_exceeded")
|
||||
return merge_playlist(db, user, pl, live=live)
|
||||
except YouTubeError:
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise _refused(422, "youtube_failed")
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
@@ -496,8 +500,8 @@ def delete_playlist(
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt.delete_playlist(pl.yt_playlist_id)
|
||||
except YouTubeError:
|
||||
raise _refused(422, "youtube_failed")
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc)
|
||||
db.delete(pl) # items cascade
|
||||
db.commit()
|
||||
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.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.routes._common import youtube_refusal
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
from app.youtube.client import YouTubeError
|
||||
|
||||
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)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
try:
|
||||
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_remaining_today"] = m.remaining
|
||||
return result
|
||||
|
||||
@@ -9,6 +9,7 @@ uses OAuth.
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
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(","))
|
||||
|
||||
|
||||
# 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]:
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
@@ -181,7 +198,10 @@ class YouTubeClient:
|
||||
},
|
||||
)
|
||||
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()
|
||||
access = data["access_token"]
|
||||
tok.access_token = encrypt(access)
|
||||
@@ -191,6 +211,25 @@ class YouTubeClient:
|
||||
log.info("Refreshed access token for user %s", self.user.id)
|
||||
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 ---
|
||||
def _get(
|
||||
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"),
|
||||
)
|
||||
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(
|
||||
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", 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", () => {
|
||||
it("does not toast a failure the request layer already put on screen", async () => {
|
||||
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,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
|
||||
@@ -91,5 +91,10 @@
|
||||
"reload": "Reload",
|
||||
"notifTitle": "Something broke",
|
||||
"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.",
|
||||
"youtube_read_scope": "Connect YouTube (read access) to sync your playlists.",
|
||||
"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_undeletable": "The Watch later list can't be deleted.",
|
||||
"youtube_editing_off": "Enable YouTube editing in Settings first.",
|
||||
|
||||
@@ -91,5 +91,10 @@
|
||||
"reload": "Újratöltés",
|
||||
"notifTitle": "Valami elromlott",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_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.",
|
||||
|
||||
@@ -167,6 +167,23 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -129,6 +129,10 @@ function localizeDetail(d: unknown): string {
|
||||
return o.reason === "name_taken"
|
||||
? t("plex.collection.nameTaken")
|
||||
: 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, …).
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user