diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index adfa4b8..bdb5230 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -101,12 +101,7 @@ def list_channels( 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, + **_channel_summary(ch), "stored_videos": (agg.get(ch.id) or {}).get("stored", 0), "last_video_at": (agg.get(ch.id) or {}).get("last_video_at"), "total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0), @@ -195,12 +190,7 @@ def discover_channels( 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, + **_channel_summary(ch), "playlist_video_count": int(vid_count), "playlist_count": int(pl_count), "details_synced": ch.details_synced_at is not None, @@ -209,6 +199,18 @@ def discover_channels( ] +def _channel_summary(ch: Channel) -> dict: + """The channel fields common to the list and discovery projections.""" + 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, + } + + def _channel_detail_dict( channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int ) -> dict: @@ -268,11 +270,7 @@ def channel_detail( ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id ) ).first() is not None - blocked = db.execute( - select(BlockedChannel.id).where( - BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id - ) - ).first() is not None + blocked = _is_blocked(db, user, channel_id) stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 return _channel_detail_dict( channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored) @@ -295,11 +293,7 @@ def explore_channel( channel = db.get(Channel, channel_id) if channel is None: raise HTTPException(status_code=404, detail="Unknown channel") - if db.execute( - select(BlockedChannel.id).where( - BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id - ) - ).first() is not None: + if _is_blocked(db, user, channel_id): raise HTTPException(status_code=403, detail="You've blocked this channel.") if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"): raise HTTPException( @@ -359,6 +353,19 @@ def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription return sub +def _is_blocked(db: Session, user: User, channel_id: str) -> bool: + """Whether this user has blocked this channel.""" + return ( + db.execute( + select(BlockedChannel.id).where( + BlockedChannel.user_id == user.id, + BlockedChannel.channel_id == channel_id, + ) + ).first() + is not None + ) + + @router.patch("/{channel_id}") def update_channel( channel_id: str, @@ -546,12 +553,7 @@ def block_channel( un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course.""" if db.get(Channel, channel_id) is None: raise HTTPException(status_code=404, detail="Unknown channel") - exists = db.execute( - select(BlockedChannel.id).where( - BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id - ) - ).first() - if exists is None: + if not _is_blocked(db, user, channel_id): db.add(BlockedChannel(user_id=user.id, channel_id=channel_id)) # Stop any active exploration of it so it can be cleaned up. db.execute( diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index f51a7fe..f4ddb7b 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -1,10 +1,11 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserPlus } from "lucide-react"; -import { api, HttpError, type DiscoveredChannel } from "../lib/api"; -import { accountKey } from "../lib/storage"; -import { formatViews } from "../lib/format"; +import { api, type DiscoveredChannel } from "../lib/api"; +import { accountKey, LS } from "../lib/storage"; +import { formatCountOrDash } from "../lib/format"; import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import Tooltip from "./Tooltip"; import ChannelLink from "./ChannelLink"; import DataTable, { type Column } from "./DataTable"; @@ -48,19 +49,8 @@ export default function ChannelDiscovery({ 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") }); - } - }, + onError: (err: unknown) => + notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), }); // Subscribing changes the user's real YouTube account and spends a little quota — confirm @@ -96,9 +86,7 @@ export default function ChannelDiscovery({ sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( - - {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} - + {formatCountOrDash(c.subscriber_count)} ), }, { @@ -109,9 +97,7 @@ export default function ChannelDiscovery({ sortable: true, sortValue: (c) => c.video_count ?? -1, render: (c) => ( - - {c.video_count != null ? formatViews(c.video_count) : "—"} - + {formatCountOrDash(c.video_count)} ), }, { @@ -174,7 +160,7 @@ export default function ChannelDiscovery({ rows={rows} columns={columns} rowKey={(c) => c.id} - persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined} + persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} controlsPosition="top" emptyText={t("channels.discovery.empty")} /> diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 2c81407..08e259b 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -6,6 +6,7 @@ import Avatar from "./Avatar"; import Feed from "./Feed"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { api, type FeedFilters, type Me } from "../lib/api"; import { channelYouTubeUrl, formatViews } from "../lib/format"; @@ -89,8 +90,14 @@ export default function ChannelPage({ qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["channels"] }); + // Match ChannelDiscovery: the channel leaves discovery and the manager's stats move. + qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: ["discovered-channels"] }); notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); }, + // A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle + // here, so no Connect button — but the user at least sees why it didn't work). + onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")), }); const unsubscribe = useMutation({ mutationFn: () => api.unsubscribeChannel(channelId), diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 0729a4d..4b08b08 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -16,10 +16,11 @@ import { UserMinus, X, } from "lucide-react"; -import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; -import { accountKey } from "../lib/storage"; +import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { accountKey, LS } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; -import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; +import { formatCountOrDash, formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; @@ -73,19 +74,6 @@ export default function Channels({ const qc = useQueryClient(); const confirm = useConfirm(); - // 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) => { - 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(fallbackKey) }); - } - }; const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); @@ -160,7 +148,7 @@ export default function Channels({ invalidate(); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); }, - onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), + onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), }); const unsubscribe = useMutation({ mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), @@ -169,7 +157,7 @@ export default function Channels({ qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); }, - onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), + onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), }); const resetBackfill = useMutation({ mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), @@ -178,7 +166,7 @@ export default function Channels({ qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); }, - onError: (e) => notifyActionError(e, "channels.notify.resetFailed"), + onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), @@ -190,7 +178,7 @@ export default function Channels({ message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, - onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), + onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), }); // Channel-name search + tag-chip filtering, applied client-side over the status-filtered list @@ -294,9 +282,7 @@ export default function Channels({ sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( - - {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} - + {formatCountOrDash(c.subscriber_count)} ), }, { @@ -570,7 +556,7 @@ export default function Channels({ rows={visibleChannels} columns={columns} rowKey={(c) => c.id} - persistKey={accountKey("siftlode.channelsTable") ?? undefined} + persistKey={accountKey(LS.channelsTable) ?? undefined} controlsPosition="top" controlsLeading={statusChips} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index d653eef..a1cc7fc 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -1,6 +1,5 @@ { "intro": "Lege die <0>Priorität eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags an, um den Feed zu filtern, oder <2>blende einen Kanal <2>aus, um ihn ohne Abbestellung aus dem Feed zu entfernen.", - "filterPlaceholder": "Kanäle filtern…", "syncSubscriptions": "Abos von YouTube lesen", "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", @@ -35,9 +34,7 @@ "tags": { "yourTags": "Deine Tags", "manage": "Tags verwalten", - "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)", - "newTag": "neuer Tag", - "createTag": "Tag erstellen" + "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)" }, "loading": "Kanäle werden geladen…", "empty": "Keine Kanäle.", @@ -74,8 +71,6 @@ "quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)." }, "row": { - "stored": "{{formatted}} gespeichert", - "subs": "{{formatted}} Abonnenten", "openOnYouTube": "Auf YouTube öffnen", "editTags": "Tags bearbeiten", "filterFeedByTag": "Feed nach „{{name}}“ filtern", @@ -98,8 +93,6 @@ "fullHistoryComing": "vollständiger Verlauf unterwegs", "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", "queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.", - "getFullHistory": "vollständigen Verlauf laden", - "getFullHistoryHint": "Bisher nur neueste Uploads. Klicke, um den gesamten Katalog dieses Kanals anzufordern (ältere Videos + vollständige Suche).", "hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.", "hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.", "unhide": "Einblenden", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 5bd7c45..faf4ea0 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -1,6 +1,5 @@ { "intro": "Set a channel's <0>priority to push its videos up when you sort by “Channel priority”, attach your own <1>tags to filter the feed, or <2>hide a channel to drop it from the feed without unsubscribing.", - "filterPlaceholder": "Filter channels…", "syncSubscriptions": "Read subscriptions from YouTube", "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", @@ -35,9 +34,7 @@ "tags": { "yourTags": "Your tags", "manage": "Manage tags", - "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", - "newTag": "new tag", - "createTag": "Create tag" + "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)" }, "loading": "Loading channels…", "empty": "No channels.", @@ -74,8 +71,6 @@ "quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)." }, "row": { - "stored": "{{formatted}} stored", - "subs": "{{formatted}} subs", "openOnYouTube": "Open on YouTube", "editTags": "Edit tags", "filterFeedByTag": "Filter the feed by “{{name}}”", @@ -98,8 +93,6 @@ "fullHistoryComing": "full history coming", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", - "getFullHistory": "get full history", - "getFullHistoryHint": "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).", "hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.", "hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.", "unhide": "Unhide", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 5ff911f..f533397 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -1,6 +1,5 @@ { "intro": "Állíts be egy csatornának <0>prioritást, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket a hírfolyam szűréséhez, vagy <2>rejts el egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.", - "filterPlaceholder": "Csatornák szűrése…", "syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról", "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", @@ -35,9 +34,7 @@ "tags": { "yourTags": "Címkéid", "manage": "Címkék kezelése", - "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", - "newTag": "új címke", - "createTag": "Címke létrehozása" + "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)" }, "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.", @@ -74,8 +71,6 @@ "quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)." }, "row": { - "stored": "{{formatted}} tárolt", - "subs": "{{formatted}} feliratkozó", "openOnYouTube": "Megnyitás a YouTube-on", "editTags": "Címkék szerkesztése", "filterFeedByTag": "Hírfolyam szűrése: „{{name}}”", @@ -98,8 +93,6 @@ "fullHistoryComing": "teljes előzmény úton", "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", - "getFullHistory": "teljes előzmény lekérése", - "getFullHistoryHint": "Egyelőre csak a legutóbbi feltöltések. Kattints a csatorna teljes archívumának lekéréséhez (régebbi videók + teljes keresés).", "hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.", "hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.", "unhide": "Megjelenítés", diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 04b7700..84afec6 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -101,3 +101,8 @@ export function formatViews(n: number | null): string { if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; return String(n); } + +// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null). +export function formatCountOrDash(n: number | null | undefined): string { + return n != null ? formatViews(n) : "—"; +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index b7babae..7a3aa2d 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -16,6 +16,8 @@ export const LS = { perfMode: "siftlode.perfMode", channelFilter: "siftlode.channelFilter", channelsView: "siftlode.channelsView", + channelsTable: "siftlode.channelsTable", + channelDiscoveryTable: "siftlode.channelDiscoveryTable", settingsTab: "siftlode.settingsTab", statsTab: "siftlode.statsTab", adminUsersTab: "siftlode.adminUsersTab", diff --git a/frontend/src/lib/youtubeErrors.ts b/frontend/src/lib/youtubeErrors.ts new file mode 100644 index 0000000..5d8440e --- /dev/null +++ b/frontend/src/lib/youtubeErrors.ts @@ -0,0 +1,25 @@ +import { HttpError } from "./api"; +import { notify } from "./notifications"; +import i18n from "../i18n"; + +// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't +// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass +// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the +// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error. +export function notifyYouTubeActionError( + err: unknown, + fallbackMessage: string, + onConnect?: () => void, +): void { + if (err instanceof HttpError && err.status === 403) { + notify({ + level: "error", + message: i18n.t("channels.notify.needYouTube"), + action: onConnect + ? { label: i18n.t("channels.notify.connect"), onClick: onConnect } + : undefined, + }); + } else { + notify({ level: "error", message: fallbackMessage }); + } +}