merge: bug/channel-details-refresh into E28

# Conflicts:
#	frontend/src/components/ChannelPage.tsx
This commit is contained in:
2026-09-27 06:42:37 +02:00
6 changed files with 240 additions and 18 deletions
+7 -4
View File
@@ -28,7 +28,7 @@ from app.models import (
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
from app.sync.subscriptions import apply_channel_details, details_stale
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
@@ -297,10 +297,13 @@ def channel_detail(
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
needed — a stub, or a channel last enriched before those fields existed (published_at is a
reliable sentinel: every real channel has a creation date). The enrich uses the API key
(1 unit, cached after), so it's skipped for the quota-less demo account."""
reliable sentinel: every real channel has a creation date) — and refreshes it once it is stale
(`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
cached after), so it's skipped for the quota-less demo account."""
channel = get_or_404(db, Channel, channel_id, what="channel")
if channel.published_at is None and not user.is_demo:
stale = channel.published_at is None or details_stale(channel, datetime.now(timezone.utc))
if stale and not user.is_demo:
try:
with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
+26 -5
View File
@@ -1,8 +1,8 @@
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import logs
@@ -47,6 +47,27 @@ def _external_links(branding: dict) -> list | None:
return out or None
# How old a channel's metadata may get before it is fetched again. Banners, avatars, descriptions and
# counts change on YouTube, and an old banner URL stops resolving at all — without a refresh a channel
# kept whatever it had the day it was first seen. A week keeps counts roughly current at almost no
# cost: `channels.list` is 1 quota unit per 50 channels, and only the stale ones are asked for.
DETAILS_MAX_AGE = timedelta(days=7)
def details_stale(channel: Channel, now: datetime) -> bool:
"""Whether `channel`'s metadata is missing or older than DETAILS_MAX_AGE (see `details_stale_clause`)."""
synced = channel.details_synced_at
return synced is None or synced < now - DETAILS_MAX_AGE
def details_stale_clause(now: datetime):
"""`details_stale` as a WHERE clause, so the batch import and a single channel agree on "stale"."""
return or_(
Channel.details_synced_at.is_(None),
Channel.details_synced_at < now - DETAILS_MAX_AGE,
)
def apply_channel_details(db: Session, items: list[dict]) -> None:
now = datetime.now(timezone.utc)
for item in items:
@@ -92,7 +113,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
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."""
any channel whose details are missing or stale (`DETAILS_MAX_AGE`)."""
fetched: dict[str, dict] = {}
new_channels = 0
@@ -145,12 +166,12 @@ def import_subscriptions(db: Session, user: User) -> dict:
removed += 1
db.commit()
# Fetch details for channels that don't have them yet.
# Fetch details for channels that don't have them yet, or had them too long ago.
need_details = (
db.execute(
select(Channel).where(
Channel.id.in_(fetched.keys()),
Channel.details_synced_at.is_(None),
details_stale_clause(datetime.now(timezone.utc)),
)
)
.scalars()