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
5ae12a1752
commit
a8ba66007f
11 changed files with 517 additions and 4 deletions
|
|
@ -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