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

@ -0,0 +1,179 @@
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, UserPlus } from "lucide-react";
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
import { formatViews } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
export default function ChannelDiscovery({
canWrite,
onOpenWizard,
}: {
canWrite: boolean;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const query = useQuery({
queryKey: ["discovered-channels"],
queryFn: api.discoveredChannels,
});
const subscribe = useMutation({
mutationFn: (c: DiscoveredChannel) => api.subscribeChannel(c.id),
onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live
// callbacks are gone (see ChannelSubscribedMeta).
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
});
},
onError: (err: unknown) => {
// A 403 means the user hasn't granted the write scope — offer to connect instead of
// a vague failure (mirrors the subscriptions tab).
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t("channels.discovery.subscribeFailed") });
}
},
});
const rows = query.data ?? [];
const columns: Column<DiscoveredChannel>[] = [
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
render: (c) => <DiscoveryNameCell c={c} />,
},
{
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
),
},
{
key: "inPlaylists",
header: t("channels.discovery.cols.inPlaylists"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.playlist_video_count,
render: (c) => (
<Tooltip
hint={t("channels.discovery.cols.inPlaylistsHint", {
videos: c.playlist_video_count,
playlists: c.playlist_count,
})}
>
<span className="text-muted tabular-nums cursor-help">
{c.playlist_video_count} / {c.playlist_count}
</span>
</Tooltip>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "right",
nowrap: true,
width: "120px",
cardLabel: false,
render: (c) => (
<Tooltip
hint={
canWrite
? t("channels.discovery.subscribeHint")
: t("channels.discovery.needWriteHint")
}
>
<button
onClick={() => subscribe.mutate(c)}
disabled={!canWrite || subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</button>
</Tooltip>
),
},
];
return (
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted mb-4 leading-relaxed">
{t("channels.discovery.intro")}
</p>
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey="siftlode.channelDiscoveryTable"
controlsPosition="top"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
);
}
function DiscoveryNameCell({ c }: { c: DiscoveredChannel }) {
const { t } = useTranslation();
const ytUrl = c.handle
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
: `https://www.youtube.com/channel/${c.id}`;
return (
<div className="flex items-center gap-2 min-w-0">
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
<span className="text-sm font-medium truncate min-w-0">{c.title ?? c.id}</span>
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
</div>
);
}

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>
</>
);
}