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:
2026-06-19 02:16:42 +02:00
parent b5141ab112
commit fb6f0c5dcb
11 changed files with 517 additions and 4 deletions
+26
View File
@@ -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