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 b5141ab112
commit fb6f0c5dcb
11 changed files with 517 additions and 4 deletions

View file

@ -362,6 +362,20 @@ export interface ManagedChannel {
backfill_done: boolean;
}
// A channel that shows up in the user's playlists but that they don't subscribe to —
// surfaced in the Channel manager's Discovery tab so they can subscribe in one click.
export interface DiscoveredChannel {
id: string;
title: string | null;
handle: string | null;
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
playlist_video_count: number; // how many of the user's playlist videos are from here
playlist_count: number; // across how many of their playlists
details_synced: boolean;
}
export interface SchedulerJob {
id: string;
interval_minutes: number;
@ -485,6 +499,10 @@ export const api = {
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
unsubscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
discoveredChannels: (): Promise<DiscoveredChannel[]> =>
req("/api/channels/discovery"),
subscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- playlists ---