From fb6f0c5dcb9b04cd534772e89eb6e057f9b799c2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 02:16:42 +0200 Subject: [PATCH 1/2] 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. --- .../versions/0019_user_yt_channel_id.py | 28 +++ backend/app/models.py | 3 + backend/app/routes/channels.py | 147 +++++++++++++- backend/app/youtube/client.py | 26 +++ frontend/src/components/ChannelDiscovery.tsx | 179 ++++++++++++++++++ frontend/src/components/Channels.tsx | 48 ++++- frontend/src/i18n/locales/de/channels.json | 19 ++ frontend/src/i18n/locales/en/channels.json | 19 ++ frontend/src/i18n/locales/hu/channels.json | 19 ++ frontend/src/lib/api.ts | 18 ++ frontend/src/lib/notifications.ts | 15 +- 11 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0019_user_yt_channel_id.py create mode 100644 frontend/src/components/ChannelDiscovery.tsx diff --git a/backend/alembic/versions/0019_user_yt_channel_id.py b/backend/alembic/versions/0019_user_yt_channel_id.py new file mode 100644 index 0000000..3976927 --- /dev/null +++ b/backend/alembic/versions/0019_user_yt_channel_id.py @@ -0,0 +1,28 @@ +"""cache each user's own YouTube channel id + +Revision ID: 0019_user_yt_channel_id +Revises: 0018_maintenance_batch_setting +Create Date: 2026-06-19 + +Adds users.yt_channel_id: the user's own YouTube channel id, resolved lazily from +channels.list?mine=true and cached. Used to exclude the user's own channel from the +playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet. +Purely additive. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0019_user_yt_channel_id" +down_revision: Union[str, None] = "0018_maintenance_batch_setting" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("yt_channel_id", sa.String(length=32), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "yt_channel_id") diff --git a/backend/app/models.py b/backend/app/models.py index fca20f3..f6185a8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -28,6 +28,9 @@ class User(Base): display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") + # The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and + # cached. Used to exclude their own channel from playlist discovery. NULL = unresolved. + yt_channel_id: Mapped[str | None] = mapped_column(String(32)) # The single shared "demo" account: signed into without Google OAuth (via a whitelisted # email on the login page), has no OAuth token / YouTube scope, and its per-user state is # shared by everyone who enters this way. Exactly one such row is expected. diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 309ef24..f21ed47 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -1,6 +1,9 @@ """Channel manager: the user's subscriptions with their personal overrides (priority, hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes (unsubscribe) live behind the optional write scope and are added in a later phase.""" +import logging +from datetime import datetime, timezone + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, func, select from sqlalchemy.orm import Session @@ -8,13 +11,25 @@ from sqlalchemy.orm import Session from app import quota from app.auth import current_user, has_write_scope from app.db import get_db -from app.models import Channel, ChannelTag, Subscription, Tag, User, Video +from app.models import ( + Channel, + ChannelTag, + Playlist, + PlaylistItem, + Subscription, + Tag, + User, + Video, +) from app.routes.admin import admin_user from app.sync.runner import run_recent_backfill +from app.sync.subscriptions import apply_channel_details from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/channels", tags=["channels"]) +log = logging.getLogger("subfeed.api") + @router.get("") def list_channels( @@ -107,6 +122,89 @@ def list_channels( ] +# Cap how many stub channels we enrich per discovery call, to bound first-load latency and +# quota (channels.list = 1 unit / 50). Anything beyond is enriched on subsequent loads. +DISCOVERY_ENRICH_CAP = 200 + + +def _discovery_rows(db: Session, user: User) -> list: + """The user's playlist channels they don't subscribe to (and that aren't their own), + each with how many playlist videos come from it and across how many playlists.""" + subscribed = select(Subscription.channel_id).where(Subscription.user_id == user.id) + q = ( + select( + Channel, + func.count(func.distinct(PlaylistItem.video_id)), + func.count(func.distinct(Playlist.id)), + ) + .select_from(PlaylistItem) + .join(Playlist, Playlist.id == PlaylistItem.playlist_id) + .join(Video, Video.id == PlaylistItem.video_id) + .join(Channel, Channel.id == Video.channel_id) + .where(Playlist.user_id == user.id, Channel.id.not_in(subscribed)) + ) + if user.yt_channel_id: + q = q.where(Channel.id != user.yt_channel_id) + return db.execute( + q.group_by(Channel.id).order_by( + func.count(func.distinct(PlaylistItem.video_id)).desc(), + func.lower(Channel.title), + ) + ).all() + + +@router.get("/discovery") +def discover_channels( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> list[dict]: + """Channels that appear in the user's playlists but that they don't subscribe to (their + own channel excluded). The base list is a local join over the shared catalog. We also + enrich each channel's metadata up front (title/thumbnail/subscriber count) so the user + can judge a channel BEFORE subscribing — but we do NOT pull its videos. Most-present + channels come first; subscribing is a separate action.""" + # Resolve & cache the user's own channel id so it can be excluded — you don't subscribe + # to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it. + if user.yt_channel_id is None and user.token is not None and not user.is_demo: + try: + with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: + own = yt.get_my_channel_id() + if own: + user.yt_channel_id = own + db.commit() + except YouTubeError as exc: + log.warning("discovery: own-channel resolve failed (user %s): %s", user.id, exc) + + rows = _discovery_rows(db, user) + + # Enrich stub channels' metadata (uses the API key — no write scope needed). Re-read + # the rows afterwards so the response carries the fresh subscriber counts/thumbnails. + need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] + if need: + try: + with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: + apply_channel_details(db, yt.get_channels(need)) + db.commit() + rows = _discovery_rows(db, user) + except YouTubeError as exc: + log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc) + db.rollback() + + return [ + { + "id": ch.id, + "title": ch.title, + "handle": ch.handle, + "thumbnail_url": ch.thumbnail_url, + "subscriber_count": ch.subscriber_count, + "video_count": ch.video_count, + "playlist_video_count": int(vid_count), + "playlist_count": int(pl_count), + "details_synced": ch.details_synced_at is not None, + } + for ch, vid_count, pl_count in rows + ] + + def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription: sub = db.execute( select(Subscription).where( @@ -181,6 +279,53 @@ def reset_backfill( return {"id": channel_id, "reset": True} +@router.post("/{channel_id}/subscribe") +def subscribe( + channel_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Subscribe to a channel discovered from the user's playlists. Gated behind the + optional write scope (YouTube subscriptions.insert). Creates the local subscription + with YouTube's returned resource id and enriches the channel's metadata if it's still + a stub, so the new row renders properly in the manager. It does NOT pull the channel's + videos — the scheduler picks those up on its next run, like any subscribed channel.""" + if not has_write_scope(user): + raise HTTPException( + status_code=403, + detail="Enable playlist editing in Settings to subscribe on YouTube.", + ) + channel = db.get(Channel, channel_id) + if channel is None: + raise HTTPException(status_code=404, detail="Unknown channel") + existing = db.execute( + select(Subscription).where( + Subscription.user_id == user.id, Subscription.channel_id == channel_id + ) + ).scalar_one_or_none() + if existing is not None: + raise HTTPException(status_code=409, detail="Already subscribed to this channel.") + try: + with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt: + yt_sub_id = yt.insert_subscription(channel_id) + # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up + # properly right away; no video pull here. + if channel.details_synced_at is None: + apply_channel_details(db, yt.get_channels([channel_id])) + except YouTubeError as exc: + raise HTTPException(status_code=502, detail=f"YouTube subscribe failed: {exc}") + db.add( + Subscription( + user_id=user.id, + channel_id=channel_id, + yt_subscription_id=yt_sub_id or None, + subscribed_at=datetime.now(timezone.utc), + ) + ) + db.commit() + return {"subscribed": channel_id} + + @router.delete("/{channel_id}/subscription") def unsubscribe( channel_id: str, diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index c65c333..4b084e8 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -168,6 +168,15 @@ class YouTubeClient: if not page_token: break + def get_my_channel_id(self) -> str | None: + """The authenticated user's own channel id (channels.list?mine=true). OAuth only; + 1 quota unit. Returns None if the account has no channel.""" + data = self._get( + "channels", {"part": "id", "mine": "true", "maxResults": 1}, allow_key=False + ) + items = data.get("items", []) + return items[0].get("id") if items else None + def get_channels(self, channel_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(channel_ids, 50): @@ -213,6 +222,23 @@ class YouTubeClient: f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" ) + def insert_subscription(self, channel_id: str) -> str: + """Subscribe to a channel on YouTube. Requires the write scope and the user's + OAuth token (never the public API key). subscriptions.insert costs 50 quota + units. Returns the new subscription resource id — store it as + `yt_subscription_id` so the channel can later be unsubscribed.""" + data = self._write( + "POST", + "subscriptions", + params={"part": "snippet"}, + json={ + "snippet": { + "resourceId": {"kind": "youtube#channel", "channelId": channel_id} + } + }, + ) + return data.get("id", "") + def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]: """Yield {item_id, video_id} for each item of one of the user's playlists, in playlist order. `item_id` is the playlistItem resource id, needed to delete or diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx new file mode 100644 index 0000000..b545f45 --- /dev/null +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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[] = [ + { + 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) => , + }, + { + key: "subs", + header: t("channels.cols.subs"), + align: "right", + nowrap: true, + sortable: true, + sortValue: (c) => c.subscriber_count ?? -1, + render: (c) => ( + + {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} + + ), + }, + { + key: "inPlaylists", + header: t("channels.discovery.cols.inPlaylists"), + align: "right", + nowrap: true, + sortable: true, + sortValue: (c) => c.playlist_video_count, + render: (c) => ( + + + {c.playlist_video_count} / {c.playlist_count} + + + ), + }, + { + key: "actions", + header: t("channels.cols.actions"), + align: "right", + nowrap: true, + width: "120px", + cardLabel: false, + render: (c) => ( + + + + ), + }, + ]; + + return ( +
+

+ {t("channels.discovery.intro")} +

+ {query.isLoading ? ( +
{t("channels.discovery.loading")}
+ ) : ( + c.id} + persistKey="siftlode.channelDiscoveryTable" + controlsPosition="top" + emptyText={t("channels.discovery.empty")} + /> + )} +
+ ); +} + +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 ( +
+ + {c.title ?? c.id} + + + + + +
+ ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ba06732..48fe03b 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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(() => + 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({ ); + const tabs = ( +
+
+ {(["subscribed", "discovery"] as ChannelsView[]).map((v) => ( + + ))} +
+
+ ); + + if (view === "discovery") { + return ( + <> + {tabs} + + + ); + } + return ( -
+ <> + {tabs} +
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering now lives in the table headers). */}
@@ -468,6 +513,7 @@ export default function Channels({ /> )}
+ ); } diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index b559115..673ef86 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -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", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 80d1b11..e2da771 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 3b43b30..6ab23bc 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 33aae14..448d593 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => + req("/api/channels/discovery"), + subscribeChannel: (id: string) => + req(`/api/channels/${id}/subscribe`, { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), // --- playlists --- diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 717533b..583637f 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -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(); } From 75ecf1914794c278e3f843b6e345a2c843ecc7f4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 02:16:50 +0200 Subject: [PATCH 2/2] feat(notifications): link the subscribe activity to the channel The "Subscribed on YouTube" inbox entry now names the channel and offers two convenience links that survive a reload (driven by the typed payload, not the live callback): "Channel manager" (jumps to the manager focused on that channel) and "Open on YouTube". Wires the panel to the app's focus-channel navigation; trilingual strings. --- frontend/src/App.tsx | 2 +- .../src/components/NotificationsPanel.tsx | 29 ++++++++++++++++++- .../src/i18n/locales/de/notifications.json | 2 ++ .../src/i18n/locales/en/notifications.json | 2 ++ .../src/i18n/locales/hu/notifications.json | 2 ++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a6b52b..306aab0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -451,7 +451,7 @@ export default function App() { ) : page === "playlists" ? ( ) : page === "notifications" ? ( - + ) : page === "settings" ? ( void; setPage: (p: Page) => void; + onFocusChannel: (name: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -183,6 +186,7 @@ export default function NotificationsPanel({ t={t} onFind={locateHidden} onRevert={revertState} + onFocusChannel={onFocusChannel} onRemove={() => removeClient(n.id)} /> ))} @@ -287,18 +291,22 @@ function ClientActivityRow({ t, onFind, onRevert, + onFocusChannel, onRemove, }: { n: ClientNotif; t: (k: string, o?: any) => string; onFind: (meta: VideoHiddenMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; + onFocusChannel: (name: string) => void; onRemove: () => void; }) { const { icon: Icon, color } = LEVEL_STYLE[n.level]; const needsAction = n.requiresInteraction && !n.dismissed; const hidden = n.meta?.kind === "video-hidden" ? n.meta : null; const watched = n.meta?.kind === "video-watched" ? n.meta : null; + const subscribed: ChannelSubscribedMeta | null = + n.meta?.kind === "channel-subscribed" ? n.meta : null; return (
+ ) : subscribed ? ( +
+ + + + {t("notifications.openOnYouTube")} + +
) : ( n.action && diff --git a/frontend/src/i18n/locales/de/notifications.json b/frontend/src/i18n/locales/de/notifications.json index f08acb7..ec7f745 100644 --- a/frontend/src/i18n/locales/de/notifications.json +++ b/frontend/src/i18n/locales/de/notifications.json @@ -8,6 +8,8 @@ "findInFeed": "Im Feed finden", "unhide": "Einblenden", "unwatch": "Nicht angesehen", + "openInManager": "Kanalverwaltung", + "openOnYouTube": "Auf YouTube öffnen", "unhidden": "Eingeblendet „{{title}}”", "unwatched": "Als nicht angesehen markiert „{{title}}”", "time": { diff --git a/frontend/src/i18n/locales/en/notifications.json b/frontend/src/i18n/locales/en/notifications.json index 352bc22..08a23dd 100644 --- a/frontend/src/i18n/locales/en/notifications.json +++ b/frontend/src/i18n/locales/en/notifications.json @@ -8,6 +8,8 @@ "findInFeed": "Find in feed", "unhide": "Unhide", "unwatch": "Unwatch", + "openInManager": "Channel manager", + "openOnYouTube": "Open on YouTube", "unhidden": "Unhidden “{{title}}”", "unwatched": "Unwatched “{{title}}”", "time": { diff --git a/frontend/src/i18n/locales/hu/notifications.json b/frontend/src/i18n/locales/hu/notifications.json index 41c82c3..e305c4b 100644 --- a/frontend/src/i18n/locales/hu/notifications.json +++ b/frontend/src/i18n/locales/hu/notifications.json @@ -8,6 +8,8 @@ "findInFeed": "Keresés a hírfolyamban", "unhide": "Megjelenítés", "unwatch": "Megtekintés visszavonása", + "openInManager": "Csatornakezelő", + "openOnYouTube": "Megnyitás a YouTube-on", "unhidden": "Megjelenítve: „{{title}}”", "unwatched": "Megtekintés visszavonva: „{{title}}”", "time": {