From eb922cb58989894fb7707c7ceec16b68fa6d372b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 02:22:36 +0200 Subject: [PATCH] feat(channels): surface full-history pull + honest stored/total count on the channel page (S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading state until done. Backend: channel_detail now returns deep_requested/backfill_done. --- backend/app/routes/channels.py | 26 ++++++++++--- frontend/src/components/ChannelPage.tsx | 46 ++++++++++++++++++++++- frontend/src/i18n/locales/en/channel.json | 7 +++- frontend/src/i18n/locales/hu/channel.json | 7 +++- frontend/src/lib/api.ts | 2 + 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index dd04589..3325d50 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -223,7 +223,13 @@ def _channel_summary(ch: Channel) -> dict: def _channel_detail_dict( - channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int + channel: Channel, + *, + subscribed: bool, + explored: bool, + blocked: bool, + stored: int, + deep_requested: bool, ) -> dict: return { "id": channel.id, @@ -245,6 +251,10 @@ def _channel_detail_dict( "explored": explored, "blocked": blocked, "stored_videos": stored, + # Deep-backfill state, so the channel page can offer "get full history" when the catalog + # holds only the recent-backfill/RSS subset (video_count is YouTube's raw total). + "deep_requested": deep_requested, + "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, } @@ -274,11 +284,12 @@ def channel_detail( except YouTubeError as exc: log.warning("channel detail enrich failed (%s): %s", channel_id, exc) db.rollback() - subscribed = db.execute( - select(Subscription.id).where( + sub = db.execute( + select(Subscription).where( Subscription.user_id == user.id, Subscription.channel_id == channel_id ) - ).first() is not None + ).scalar_one_or_none() + subscribed = sub is not None explored = db.execute( select(ExploredChannel.id).where( ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id @@ -287,7 +298,12 @@ def channel_detail( 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) + channel, + subscribed=subscribed, + explored=explored, + blocked=blocked, + stored=int(stored), + deep_requested=bool(sub.deep_requested) if sub else False, ) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 320e800..a8bef92 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -8,6 +8,7 @@ import { Ban, Check, ExternalLink, + History, Loader2, Plus, RefreshCw, @@ -150,6 +151,21 @@ export default function ChannelPage({ }, onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), }); + // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's + // uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full + // back-catalog pager. Same trigger the channel manager uses. + const deepBackfill = useMutation({ + mutationFn: () => api.updateChannel(channelId, { deep_requested: true }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + notify({ level: "info", message: t("channel.fullHistoryQueued") }); + }, + onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), + }); + const onUnsubscribe = async () => { const ok = await confirm({ title: t("channel.unsubTitle"), @@ -191,7 +207,14 @@ export default function ChannelPage({ ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null, - ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null, + // Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the + // gap between the browsable videos and YouTube's raw count is honest rather than looking like + // missing data (the "get full history" button pulls the rest). + ch?.video_count != null + ? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count + ? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count }) + : t("channel.videoCount", { count: ch.video_count }) + : null, ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null, @@ -330,6 +353,27 @@ export default function ChannelPage({ )} + {/* Only the recent subset is stored → offer to pull the channel's full back-catalogue. + Once queued (deep_requested) it shows as loading; hidden once the backfill is done. */} + {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( + + )} {!me.is_demo && (