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
179
frontend/src/components/ChannelDiscovery.tsx
Normal file
179
frontend/src/components/ChannelDiscovery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue