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
|
|
@ -5,15 +5,16 @@ import logging
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota
|
||||
from app.auth import current_user, has_write_scope
|
||||
from app import quota, sysconfig
|
||||
from app.auth import current_user, has_write_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
Channel,
|
||||
ChannelTag,
|
||||
ExploredChannel,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
Subscription,
|
||||
|
|
@ -22,6 +23,7 @@ from app.models import (
|
|||
Video,
|
||||
)
|
||||
from app.routes.admin import admin_user
|
||||
from app.sync.explore import explore_ingest_page
|
||||
from app.sync.runner import run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
|
@ -205,6 +207,133 @@ def discover_channels(
|
|||
]
|
||||
|
||||
|
||||
def _channel_detail_dict(
|
||||
channel: Channel, *, subscribed: bool, explored: bool, stored: int
|
||||
) -> dict:
|
||||
return {
|
||||
"id": channel.id,
|
||||
"title": channel.title,
|
||||
"handle": channel.handle,
|
||||
"description": channel.description,
|
||||
"thumbnail_url": channel.thumbnail_url,
|
||||
"banner_url": channel.banner_url,
|
||||
"subscriber_count": channel.subscriber_count,
|
||||
"video_count": channel.video_count,
|
||||
"total_view_count": channel.total_view_count,
|
||||
"published_at": channel.published_at.isoformat() if channel.published_at else None,
|
||||
"external_links": channel.external_links or [],
|
||||
"country": channel.country,
|
||||
"subscribed": subscribed,
|
||||
"explored": explored,
|
||||
"stored_videos": stored,
|
||||
"from_explore": channel.from_explore,
|
||||
"details_synced": channel.details_synced_at is not None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{channel_id}")
|
||||
def channel_detail(
|
||||
channel_id: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
||||
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."""
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
if channel.published_at is None and not user.is_demo:
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
|
||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||
db.commit()
|
||||
channel = db.get(Channel, channel_id)
|
||||
except YouTubeError as exc:
|
||||
log.warning("channel detail enrich failed (%s): %s", channel_id, exc)
|
||||
db.rollback()
|
||||
subscribed = db.execute(
|
||||
select(Subscription.id).where(
|
||||
Subscription.user_id == user.id, Subscription.channel_id == channel_id
|
||||
)
|
||||
).first() is not None
|
||||
explored = db.execute(
|
||||
select(ExploredChannel.id).where(
|
||||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||
)
|
||||
).first() is not None
|
||||
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
||||
return _channel_detail_dict(
|
||||
channel, subscribed=subscribed, explored=explored, stored=int(stored)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{channel_id}/explore")
|
||||
def explore_channel(
|
||||
channel_id: str,
|
||||
page_token: str | None = None,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
|
||||
grace-clocked row that gates this user's access to the channel's ephemeral videos and
|
||||
drives the later cleanup), flags the channel ephemeral unless someone organically follows
|
||||
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
|
||||
Returns the next page token so the page can "load more". Guarded against the shared
|
||||
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="The shared daily quota reserve is reached — try exploring later.",
|
||||
)
|
||||
|
||||
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
||||
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
|
||||
existing = db.execute(
|
||||
select(ExploredChannel).where(
|
||||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
db.add(ExploredChannel(user_id=user.id, channel_id=channel_id))
|
||||
else:
|
||||
existing.created_at = datetime.now(timezone.utc) # refresh the grace clock
|
||||
followed = db.execute(
|
||||
select(Subscription.id).where(Subscription.channel_id == channel_id).limit(1)
|
||||
).first()
|
||||
if followed is None and not channel.from_explore:
|
||||
channel.from_explore = True
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE), YouTubeClient(db, user) as yt:
|
||||
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
||||
if (
|
||||
channel.details_synced_at is None
|
||||
or channel.uploads_playlist_id is None
|
||||
or channel.published_at is None
|
||||
):
|
||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||
db.commit()
|
||||
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}"
|
||||
)
|
||||
return {
|
||||
"channel_id": channel_id,
|
||||
"ingested": result["ingested"],
|
||||
"next_page_token": result["next_page_token"],
|
||||
}
|
||||
|
||||
|
||||
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
|
||||
sub = db.execute(
|
||||
select(Subscription).where(
|
||||
|
|
@ -332,6 +461,14 @@ def subscribe(
|
|||
subscribed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
# Subscribing is the "keep" signal: a previously-explored channel becomes a normal organic
|
||||
# one. Drop its ephemeral flags so the cleanup job leaves it alone and its videos stop being
|
||||
# per-user private (they join the shared Library like any subscribed channel's uploads).
|
||||
if channel.from_explore:
|
||||
channel.from_explore = False
|
||||
db.execute(
|
||||
update(Video).where(Video.channel_id == channel_id, Video.via_explore.is_(True)).values(via_explore=False)
|
||||
)
|
||||
db.commit()
|
||||
return {"subscribed": channel_id}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.db import get_db
|
|||
from app.models import (
|
||||
Channel,
|
||||
ChannelTag,
|
||||
ExploredChannel,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
SearchFind,
|
||||
|
|
@ -180,6 +181,23 @@ def _filtered_query(
|
|||
# either way they shouldn't show in any feed view meanwhile.
|
||||
query = query.where(Video.unavailable_since.is_(None))
|
||||
|
||||
# Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone
|
||||
# opened an un-subscribed channel's page) is visible only to users who explored or
|
||||
# subscribe to its channel, so one user's curiosity never leaks into everyone else's
|
||||
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
|
||||
# asking for scope=all + channel_id (the channel is in this accessible set).
|
||||
accessible_explore = (
|
||||
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||
).union(
|
||||
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
|
||||
)
|
||||
query = query.where(
|
||||
or_(
|
||||
Video.via_explore.is_(False),
|
||||
Video.channel_id.in_(accessible_explore),
|
||||
)
|
||||
)
|
||||
|
||||
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
|
||||
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
|
||||
# "organic" = your subscriptions, "search" = videos you found via your own search,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue