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

@ -24,7 +24,12 @@ export type VideoWatchedMeta = {
videoId: string;
title: string;
};
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta;
export type ChannelSubscribedMeta = {
kind: "channel-subscribed";
channelId: string;
channelName: string;
};
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta | ChannelSubscribedMeta;
export interface Notification {
id: number;
@ -239,7 +244,13 @@ export function remove(id: number): void {
* now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */
export function resolveVideo(videoId: string): void {
const before = items.length;
items = items.filter((n) => n.meta?.videoId !== videoId);
items = items.filter(
(n) =>
!(
(n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched") &&
n.meta.videoId === videoId
)
);
if (items.length !== before) emit();
}