Merge feature/s2-shared-library: shared-library feed scope

S2 shared-library feed:
- feat(feed): scope=all query path to browse the whole shared catalog
- feat(feed): Mine/Library header toggle + read-scope-less browse path
This commit is contained in:
npeter83 2026-06-15 04:14:48 +02:00
commit 2604f55dac
12 changed files with 115 additions and 27 deletions

View file

@ -66,15 +66,21 @@ 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(
query = select(
Video.id,
Video.title,
Video.channel_id,
@ -89,10 +95,20 @@ def _filtered_query(
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)

View file

@ -39,6 +39,7 @@ const DEFAULT_FILTERS: FeedFilters = {
tagMode: "or",
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,

View file

@ -148,7 +148,9 @@ export default function Feed({
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
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 (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
@ -160,6 +162,12 @@ export default function Feed({
>
{t("feed.setUp")}
</button>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</div>
</div>
);

View file

@ -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({
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
{page === "feed" && (
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
filters.scope === s
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg"
}`}
>
{s === "my" ? (
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
</button>
))}
</div>
)}
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />

View file

@ -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<FeedFilters, "q"> = {
// 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<FeedFilters, "q" | "scope"> = {
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<WidgetId, boolean> = {

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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ó",

View file

@ -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",

View file

@ -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));