feat(feed): add scope=all to browse the whole shared catalog

The feed query was always scoped to the user's own non-hidden subscriptions
via an INNER JOIN on subscriptions. Add a scope param: scope=my (default)
keeps that behaviour; scope=all LEFT-joins the subscription instead, so every
video in the shared catalog shows while per-channel priority still resolves
for channels the user is subscribed to. Per-user watch state stays private via
the VideoState outer join in both modes. The priority sort is made null-safe
(coalesce to 0) since unsubscribed channels have no subscription row in all-mode.
This commit is contained in:
npeter83 2026-06-15 04:06:14 +02:00
parent 44ef725a2e
commit e91ded61bb

View file

@ -66,15 +66,21 @@ def _filtered_query(
include_shorts: bool, include_shorts: bool,
include_live: bool, include_live: bool,
show: str, show: str,
scope: str = "my",
) -> tuple[Select, object]: ) -> tuple[Select, object]:
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. """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) state = aliased(VideoState)
status_expr = func.coalesce(state.status, "new") status_expr = func.coalesce(state.status, "new")
position_expr = func.coalesce(state.position_seconds, 0) position_expr = func.coalesce(state.position_seconds, 0)
query = ( query = select(
select(
Video.id, Video.id,
Video.title, Video.title,
Video.channel_id, Video.channel_id,
@ -89,10 +95,20 @@ def _filtered_query(
Video.live_status, Video.live_status,
status_expr.label("status"), status_expr.label("status"),
position_expr.label("position_seconds"), 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). # Only channels this user is subscribed to (and hasn't hidden).
.join( query = query.join(
Subscription, Subscription,
and_( and_(
Subscription.channel_id == Video.channel_id, Subscription.channel_id == Video.channel_id,
@ -100,7 +116,9 @@ def _filtered_query(
Subscription.hidden.is_(False), 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: if channel_id:
@ -199,6 +217,7 @@ def _feed_params(
include_shorts: bool = False, include_shorts: bool = False,
include_live: bool = False, include_live: bool = False,
show: str = "unwatched", show: str = "unwatched",
scope: str = "my",
) -> dict: ) -> dict:
return { return {
"tags": tags, "tags": tags,
@ -214,6 +233,7 @@ def _feed_params(
"include_shorts": include_shorts, "include_shorts": include_shorts,
"include_live": include_live, "include_live": include_live,
"show": show, "show": show,
"scope": scope,
} }
@ -226,7 +246,8 @@ SORTS = {
"title": func.lower(Video.title).asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(),
"subscribers": Channel.subscriber_count.desc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(),
# Your per-channel priority (set in the channel manager), newest first within a tier. # 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) query, _status = _filtered_query(db, user, **params)
if sort == "priority": if sort == "priority":
query = query.order_by( 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: else:
order = SORTS.get(sort) order = SORTS.get(sort)