feat(channels): discover & subscribe to channels from playlists
Add a "Discover from playlists" tab to the Channel manager that lists
channels appearing in the user's playlists they don't subscribe to, with
a one-click Subscribe.
- GET /api/channels/discovery: local join (playlist_items -> videos ->
channels) minus the user's subscriptions and their own channel. Enriches
stub channels' metadata up front (title/thumbnail/subscriber count via the
API key) so the user can judge a channel before subscribing; videos are
not pulled (the scheduler picks those up).
- POST /api/channels/{id}/subscribe: write-scope gated, subscriptions.insert
+ local Subscription with the returned resource id.
- YouTubeClient.insert_subscription / get_my_channel_id.
- users.yt_channel_id (migration 0019) caches the user's own channel id so
discovery can exclude it.
- Frontend: ChannelDiscovery DataTable, Channels tab toggle (persisted),
api methods, trilingual strings. Subscribe ships a typed notification
payload (ChannelSubscribedMeta) for the inbox to act on.
This commit is contained in:
parent
b5141ab112
commit
fb6f0c5dcb
11 changed files with 517 additions and 4 deletions
28
backend/alembic/versions/0019_user_yt_channel_id.py
Normal file
28
backend/alembic/versions/0019_user_yt_channel_id.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""cache each user's own YouTube channel id
|
||||
|
||||
Revision ID: 0019_user_yt_channel_id
|
||||
Revises: 0018_maintenance_batch_setting
|
||||
Create Date: 2026-06-19
|
||||
|
||||
Adds users.yt_channel_id: the user's own YouTube channel id, resolved lazily from
|
||||
channels.list?mine=true and cached. Used to exclude the user's own channel from the
|
||||
playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
|
||||
Purely additive.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0019_user_yt_channel_id"
|
||||
down_revision: Union[str, None] = "0018_maintenance_batch_setting"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("yt_channel_id", sa.String(length=32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "yt_channel_id")
|
||||
|
|
@ -28,6 +28,9 @@ class User(Base):
|
|||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
|
||||
# cached. Used to exclude their own channel from playlist discovery. NULL = unresolved.
|
||||
yt_channel_id: Mapped[str | None] = mapped_column(String(32))
|
||||
# The single shared "demo" account: signed into without Google OAuth (via a whitelisted
|
||||
# email on the login page), has no OAuth token / YouTube scope, and its per-user state is
|
||||
# shared by everyone who enters this way. Exactly one such row is expected.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
"""Channel manager: the user's subscriptions with their personal overrides (priority,
|
||||
hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes
|
||||
(unsubscribe) live behind the optional write scope and are added in a later phase."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -8,13 +11,25 @@ from sqlalchemy.orm import Session
|
|||
from app import quota
|
||||
from app.auth import current_user, has_write_scope
|
||||
from app.db import get_db
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
|
||||
from app.models import (
|
||||
Channel,
|
||||
ChannelTag,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
Subscription,
|
||||
Tag,
|
||||
User,
|
||||
Video,
|
||||
)
|
||||
from app.routes.admin import admin_user
|
||||
from app.sync.runner import run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||
|
||||
log = logging.getLogger("subfeed.api")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_channels(
|
||||
|
|
@ -107,6 +122,89 @@ def list_channels(
|
|||
]
|
||||
|
||||
|
||||
# Cap how many stub channels we enrich per discovery call, to bound first-load latency and
|
||||
# quota (channels.list = 1 unit / 50). Anything beyond is enriched on subsequent loads.
|
||||
DISCOVERY_ENRICH_CAP = 200
|
||||
|
||||
|
||||
def _discovery_rows(db: Session, user: User) -> list:
|
||||
"""The user's playlist channels they don't subscribe to (and that aren't their own),
|
||||
each with how many playlist videos come from it and across how many playlists."""
|
||||
subscribed = select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||
q = (
|
||||
select(
|
||||
Channel,
|
||||
func.count(func.distinct(PlaylistItem.video_id)),
|
||||
func.count(func.distinct(Playlist.id)),
|
||||
)
|
||||
.select_from(PlaylistItem)
|
||||
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||
.join(Video, Video.id == PlaylistItem.video_id)
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.where(Playlist.user_id == user.id, Channel.id.not_in(subscribed))
|
||||
)
|
||||
if user.yt_channel_id:
|
||||
q = q.where(Channel.id != user.yt_channel_id)
|
||||
return db.execute(
|
||||
q.group_by(Channel.id).order_by(
|
||||
func.count(func.distinct(PlaylistItem.video_id)).desc(),
|
||||
func.lower(Channel.title),
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
@router.get("/discovery")
|
||||
def discover_channels(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""Channels that appear in the user's playlists but that they don't subscribe to (their
|
||||
own channel excluded). The base list is a local join over the shared catalog. We also
|
||||
enrich each channel's metadata up front (title/thumbnail/subscriber count) so the user
|
||||
can judge a channel BEFORE subscribing — but we do NOT pull its videos. Most-present
|
||||
channels come first; subscribing is a separate action."""
|
||||
# Resolve & cache the user's own channel id so it can be excluded — you don't subscribe
|
||||
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
|
||||
if user.yt_channel_id is None and user.token is not None and not user.is_demo:
|
||||
try:
|
||||
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
|
||||
own = yt.get_my_channel_id()
|
||||
if own:
|
||||
user.yt_channel_id = own
|
||||
db.commit()
|
||||
except YouTubeError as exc:
|
||||
log.warning("discovery: own-channel resolve failed (user %s): %s", user.id, exc)
|
||||
|
||||
rows = _discovery_rows(db, user)
|
||||
|
||||
# Enrich stub channels' metadata (uses the API key — no write scope needed). Re-read
|
||||
# the rows afterwards so the response carries the fresh subscriber counts/thumbnails.
|
||||
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
|
||||
if need:
|
||||
try:
|
||||
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
|
||||
apply_channel_details(db, yt.get_channels(need))
|
||||
db.commit()
|
||||
rows = _discovery_rows(db, user)
|
||||
except YouTubeError as exc:
|
||||
log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc)
|
||||
db.rollback()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": ch.id,
|
||||
"title": ch.title,
|
||||
"handle": ch.handle,
|
||||
"thumbnail_url": ch.thumbnail_url,
|
||||
"subscriber_count": ch.subscriber_count,
|
||||
"video_count": ch.video_count,
|
||||
"playlist_video_count": int(vid_count),
|
||||
"playlist_count": int(pl_count),
|
||||
"details_synced": ch.details_synced_at is not None,
|
||||
}
|
||||
for ch, vid_count, pl_count in rows
|
||||
]
|
||||
|
||||
|
||||
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
|
||||
sub = db.execute(
|
||||
select(Subscription).where(
|
||||
|
|
@ -181,6 +279,53 @@ def reset_backfill(
|
|||
return {"id": channel_id, "reset": True}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/subscribe")
|
||||
def subscribe(
|
||||
channel_id: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Subscribe to a channel discovered from the user's playlists. Gated behind the
|
||||
optional write scope (YouTube subscriptions.insert). Creates the local subscription
|
||||
with YouTube's returned resource id and enriches the channel's metadata if it's still
|
||||
a stub, so the new row renders properly in the manager. It does NOT pull the channel's
|
||||
videos — the scheduler picks those up on its next run, like any subscribed channel."""
|
||||
if not has_write_scope(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Enable playlist editing in Settings to subscribe on YouTube.",
|
||||
)
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
existing = db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user.id, Subscription.channel_id == channel_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
||||
try:
|
||||
with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt:
|
||||
yt_sub_id = yt.insert_subscription(channel_id)
|
||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up
|
||||
# properly right away; no video pull here.
|
||||
if channel.details_synced_at is None:
|
||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||
except YouTubeError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"YouTube subscribe failed: {exc}")
|
||||
db.add(
|
||||
Subscription(
|
||||
user_id=user.id,
|
||||
channel_id=channel_id,
|
||||
yt_subscription_id=yt_sub_id or None,
|
||||
subscribed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"subscribed": channel_id}
|
||||
|
||||
|
||||
@router.delete("/{channel_id}/subscription")
|
||||
def unsubscribe(
|
||||
channel_id: str,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,15 @@ class YouTubeClient:
|
|||
if not page_token:
|
||||
break
|
||||
|
||||
def get_my_channel_id(self) -> str | None:
|
||||
"""The authenticated user's own channel id (channels.list?mine=true). OAuth only;
|
||||
1 quota unit. Returns None if the account has no channel."""
|
||||
data = self._get(
|
||||
"channels", {"part": "id", "mine": "true", "maxResults": 1}, allow_key=False
|
||||
)
|
||||
items = data.get("items", [])
|
||||
return items[0].get("id") if items else None
|
||||
|
||||
def get_channels(self, channel_ids: list[str]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(channel_ids, 50):
|
||||
|
|
@ -213,6 +222,23 @@ class YouTubeClient:
|
|||
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
|
||||
def insert_subscription(self, channel_id: str) -> str:
|
||||
"""Subscribe to a channel on YouTube. Requires the write scope and the user's
|
||||
OAuth token (never the public API key). subscriptions.insert costs 50 quota
|
||||
units. Returns the new subscription resource id — store it as
|
||||
`yt_subscription_id` so the channel can later be unsubscribed."""
|
||||
data = self._write(
|
||||
"POST",
|
||||
"subscriptions",
|
||||
params={"part": "snippet"},
|
||||
json={
|
||||
"snippet": {
|
||||
"resourceId": {"kind": "youtube#channel", "channelId": channel_id}
|
||||
}
|
||||
},
|
||||
)
|
||||
return data.get("id", "")
|
||||
|
||||
def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]:
|
||||
"""Yield {item_id, video_id} for each item of one of the user's playlists, in
|
||||
playlist order. `item_id` is the playlistItem resource id, needed to delete or
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue