114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.
What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
degraded to a software transcode with nothing saying why; a failed audio probe
collapsed a multi-audio file to one track and the player showed no language
picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
own root, leaving every crashed session's segments on disk forever.
Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.
ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.
logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.
tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import logs
|
|
from app.models import Channel, Subscription, User
|
|
from app.utils import normalize_description
|
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
|
|
|
log = logs.get_logger(__name__)
|
|
|
|
|
|
def _to_int(value) -> int | None:
|
|
try:
|
|
return int(value) if value is not None else None
|
|
# silent-ok: an upstream count we cannot read is stored as no count, which the card renders as
|
|
# absent rather than as zero. Per-channel over a whole subscription list, so it must stay quiet.
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _parse_dt(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
# silent-ok: as `_to_int` above — an unreadable upstream date becomes NULL, and every caller
|
|
# already treats a missing date as unknown.
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _external_links(branding: dict) -> list | None:
|
|
"""Channel "About" links. The API exposes a few in brandingSettings.channel.profileColor/
|
|
/... varies; the durable spot is brandingSettings.channel.links is gone post-2023, so we
|
|
read what's present (settings/links) defensively and keep [{title, url}] pairs."""
|
|
channel = branding.get("channel", {})
|
|
links = channel.get("links") or []
|
|
out = [
|
|
{"title": item.get("title"), "url": item.get("url")}
|
|
for item in links
|
|
if isinstance(item, dict) and item.get("url")
|
|
]
|
|
return out or None
|
|
|
|
|
|
def apply_channel_details(db: Session, items: list[dict]) -> None:
|
|
now = datetime.now(timezone.utc)
|
|
for item in items:
|
|
channel = db.get(Channel, item["id"])
|
|
if channel is None:
|
|
continue
|
|
snippet = item.get("snippet", {})
|
|
content = item.get("contentDetails", {})
|
|
stats = item.get("statistics", {})
|
|
topics = item.get("topicDetails", {})
|
|
branding = item.get("brandingSettings", {})
|
|
|
|
channel.title = snippet.get("title") or channel.title
|
|
channel.handle = snippet.get("customUrl")
|
|
channel.description = normalize_description(snippet.get("description"))
|
|
channel.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or channel.thumbnail_url
|
|
channel.default_language = snippet.get("defaultLanguage")
|
|
channel.country = snippet.get("country")
|
|
channel.uploads_playlist_id = content.get("relatedPlaylists", {}).get("uploads")
|
|
channel.subscriber_count = _to_int(stats.get("subscriberCount"))
|
|
channel.video_count = _to_int(stats.get("videoCount"))
|
|
channel.total_view_count = _to_int(stats.get("viewCount"))
|
|
channel.published_at = _parse_dt(snippet.get("publishedAt"))
|
|
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
|
|
channel.external_links = _external_links(branding)
|
|
channel.topic_categories = topics.get("topicCategories")
|
|
channel.keywords = (branding.get("channel") or {}).get("keywords")
|
|
channel.details_synced_at = now
|
|
|
|
|
|
def should_prune(fetched_count: int, existing_count: int) -> bool:
|
|
"""Whether an import may delete the local subscriptions missing from this fetch.
|
|
|
|
NEVER on an empty fetch that contradicts local state: the API can answer 200 with no items (a
|
|
transient blip, a scope/token edge), and pruning against that wipes every subscription the user
|
|
has — taking each row's `deep_requested` flag with it — in a single run. Someone who genuinely
|
|
unsubscribed from everything simply keeps their rows until a sync that returns something.
|
|
|
|
Pure so it can be tested without a database; `import_subscriptions` is the only caller."""
|
|
return fetched_count > 0 or existing_count == 0
|
|
|
|
|
|
def import_subscriptions(db: Session, user: User) -> dict:
|
|
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
|
|
channels the user has unsubscribed from on YouTube, and fetch channel details for
|
|
any channel we don't have details for yet."""
|
|
fetched: dict[str, dict] = {}
|
|
new_channels = 0
|
|
|
|
with YouTubeClient(db, user) as yt:
|
|
for sub in yt.iter_subscriptions():
|
|
cid = sub.get("channel_id")
|
|
if cid:
|
|
fetched[cid] = sub
|
|
|
|
for cid, sub in fetched.items():
|
|
channel = db.get(Channel, cid)
|
|
if channel is None:
|
|
channel = Channel(
|
|
id=cid,
|
|
title=sub.get("title"),
|
|
description=normalize_description(sub.get("description")),
|
|
thumbnail_url=sub.get("thumbnail_url"),
|
|
)
|
|
db.add(channel)
|
|
new_channels += 1
|
|
sub_row = db.execute(
|
|
select(Subscription).where(
|
|
Subscription.user_id == user.id, Subscription.channel_id == cid
|
|
)
|
|
).scalar_one_or_none()
|
|
if sub_row is None:
|
|
sub_row = Subscription(user_id=user.id, channel_id=cid)
|
|
db.add(sub_row)
|
|
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
|
|
db.flush()
|
|
|
|
# Prune subscriptions removed on YouTube — but only when this fetch is trustworthy
|
|
# (see `should_prune`).
|
|
removed = 0
|
|
existing = (
|
|
db.execute(select(Subscription).where(Subscription.user_id == user.id)).scalars().all()
|
|
)
|
|
prune_skipped = not should_prune(len(fetched), len(existing))
|
|
if prune_skipped:
|
|
log.warning(
|
|
"Subscription import (user %s): YouTube returned 0 subscriptions while %d exist "
|
|
"locally — skipping the prune (treating it as a transient empty response)",
|
|
user.id,
|
|
len(existing),
|
|
)
|
|
else:
|
|
for sub_row in existing:
|
|
if sub_row.channel_id not in fetched:
|
|
db.delete(sub_row)
|
|
removed += 1
|
|
db.commit()
|
|
|
|
# Fetch details for channels that don't have them yet.
|
|
need_details = (
|
|
db.execute(
|
|
select(Channel).where(
|
|
Channel.id.in_(fetched.keys()),
|
|
Channel.details_synced_at.is_(None),
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
detailed = 0
|
|
if need_details:
|
|
items = yt.get_channels([c.id for c in need_details])
|
|
apply_channel_details(db, items)
|
|
detailed = len(items)
|
|
db.commit()
|
|
|
|
result = {
|
|
"subscriptions": len(fetched),
|
|
"channels_new": new_channels,
|
|
"channels_detailed": detailed,
|
|
"removed_stale": removed,
|
|
"prune_skipped": prune_skipped,
|
|
}
|
|
log.info("Subscription import (user %s): %s", user.id, result)
|
|
return result
|