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

@ -0,0 +1,28 @@
"""app_state (admin-controlled global flags)
Revision ID: 0006_app_state
Revises: 0005_shorts_probed
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0006_app_state"
down_revision: Union[str, None] = "0005_shorts_probed"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_state",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("sync_paused", sa.Boolean(), nullable=False, server_default="false"),
)
op.execute("INSERT INTO app_state (id, sync_paused) VALUES (1, false)")
def downgrade() -> None:
op.drop_table("app_state")

View file

@ -237,3 +237,14 @@ class ApiQuotaUsage(Base):
day: Mapped[date] = mapped_column(Date, primary_key=True)
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
class AppState(Base):
"""Single-row global app state (admin-controlled)."""
__tablename__ = "app_state"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
sync_paused: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)

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,7 +164,43 @@ def get_feed(
else: # all
query = query.where(status_expr != "hidden")
sorts = {
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,
}
SORTS = {
"newest": Video.published_at.desc().nulls_last(),
"oldest": Video.published_at.asc().nulls_last(),
"views": Video.view_count.desc().nulls_last(),
@ -183,14 +208,44 @@ def get_feed(
"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))),
}
query = query.order_by(sorts.get(sort, sorts["newest"]))
}
@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")

View file

@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import quota
from app import quota, state
from app.auth import current_user
from app.db import get_db
from app.models import Channel, Subscription, User, Video
@ -79,7 +79,35 @@ def sync_status(
return {
"subscriptions": sub_count,
"channels_total": db.scalar(select(func.count()).select_from(Channel)),
"channels_backfilling": db.scalar(
select(func.count()).select_from(Channel).where(Channel.backfill_done.is_(False))
),
"videos_total": db.scalar(select(func.count()).select_from(Video)),
"pending_enrich": db.scalar(
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
),
"quota_used_today": quota.units_used_today(db),
"quota_remaining_today": quota.remaining_today(db),
"paused": state.is_sync_paused(db),
"is_admin": user.role == "admin",
}
@router.post("/pause")
def pause_sync(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
state.set_sync_paused(db, True)
return {"paused": True}
@router.post("/resume")
def resume_sync(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
state.set_sync_paused(db, False)
return {"paused": False}

View file

@ -6,6 +6,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
from app.config import settings
from app.db import SessionLocal
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all
from app.sync.runner import (
run_deep_backfill,
@ -24,6 +25,9 @@ _scheduler: BackgroundScheduler | None = None
def _job(name: str, fn) -> None:
db = SessionLocal()
try:
if is_sync_paused(db):
logger.info("job %s skipped (sync paused)", name)
return
result = fn(db)
logger.info("job %s -> %s", name, result)
except Exception:

24
backend/app/state.py Normal file
View file

@ -0,0 +1,24 @@
"""Global admin-controlled app state (e.g. pausing background sync)."""
from sqlalchemy.orm import Session
from app.models import AppState
def _row(db: Session) -> AppState:
row = db.get(AppState, 1)
if row is None:
row = AppState(id=1, sync_paused=False)
db.add(row)
db.commit()
return row
def is_sync_paused(db: Session) -> bool:
return _row(db).sync_paused
def set_sync_paused(db: Session, paused: bool) -> None:
row = _row(db)
row.sync_paused = paused
db.add(row)
db.commit()

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api";
import { toast } from "../lib/toast";
import VideoCard from "./VideoCard";
@ -42,6 +42,12 @@ export default function Feed({
useEffect(() => setOverrides({}), [filters]);
const countQuery = useQuery({
queryKey: ["feed-count", filters],
queryFn: () => api.feedCount(filters),
staleTime: 30_000,
});
const sentinel = useRef<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
@ -87,6 +93,11 @@ export default function Feed({
return (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
{countQuery.data
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
: " "}
</div>
{view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (

View file

@ -10,6 +10,7 @@ import {
} from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import type { FeedFilters, Me } from "../lib/api";
import SyncStatus from "./SyncStatus";
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className = "", ...rest } = props;
@ -91,6 +92,8 @@ export default function Header({
Sub<span className="text-accent">feed</span>
</div>
<SyncStatus />
<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" />
<input

View file

@ -0,0 +1,49 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, Pause, Play } from "lucide-react";
import { api } from "../lib/api";
import { formatViews } from "../lib/format";
export default function SyncStatus() {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["sync-status"],
queryFn: api.status,
refetchInterval: 30_000,
staleTime: 25_000,
});
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["sync-status"] }),
});
if (!data) return null;
return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Database className="w-3.5 h-3.5" />
<span>{formatViews(data.videos_total)} videos</span>
<span className="opacity-40">·</span>
{data.paused ? (
<span className="text-accent font-medium">paused</span>
) : data.channels_backfilling > 0 ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{data.channels_backfilling} syncing
</span>
) : (
<span>all synced</span>
)}
{data.is_admin && (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? "Resume background sync" : "Pause background sync"}
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
)}
</div>
);
}

View file

@ -97,14 +97,30 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string {
return p.toString();
}
export interface SyncStatus {
subscriptions: number;
channels_total: number;
channels_backfilling: number;
videos_total: number;
pending_enrich: number;
quota_used_today: number;
quota_remaining_today: number;
paused: boolean;
is_admin: boolean;
}
export const api = {
me: (): Promise<Me> => req("/api/me"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<any> => req("/api/sync/status"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
savePrefs: (p: Record<string, any>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
};