diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 6134e23..fc0b8b3 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -66,33 +66,49 @@ def _filtered_query( include_shorts: bool, include_live: bool, show: str, + scope: str = "my", ) -> tuple[Select, object]: """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. - Returns the column-bearing select plus the watch-status expression for sorting.""" + Returns the column-bearing select plus the watch-status expression for sorting. + + `scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions. + `scope="all"` shows every video in the shared catalog (any user's ingested channels); + the subscription is then only LEFT-joined so per-channel priority still resolves for + channels the user happens to be subscribed to. Per-user watch state stays private in + either mode via the VideoState outer join.""" state = aliased(VideoState) status_expr = func.coalesce(state.status, "new") position_expr = func.coalesce(state.position_seconds, 0) - query = ( - select( - Video.id, - Video.title, - Video.channel_id, - Channel.title.label("channel_title"), - Channel.thumbnail_url.label("channel_thumbnail"), - Channel.handle.label("channel_handle"), - Video.published_at, - Video.thumbnail_url, - Video.duration_seconds, - Video.view_count, - Video.is_short, - Video.live_status, - status_expr.label("status"), - position_expr.label("position_seconds"), + query = select( + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + status_expr.label("status"), + position_expr.label("position_seconds"), + ).join(Channel, Channel.id == Video.channel_id) + + if scope == "all": + # Whole shared catalog; subscription is optional (only for priority sort). + query = query.outerjoin( + Subscription, + and_( + Subscription.channel_id == Video.channel_id, + Subscription.user_id == user.id, + ), ) - .join(Channel, Channel.id == Video.channel_id) + else: # Only channels this user is subscribed to (and hasn't hidden). - .join( + query = query.join( Subscription, and_( Subscription.channel_id == Video.channel_id, @@ -100,7 +116,9 @@ def _filtered_query( Subscription.hidden.is_(False), ), ) - .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) + + query = query.outerjoin( + state, and_(state.video_id == Video.id, state.user_id == user.id) ) if channel_id: @@ -199,6 +217,7 @@ def _feed_params( include_shorts: bool = False, include_live: bool = False, show: str = "unwatched", + scope: str = "my", ) -> dict: return { "tags": tags, @@ -214,6 +233,7 @@ def _feed_params( "include_shorts": include_shorts, "include_live": include_live, "show": show, + "scope": scope, } @@ -226,7 +246,8 @@ SORTS = { "title": func.lower(Video.title).asc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(), # Your per-channel priority (set in the channel manager), newest first within a tier. - "priority": Subscription.priority.desc(), + # coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. + "priority": func.coalesce(Subscription.priority, 0).desc(), } @@ -243,7 +264,8 @@ def get_feed( query, _status = _filtered_query(db, user, **params) if sort == "priority": query = query.order_by( - Subscription.priority.desc(), Video.published_at.desc().nulls_last() + func.coalesce(Subscription.priority, 0).desc(), + Video.published_at.desc().nulls_last(), ) else: order = SORTS.get(sort) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e890b5..ff3dfcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ const DEFAULT_FILTERS: FeedFilters = { tagMode: "or", q: "", sort: "newest", + scope: "my", includeNormal: true, includeShorts: false, includeLive: false, diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 32f4785..2e32004 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -444,7 +444,7 @@ function ChannelRow({ - {t("channels.row.fullHistoryQueued")} + {t("channels.row.fullHistoryComing")} ) : ( diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 09a05cf..7380bb0 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -148,7 +148,9 @@ export default function Feed({ if (query.isLoading) return
{t("feed.loading")}
; if (query.isError) return
{t("feed.loadError")}
; if (items.length === 0) { - if (!canRead) + // No YouTube connection and looking at their own (empty) subscriptions: offer both + // connecting YouTube and just browsing the shared library (no read scope needed). + if (!canRead && filters.scope === "my") return (
@@ -160,6 +162,12 @@ export default function Feed({ > {t("feed.setUp")} +
); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7474091..15dc8d0 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BarChart3, Home, Info, LogOut, Search, Settings, Shield, Tv } from "lucide-react"; +import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; @@ -49,6 +49,35 @@ export default function Header({ + {page === "feed" && ( +
+ {(["my", "all"] as const).map((s) => ( + + ))} +
+ )} + {page === "feed" ? (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0a95fdb..3ab6c97 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { EyeOff, GripVertical, Pencil, + RefreshCw, RotateCcw, X, } from "lucide-react"; @@ -48,6 +49,9 @@ const SORT_IDS = [ const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"]; +// Fresh shuffle token for the "surprise me" sort. +const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); + const DATE_PRESETS: { days: number; key: string }[] = [ { days: 1, key: "24h" }, { days: 7, key: "1week" }, @@ -56,8 +60,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [ { days: 365, key: "1year" }, ]; -// Filter values owned by the sidebar (everything except the header search `q`). -const DEFAULT_SIDEBAR_FILTERS: Omit = { +// Filter values owned by the sidebar (everything except the header search `q` and the +// `scope` mode, both preserved across a "Clear all"). +const DEFAULT_SIDEBAR_FILTERS: Omit = { tags: [], tagMode: "or", sort: "newest", @@ -81,13 +86,20 @@ function TagChip({ ); } @@ -159,7 +171,7 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q }); + setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope }); } const available: Record = { @@ -208,17 +220,38 @@ export default function Sidebar({ ); case "sort": return ( - +
+ + {filters.sort === "shuffle" && ( + + )} +
); case "date": return ( diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 447238e..2e5c368 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -57,6 +57,13 @@ export default function SyncStatus({ {t("header.sync.syncing", { count: syncing })} + ) : notFull > 0 ? ( + // Recent uploads are in, but the scheduler is still backfilling full history — + // so "all synced" would be misleading. Reflect the deep work as active. + + + {t("header.sync.backfillingHistory")} + ) : ( {t("header.sync.allSynced")} )} @@ -74,9 +81,10 @@ export default function SyncStatus({ )} - {/* Pause only makes sense when there's work to pause; Resume must always show - while paused so it can be lifted. Hidden entirely when idle and not paused. */} - {isAdmin && (data.paused || syncing > 0) && ( + {/* Pause only makes sense when there's work to pause (recent OR deep backfill); + Resume must always show while paused so it can be lifted. Hidden entirely when + idle and not paused. */} + {isAdmin && (data.paused || syncing > 0 || notFull > 0) && (