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:
npeter83 2026-06-19 02:16:42 +02:00
parent 5ae12a1752
commit a8ba66007f
11 changed files with 517 additions and 4 deletions

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