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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,25 @@
|
|||
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
|
||||
"backfillEverything": "Alles nachladen",
|
||||
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
|
||||
"tabs": {
|
||||
"subscribed": "Abonnements",
|
||||
"discovery": "Aus Playlists entdecken"
|
||||
},
|
||||
"discovery": {
|
||||
"intro": "Kanäle, die in deinen Playlists vorkommen, die du aber nicht abonniert hast. Abonniere sie, um ihnen zu folgen — ihre neuen Uploads erscheinen dann in deinem Feed (das Abonnieren kostet etwas YouTube-Kontingent; vorhandene Videos werden nicht erneut geladen).",
|
||||
"loading": "Kanäle werden gesucht…",
|
||||
"empty": "Keine neuen Kanäle — jeden Kanal in deinen Playlists abonnierst du bereits.",
|
||||
"subscribe": "Abonnieren",
|
||||
"subscribeHint": "Diesen Kanal auf YouTube abonnieren (ändert dein echtes Konto; verbraucht etwas API-Kontingent). Seine Videos kommen bei der nächsten Hintergrund-Synchronisierung.",
|
||||
"needWriteHint": "Aktiviere das Bearbeiten von Playlists in den Einstellungen, um auf YouTube zu abonnieren.",
|
||||
"subscribedTitle": "Auf YouTube abonniert",
|
||||
"subscribedBody": "Du folgst jetzt {{name}} — neue Uploads erscheinen in deinem Feed.",
|
||||
"subscribeFailed": "Abonnieren fehlgeschlagen",
|
||||
"cols": {
|
||||
"inPlaylists": "In Playlists",
|
||||
"inPlaylistsHint": "{{videos}} Video(s) von diesem Kanal in {{playlists}} deiner Playlist(s)."
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle",
|
||||
"needsFull": "Verlauf unvollständig",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,25 @@
|
|||
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
|
||||
"backfillEverything": "Backfill everything",
|
||||
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
|
||||
"tabs": {
|
||||
"subscribed": "Subscriptions",
|
||||
"discovery": "Discover from playlists"
|
||||
},
|
||||
"discovery": {
|
||||
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
|
||||
"loading": "Finding channels…",
|
||||
"empty": "No new channels — every channel in your playlists is one you already follow.",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeHint": "Subscribe to this channel on YouTube (changes your real account; uses a little API quota). Its videos arrive on the next background sync.",
|
||||
"needWriteHint": "Enable playlist editing in Settings to subscribe on YouTube.",
|
||||
"subscribedTitle": "Subscribed on YouTube",
|
||||
"subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your feed.",
|
||||
"subscribeFailed": "Subscribe failed",
|
||||
"cols": {
|
||||
"inPlaylists": "In playlists",
|
||||
"inPlaylistsHint": "{{videos}} video(s) from this channel across {{playlists}} of your playlist(s)."
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"needsFull": "Needs full history",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,25 @@
|
|||
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
||||
"backfillEverything": "Minden letöltése",
|
||||
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
|
||||
"tabs": {
|
||||
"subscribed": "Feliratkozások",
|
||||
"discovery": "Felfedezés a lejátszási listákból"
|
||||
},
|
||||
"discovery": {
|
||||
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
|
||||
"loading": "Csatornák keresése…",
|
||||
"empty": "Nincs új csatorna — a lejátszási listáidban minden csatornát már követsz.",
|
||||
"subscribe": "Feliratkozás",
|
||||
"subscribeHint": "Feliratkozás erre a csatornára a YouTube-on (a valódi fiókodat módosítja; kevés API-kvótát használ). A videói a következő háttér-szinkronnál érkeznek.",
|
||||
"needWriteHint": "Engedélyezd a lejátszási listák szerkesztését a Beállításokban a feliratkozáshoz.",
|
||||
"subscribedTitle": "Feliratkozva a YouTube-on",
|
||||
"subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a hírfolyamodban.",
|
||||
"subscribeFailed": "A feliratkozás sikertelen",
|
||||
"cols": {
|
||||
"inPlaylists": "Listákban",
|
||||
"inPlaylistsHint": "{{videos}} videó ettől a csatornától, {{playlists}} lejátszási listádban."
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"all": "Mind",
|
||||
"needsFull": "Hiányos előzmény",
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue