Files
peter bdf7c03b59 fix(r5): address the eighth /code-review high round — 7 findings
The worst one was, again, the previous round's own fix.

- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
  also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
  live-session set BEFORE listing the root and rmtree'd outside the lock. Since
  `start_session` reuses a directory path for the same (key, offset, tag), a stale
  name can become a live session at any moment. Two guards now: nothing whose mtime
  is newer than this process's start is ever deleted, and the live check + rmtree
  happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
  0.0 read as "never ran" — unreachable in production, but `now` exists to be
  injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
  failed" is also what ONE dead feed id looks like on a small instance, and a red
  card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
  too — it had the same unprefixed-only reads in three places, including an Escape
  branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
  still on the element and wins. Comment now states the real mechanism.

Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
  band and overlaps NO YouTube control — the collision this round suspected does not
  reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
  level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
  change listener fires exactly once per transition, exit returns to 889.

Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
2026-07-24 04:44:20 +02:00

270 lines
11 KiB
Python

"""Reusable sync routines shared by the scheduler and the manual /api/sync endpoints.
Channel/video data is shared across users, so background work acts through a "service
user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configured for
public reads.
"""
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import progress, quota, sysconfig
log = logging.getLogger("siftlode.sync")
from app.models import Channel, OAuthToken, Subscription, User
from app.sync.subscriptions import import_subscriptions
from app.sync.videos import (
apply_rss_feed,
backfill_channel_deep,
backfill_channel_recent,
enrich_pending,
reconcile_full_history,
refresh_live,
run_shorts_classification,
)
from app.youtube.client import YouTubeClient
from app.youtube.rss import RssError, fetch_channel_feed
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
RSS_POLL_WORKERS = 16
def token_users(db: Session) -> list[User]:
"""Every user with a stored YouTube refresh token (the read-scope service accounts), by id."""
return list(
db.execute(
select(User)
.join(OAuthToken)
.where(OAuthToken.refresh_token_enc.is_not(None))
.order_by(User.id)
).scalars()
)
def get_service_user(db: Session) -> User | None:
users = token_users(db)
return users[0] if users else None
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str, int]:
"""Poll every channel's free RSS feed. Returns `{"new": inserted, "failed": channels_that_did
_not_complete}` — the failure count is part of the RESULT, not just a log line, so the scheduler
card and the audit summary can tell "no new uploads" apart from "nothing worked" (an egress
outage, or a database that can't accept writes, otherwise renders identically to a quiet hour).
`failed` counts a channel whose feed could not be READ *or* whose rows could not be APPLIED —
both leave the channel un-polled, and counting only the first would move the blind spot rather
than remove it.
Raises RuntimeError when NOTHING could be polled (`failed == total`, total > 0): a run that
achieved literally nothing is a failed run, and only an exception turns the scheduler card red
— a returned count leaves it green with the outage buried in the summary line. The message
carries the FIRST underlying error, because on a small instance "all channels failed" is also
what a single dead feed id looks like."""
if channels is None:
channels = db.execute(select(Channel)).scalars().all()
total = len(channels)
new = 0
done = 0
failed = 0
# Kept for the total-outage message: "failed for all N channels" alone can't tell an egress
# outage from one permanently dead feed id — which is the whole difference on an instance with
# a single subscription, where BOTH render as 100% failed.
first_error: str | None = None
# Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
# each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
# plain str) to the workers; the ORM instance is touched only here on the main thread.
futures = {}
with ThreadPoolExecutor(max_workers=RSS_POLL_WORKERS) as pool:
for channel in channels:
futures[pool.submit(fetch_channel_feed, channel.id)] = channel
for fut in as_completed(futures):
channel = futures[fut]
try:
entries = fut.result()
# A failed poll is NOT an empty feed: skip the apply so `last_rss_at` stays where it
# was (stamping it made a permanently-broken feed look freshly polled). The shared
# counter/progress tail lives after the statement, so the two outcomes can stay two
# clauses: an RssError is EXPECTED (404, timeout) and gets one warning line; anything
# else is a bug on our side and earns a traceback.
except RssError as exc:
log.warning("RSS poll skipped for channel %s: %s", channel.id, exc)
failed += 1
first_error = first_error or f"{channel.id}: {exc}"
except Exception as exc:
log.exception("RSS fetch failed for channel %s", channel.id)
failed += 1
first_error = first_error or f"{channel.id}: {exc}"
else:
try:
new += apply_rss_feed(db, channel, entries)
except Exception as exc:
# A write failure (read-only DB, failover) leaves the channel just as un-polled
# as an unreachable feed — count it, or a wholly broken run reports "failed=0".
db.rollback()
failed += 1
first_error = first_error or f"{channel.id} (apply): {exc}"
log.exception("RSS apply failed for channel %s", channel.id)
# One place that advances the counter, whatever happened above.
done += 1
progress.report(done, total, "rss_poll")
if failed:
log.warning("RSS poll: %d/%d channels could not be polled", failed, total)
if total and failed == total:
# Nothing was polled at all — an egress outage (the tinyproxy-after-reboot incident), a
# dead DNS, a read-only database. Returning normally would record the run as `ok` with the
# failure hidden inside the result string, i.e. a green pill for a total outage.
# The first error TRAVELS WITH the message on purpose: on an instance with one or two
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
# "fix this subscription"; `Connection refused` says "the egress is down".
raise RuntimeError(f"RSS poll failed for all {total} channels — first: {first_error}")
return {"new": new, "failed": failed}
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
user = get_service_user(db)
if user is None:
return 0
total = 0
with YouTubeClient(db, user) as yt:
for _ in range(max_batches):
if quota.remaining_today(db) <= floor:
break
n = enrich_pending(db, yt)
total += n
progress.report(total, None, "enrich") # total unknown (drains until empty)
if n == 0:
break
# Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended
# stream gains its final duration + was_live status instead of staying "live" forever.
if quota.remaining_today(db) > floor:
total += refresh_live(db, yt)
return total
def run_shorts(db: Session) -> dict:
return run_shorts_classification(db)
def run_subscription_resync(db: Session) -> dict:
"""Re-import every user's subscriptions so unsubscribes and new subscriptions on
YouTube are reflected automatically."""
users = token_users(db)
for i, user in enumerate(users, 1):
try:
import_subscriptions(db, user)
except Exception:
db.rollback()
log.exception("Subscription resync failed for user %s", user.id)
progress.report(i, len(users), "subscriptions")
return {"users": len(users)}
def run_recent_backfill(
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
) -> dict:
user = get_service_user(db)
if user is None:
return {"channels_processed": 0, "videos_added": 0, "reason": "no service user"}
if channels is None:
channels = (
db.execute(select(Channel).where(Channel.recent_synced_at.is_(None)))
.scalars()
.all()
)
if max_channels:
channels = channels[:max_channels]
processed = 0
videos_added = 0
with YouTubeClient(db, user) as yt:
for channel in channels:
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
break
try:
videos_added += backfill_channel_recent(db, yt, channel)
processed += 1
progress.report(processed, len(channels), "backfill_recent")
except Exception:
db.rollback()
log.exception("Recent backfill failed for channel %s", channel.id)
reconciled = reconcile_full_history(db)
return {
"channels_processed": processed,
"videos_added": videos_added,
"reconciled_full": reconciled,
}
def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -> dict:
user = get_service_user(db)
if user is None:
return {"channels_processed": 0, "videos_added": 0, "reason": "no service user"}
# Demand-driven: only deep-backfill channels at least one user has opted into
# (Subscription.deep_requested). Recent backfill stays unconditional (cheap).
deep_requested = (
select(Subscription.id)
.where(
Subscription.channel_id == Channel.id,
Subscription.deep_requested.is_(True),
)
.exists()
)
channels = (
db.execute(
select(Channel).where(
Channel.backfill_done.is_(False),
Channel.recent_synced_at.is_not(None),
deep_requested,
)
)
.scalars()
.all()
)[:max_channels]
processed = 0
videos_added = 0
with YouTubeClient(db, user) as yt:
for channel in channels:
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
break
try:
videos_added += backfill_channel_deep(db, yt, channel, max_pages)
processed += 1
progress.report(processed, len(channels), "backfill_deep")
except Exception:
db.rollback()
log.exception("Deep backfill failed for channel %s", channel.id)
reconciled = reconcile_full_history(db)
return {
"channels_processed": processed,
"videos_added": videos_added,
"reconciled_full": reconciled,
}
def estimate_deep_backfill(db: Session, channels: list[Channel]) -> dict:
"""Rough ETA to finish full-history backfill of `channels`. Quota-bound: total
estimated API units / the daily budget left for backfill. Deliberately approximate —
a channel's cost ~= playlistItems pages (1 unit / 50 videos) + enrichment (likewise)."""
pending = [c for c in channels if not c.backfill_done]
def units(c: Channel) -> int:
videos = c.video_count or 0
pages = max(1, (videos + 49) // 50)
return 2 * pages
total_units = sum(units(c) for c in pending)
daily_budget = max(
1, sysconfig.get_int(db, "quota_daily_budget") - sysconfig.get_int(db, "backfill_quota_reserve")
)
eta_seconds = int(total_units / daily_budget * 86_400) if pending else 0
return {
"channels_pending": len(pending),
"videos_pending_est": sum(c.video_count or 0 for c in pending),
"units_est": total_units,
"daily_budget": daily_budget,
"eta_seconds": eta_seconds,
}