feat: sync status indicator, admin pause/resume, filtered video count

- Header shows a live sync status (total videos + channels still backfilling, or
  "paused"), polled every 30s
- Admins can pause/resume the background sync; a paused flag in app_state makes
  scheduled jobs skip (migration 0006)
- GET /api/feed/count returns the number of videos matching the current filters;
  shared filter builder keeps it in sync with /api/feed; shown above the feed
- /api/sync/status reports backfill progress, pending enrichment and paused state
This commit is contained in:
npeter83 2026-06-11 04:15:25 +02:00
parent f73cbdb490
commit dc73b43b71
10 changed files with 298 additions and 69 deletions

View file

@ -1,7 +1,7 @@
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, false, func, or_, select
from sqlalchemy import Select, and_, false, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.auth import current_user
@ -39,30 +39,28 @@ def _serialize(row) -> dict:
}
@router.get("/feed")
def get_feed(
user: User = Depends(current_user),
db: Session = Depends(get_db),
tags: list[int] = Query(default=[]),
tag_mode: str = "or",
channel_id: str | None = None,
q: str | None = None,
min_duration: int | None = None,
max_duration: int | None = None,
max_age_days: int | None = None,
published_after: date | None = None,
published_before: date | None = None,
show_normal: bool = True,
include_shorts: bool = False,
include_live: bool = False,
show: str = "unwatched", # all | unwatched | watched | saved | hidden
sort: str = "newest",
seed: int = 0,
limit: int = Query(default=60, le=200),
offset: int = 0,
) -> dict:
def _filtered_query(
db: Session,
user: User,
*,
tags: list[int],
tag_mode: str,
channel_id: str | None,
q: str | None,
min_duration: int | None,
max_duration: int | None,
max_age_days: int | None,
published_after: date | None,
published_before: date | None,
show_normal: bool,
include_shorts: bool,
include_live: bool,
show: str,
) -> 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."""
state = aliased(VideoState)
status_expr = func.coalesce(state.status, "new").label("status")
status_expr = func.coalesce(state.status, "new")
query = (
select(
@ -78,7 +76,7 @@ def get_feed(
Video.view_count,
Video.is_short,
Video.live_status,
status_expr,
status_expr.label("status"),
)
.join(Channel, Channel.id == Video.channel_id)
# Only channels this user is subscribed to (and hasn't hidden).
@ -90,26 +88,9 @@ def get_feed(
Subscription.hidden.is_(False),
),
)
.outerjoin(
state, and_(state.video_id == Video.id, state.user_id == user.id)
)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
)
# Content-type filter: Normal (regular videos incl. past-stream VODs), Shorts and
# Live/Upcoming are independent toggles; the feed is the union of the enabled types.
# The explicit Watched/Saved/Hidden views show every type so nothing goes missing.
explicit_view = show in ("watched", "saved", "hidden")
if not explicit_view:
type_clauses = []
if show_normal:
type_clauses.append(
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE))
)
if include_shorts:
type_clauses.append(Video.is_short.is_(True))
if include_live:
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE))
query = query.where(or_(*type_clauses) if type_clauses else false())
if channel_id:
query = query.where(Video.channel_id == channel_id)
if min_duration is not None:
@ -127,27 +108,21 @@ def get_feed(
)
query = query.where(Video.published_at >= start)
if published_before is not None:
# Inclusive of the end day.
end = datetime.combine(
published_before, datetime.min.time(), tzinfo=timezone.utc
) + timedelta(days=1)
query = query.where(Video.published_at < end)
if q:
# Title (and channel name) only — searching descriptions produced noisy matches.
like = f"%{q}%"
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
if tags:
# Group selected tags by category: AND across categories (e.g. language AND
# topic narrows results), OR within a category. The any/all toggle controls
# whether multiple topic tags are OR'd (any) or AND'd (all).
cat_rows = db.execute(
select(Tag.id, Tag.category).where(Tag.id.in_(tags))
).all()
# AND across tag categories (e.g. language AND topic narrows), OR within a
# category; the any/all toggle controls multiple topic tags.
cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all()
by_category: dict[str, list[int]] = {}
for tag_id, category in cat_rows:
by_category.setdefault(category, []).append(tag_id)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
for category, ids in by_category.items():
if category == "topic" and tag_mode == "and" and len(ids) > 1:
@ -163,7 +138,21 @@ def get_feed(
)
query = query.where(Video.channel_id.in_(sub))
# Watch-state visibility.
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
explicit_view = show in ("watched", "saved", "hidden")
if not explicit_view:
type_clauses = []
if show_normal:
type_clauses.append(
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE))
)
if include_shorts:
type_clauses.append(Video.is_short.is_(True))
if include_live:
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE))
query = query.where(or_(*type_clauses) if type_clauses else false())
if show == "unwatched":
query = query.where(status_expr.notin_(("watched", "hidden")))
elif show == "watched":
@ -175,22 +164,88 @@ def get_feed(
else: # all
query = query.where(status_expr != "hidden")
sorts = {
"newest": Video.published_at.desc().nulls_last(),
"oldest": Video.published_at.asc().nulls_last(),
"views": Video.view_count.desc().nulls_last(),
"duration_desc": Video.duration_seconds.desc().nulls_last(),
"duration_asc": Video.duration_seconds.asc().nulls_last(),
"title": func.lower(Video.title).asc().nulls_last(),
"subscribers": Channel.subscriber_count.desc().nulls_last(),
"shuffle": func.md5(func.concat(Video.id, str(seed))),
return query, status_expr
# Shared query parameters for /feed and /feed/count.
def _feed_params(
tags: list[int] = Query(default=[]),
tag_mode: str = "or",
channel_id: str | None = None,
q: str | None = None,
min_duration: int | None = None,
max_duration: int | None = None,
max_age_days: int | None = None,
published_after: date | None = None,
published_before: date | None = None,
show_normal: bool = True,
include_shorts: bool = False,
include_live: bool = False,
show: str = "unwatched",
) -> dict:
return {
"tags": tags,
"tag_mode": tag_mode,
"channel_id": channel_id,
"q": q,
"min_duration": min_duration,
"max_duration": max_duration,
"max_age_days": max_age_days,
"published_after": published_after,
"published_before": published_before,
"show_normal": show_normal,
"include_shorts": include_shorts,
"include_live": include_live,
"show": show,
}
query = query.order_by(sorts.get(sort, sorts["newest"]))
SORTS = {
"newest": Video.published_at.desc().nulls_last(),
"oldest": Video.published_at.asc().nulls_last(),
"views": Video.view_count.desc().nulls_last(),
"duration_desc": Video.duration_seconds.desc().nulls_last(),
"duration_asc": Video.duration_seconds.asc().nulls_last(),
"title": func.lower(Video.title).asc().nulls_last(),
"subscribers": Channel.subscriber_count.desc().nulls_last(),
}
@router.get("/feed")
def get_feed(
params: dict = Depends(_feed_params),
sort: str = "newest",
seed: int = 0,
limit: int = Query(default=60, le=200),
offset: int = 0,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
query, _status = _filtered_query(db, user, **params)
order = SORTS.get(sort)
if order is None and sort == "shuffle":
order = func.md5(func.concat(Video.id, str(seed)))
query = query.order_by(order if order is not None else SORTS["newest"])
rows = db.execute(query.offset(offset).limit(limit + 1)).all()
has_more = len(rows) > limit
items = [_serialize(r) for r in rows[:limit]]
return {"items": items, "has_more": has_more, "offset": offset, "limit": limit}
return {
"items": [_serialize(r) for r in rows[:limit]],
"has_more": has_more,
"offset": offset,
"limit": limit,
}
@router.get("/feed/count")
def get_feed_count(
params: dict = Depends(_feed_params),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
query, _status = _filtered_query(db, user, **params)
total = db.scalar(select(func.count()).select_from(query.subquery()))
return {"count": total or 0}
@router.post("/videos/{video_id}/state")