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

@ -20,9 +20,12 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
type ChannelsView = "subscribed" | "discovery";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
@ -64,6 +67,17 @@ export default function Channels({
const qc = useQueryClient();
const confirm = useConfirm();
// Which tab is showing: the user's subscriptions, or channels discovered from their
// playlists. Persisted so a reload keeps the active tab (see other siftlode.* keys).
const [view, setView] = useState<ChannelsView>(() =>
localStorage.getItem("siftlode.channelsView") === "discovery"
? "discovery"
: "subscribed"
);
useEffect(() => {
localStorage.setItem("siftlode.channelsView", view);
}, [view]);
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
const notifyActionError = (err: unknown, fallbackKey: string) => {
@ -348,8 +362,39 @@ export default function Channels({
</div>
);
const tabs = (
<div className="px-4 pt-4 max-w-7xl mx-auto">
<div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`text-sm px-3 py-1.5 rounded-full border transition ${
view === v
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(`channels.tabs.${v}`)}
</button>
))}
</div>
</div>
);
if (view === "discovery") {
return (
<>
{tabs}
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
</>
);
}
return (
<div className="p-4 max-w-7xl mx-auto">
<>
{tabs}
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
@ -468,6 +513,7 @@ export default function Channels({
/>
)}
</div>
</>
);
}