feat(channels): channel-explore backend — about metadata, ephemeral provenance, cleanup
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
exploration, flags the channel ephemeral unless followed, ingests one page of uploads
(via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
unkept channels and their untouched ephemeral videos after a grace period.
This commit is contained in:
parent
bb7b9972fd
commit
bc4c362423
11 changed files with 481 additions and 4 deletions
145
backend/app/sync/explore.py
Normal file
145
backend/app/sync/explore.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Channel exploration: ingest an un-subscribed channel's uploads on demand (flagged
|
||||
via_explore, kept per-explorer) and purge channels nobody ends up keeping.
|
||||
|
||||
A user opens a channel's page without subscribing → we pull a page of its recent uploads,
|
||||
mark the new rows via_explore, and enrich them so they're immediately browsable. The videos
|
||||
stay private to users who explored (or subscribe to) the channel — the feed query gates them
|
||||
— so one user's curiosity never floods everyone's catalog. Subscribing is the "keep" signal;
|
||||
otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a
|
||||
grace period (mirrors the maintenance job's grace-then-delete safety)."""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.models import (
|
||||
Channel,
|
||||
ExploredChannel,
|
||||
PlaylistItem,
|
||||
SearchFind,
|
||||
Subscription,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.sync.videos import _insert_stubs, _stub_from_playlist_item, apply_video_details
|
||||
from app.utils import now_utc as _now
|
||||
from app.youtube.client import YouTubeClient
|
||||
|
||||
log = logging.getLogger("siftlode.explore")
|
||||
|
||||
|
||||
def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
|
||||
"""Enrich just the not-yet-enriched videos among `video_ids` (videos.list, 1 unit/50)."""
|
||||
if not video_ids:
|
||||
return 0
|
||||
videos = (
|
||||
db.execute(
|
||||
select(Video).where(
|
||||
Video.id.in_(video_ids), Video.enriched_at.is_(None)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not videos:
|
||||
return 0
|
||||
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
|
||||
now = _now()
|
||||
for video in videos:
|
||||
item = items.get(video.id)
|
||||
if item is None:
|
||||
video.enriched_at = now # unavailable; stop retrying
|
||||
continue
|
||||
apply_video_details(video, item)
|
||||
video.enriched_at = now
|
||||
db.commit()
|
||||
return len(videos)
|
||||
|
||||
|
||||
def explore_ingest_page(
|
||||
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
|
||||
) -> dict:
|
||||
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new
|
||||
videos via_explore, then enrich them. Returns the count ingested and the next page token
|
||||
(None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless
|
||||
pagination: the caller round-trips the token, so the channel's own backfill cursor (used by
|
||||
the organic deep backfill) is never touched."""
|
||||
if not channel.uploads_playlist_id:
|
||||
return {"ingested": 0, "next_page_token": None}
|
||||
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
|
||||
stubs: list[dict] = []
|
||||
for item in data.get("items", []):
|
||||
stub = _stub_from_playlist_item(item, channel.id)
|
||||
if stub is not None:
|
||||
stub["via_explore"] = True
|
||||
stubs.append(stub)
|
||||
inserted = _insert_stubs(db, stubs)
|
||||
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
|
||||
log.info(
|
||||
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
|
||||
)
|
||||
return {"ingested": inserted, "next_page_token": data.get("nextPageToken")}
|
||||
|
||||
|
||||
def purge_unkept_explored(db: Session) -> dict:
|
||||
"""Cleanup job: hard-delete explored channels nobody kept and their untouched ephemeral
|
||||
videos, after a grace period (mirrors the maintenance grace-then-delete safety).
|
||||
|
||||
Keep signals that spare a channel/video:
|
||||
- any user SUBSCRIBES to the channel (subscribe promotes it to organic — channel-level), or
|
||||
- a live ExploredChannel row (someone explored it within the grace window — channel-level), or
|
||||
- a video has per-user state: watched/in-progress (VideoState), in a playlist (PlaylistItem),
|
||||
or surfaced by the user's own search (SearchFind) — these survive even if the channel goes.
|
||||
A channel row is dropped only once it holds no videos at all. 0 quota."""
|
||||
grace_days = sysconfig.get_int(db, "explore_grace_days")
|
||||
cutoff = _now() - timedelta(days=grace_days)
|
||||
|
||||
expired = db.execute(
|
||||
delete(ExploredChannel).where(ExploredChannel.created_at < cutoff)
|
||||
).rowcount
|
||||
db.commit()
|
||||
|
||||
sub_exists = (
|
||||
select(Subscription.id).where(Subscription.channel_id == Channel.id).exists()
|
||||
)
|
||||
exp_exists = (
|
||||
select(ExploredChannel.id).where(ExploredChannel.channel_id == Channel.id).exists()
|
||||
)
|
||||
orphan_ids = (
|
||||
db.execute(
|
||||
select(Channel.id).where(
|
||||
Channel.from_explore.is_(True), ~sub_exists, ~exp_exists
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not orphan_ids:
|
||||
return {"expired": expired, "videos_deleted": 0, "channels_deleted": 0}
|
||||
|
||||
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
|
||||
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
|
||||
find_exists = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
|
||||
videos_deleted = db.execute(
|
||||
delete(Video).where(
|
||||
Video.channel_id.in_(orphan_ids),
|
||||
Video.via_explore.is_(True),
|
||||
~state_exists,
|
||||
~pl_exists,
|
||||
~find_exists,
|
||||
)
|
||||
).rowcount
|
||||
db.commit()
|
||||
|
||||
has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists()
|
||||
channels_deleted = db.execute(
|
||||
delete(Channel).where(Channel.id.in_(orphan_ids), ~has_videos)
|
||||
).rowcount
|
||||
db.commit()
|
||||
return {
|
||||
"expired": expired,
|
||||
"videos_deleted": videos_deleted,
|
||||
"channels_deleted": channels_deleted,
|
||||
}
|
||||
|
|
@ -18,6 +18,29 @@ def _to_int(value) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
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": l.get("title"), "url": l.get("url")}
|
||||
for l in links
|
||||
if isinstance(l, dict) and l.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:
|
||||
|
|
@ -28,6 +51,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
|||
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")
|
||||
|
|
@ -40,6 +64,10 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
|||
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.details_synced_at = now
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue