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/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 6e37c9a..3ab6c97 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -60,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", @@ -170,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 = { diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json index 07e9185..3a46ab8 100644 --- a/frontend/src/i18n/locales/de/feed.json +++ b/frontend/src/i18n/locales/de/feed.json @@ -4,6 +4,7 @@ "emptyTitle": "Dein Feed ist leer", "emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.", "setUp": "Meinen Feed einrichten", + "browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen", "noMatches": "Keine Videos entsprechen diesen Filtern.", "videoCount_one": "{{formattedCount}} Video", "videoCount_other": "{{formattedCount}} Videos", diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 4465368..b634f4f 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -3,6 +3,13 @@ "searchPlaceholder": "Deine Abos durchsuchen…", "channelManager": "Kanalverwaltung", "usageStats": "Nutzung & Statistik", + "scope": { + "label": "Feed-Quelle", + "my": "Meine", + "myTip": "Nur Videos aus deinen eigenen Abos", + "all": "Bibliothek", + "allTip": "Alle Videos im gemeinsamen Katalog" + }, "account": { "admin": "Admin", "feed": "Feed", diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 571247c..8e30dda 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -4,6 +4,7 @@ "emptyTitle": "Your feed is empty", "emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.", "setUp": "Set up my feed", + "browseShared": "Or just browse the shared library", "noMatches": "No videos match these filters.", "videoCount_one": "{{formattedCount}} video", "videoCount_other": "{{formattedCount}} videos", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 7cbe22d..d01e782 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -3,6 +3,13 @@ "searchPlaceholder": "Search your subscriptions…", "channelManager": "Channel manager", "usageStats": "Usage & stats", + "scope": { + "label": "Feed source", + "my": "Mine", + "myTip": "Only videos from your own subscriptions", + "all": "Library", + "allTip": "Every video in the shared catalog" + }, "account": { "admin": "Admin", "feed": "Feed", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 0c30c5e..2995492 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -4,6 +4,7 @@ "emptyTitle": "A hírfolyamod üres", "emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.", "setUp": "Hírfolyam beállítása", + "browseShared": "Vagy csak böngészd a közös könyvtárat", "noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.", "videoCount_one": "{{formattedCount}} videó", "videoCount_other": "{{formattedCount}} videó", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 44dcb04..535fad8 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -3,6 +3,13 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "channelManager": "Csatornakezelő", "usageStats": "Használat és statisztika", + "scope": { + "label": "Hírfolyam forrása", + "my": "Sajátom", + "myTip": "Csak a saját feliratkozásaid videói", + "all": "Könyvtár", + "allTip": "A közös könyvtár összes videója" + }, "account": { "admin": "Adminisztrátor", "feed": "Hírfolyam", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 95a9c43..e813069 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,6 +76,8 @@ export interface FeedFilters { // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the // feed re-queries with a fresh order. Ignored by every other sort. seed?: number; + // "my" = only your subscriptions (default); "all" = the whole shared catalog. + scope: "my" | "all"; includeNormal: boolean; includeShorts: boolean; includeLive: boolean; @@ -157,6 +159,7 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string { if (f.q) p.set("q", f.q); p.set("sort", f.sort); if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); + p.set("scope", f.scope); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); p.set("include_live", String(f.includeLive));