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) day: Mapped[date] = mapped_column(Date, primary_key=True)
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0") 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 datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query 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 sqlalchemy.orm import Session, aliased
from app.auth import current_user from app.auth import current_user
@ -39,30 +39,28 @@ def _serialize(row) -> dict:
} }
@router.get("/feed") def _filtered_query(
def get_feed( db: Session,
user: User = Depends(current_user), user: User,
db: Session = Depends(get_db), *,
tags: list[int] = Query(default=[]), tags: list[int],
tag_mode: str = "or", tag_mode: str,
channel_id: str | None = None, channel_id: str | None,
q: str | None = None, q: str | None,
min_duration: int | None = None, min_duration: int | None,
max_duration: int | None = None, max_duration: int | None,
max_age_days: int | None = None, max_age_days: int | None,
published_after: date | None = None, published_after: date | None,
published_before: date | None = None, published_before: date | None,
show_normal: bool = True, show_normal: bool,
include_shorts: bool = False, include_shorts: bool,
include_live: bool = False, include_live: bool,
show: str = "unwatched", # all | unwatched | watched | saved | hidden show: str,
sort: str = "newest", ) -> tuple[Select, object]:
seed: int = 0, """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
limit: int = Query(default=60, le=200), Returns the column-bearing select plus the watch-status expression for sorting."""
offset: int = 0,
) -> dict:
state = aliased(VideoState) state = aliased(VideoState)
status_expr = func.coalesce(state.status, "new").label("status") status_expr = func.coalesce(state.status, "new")
query = ( query = (
select( select(
@ -78,7 +76,7 @@ def get_feed(
Video.view_count, Video.view_count,
Video.is_short, Video.is_short,
Video.live_status, Video.live_status,
status_expr, status_expr.label("status"),
) )
.join(Channel, Channel.id == Video.channel_id) .join(Channel, Channel.id == Video.channel_id)
# Only channels this user is subscribed to (and hasn't hidden). # Only channels this user is subscribed to (and hasn't hidden).
@ -90,26 +88,9 @@ def get_feed(
Subscription.hidden.is_(False), Subscription.hidden.is_(False),
), ),
) )
.outerjoin( .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
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: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)
if min_duration is not None: if min_duration is not None:
@ -127,27 +108,21 @@ def get_feed(
) )
query = query.where(Video.published_at >= start) query = query.where(Video.published_at >= start)
if published_before is not None: if published_before is not None:
# Inclusive of the end day.
end = datetime.combine( end = datetime.combine(
published_before, datetime.min.time(), tzinfo=timezone.utc published_before, datetime.min.time(), tzinfo=timezone.utc
) + timedelta(days=1) ) + timedelta(days=1)
query = query.where(Video.published_at < end) query = query.where(Video.published_at < end)
if q: if q:
# Title (and channel name) only — searching descriptions produced noisy matches.
like = f"%{q}%" like = f"%{q}%"
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like))) query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
if tags: if tags:
# Group selected tags by category: AND across categories (e.g. language AND # AND across tag categories (e.g. language AND topic narrows), OR within a
# topic narrows results), OR within a category. The any/all toggle controls # category; the any/all toggle controls multiple topic tags.
# 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()
cat_rows = db.execute(
select(Tag.id, Tag.category).where(Tag.id.in_(tags))
).all()
by_category: dict[str, list[int]] = {} by_category: dict[str, list[int]] = {}
for tag_id, category in cat_rows: for tag_id, category in cat_rows:
by_category.setdefault(category, []).append(tag_id) by_category.setdefault(category, []).append(tag_id)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
for category, ids in by_category.items(): for category, ids in by_category.items():
if category == "topic" and tag_mode == "and" and len(ids) > 1: 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)) 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": if show == "unwatched":
query = query.where(status_expr.notin_(("watched", "hidden"))) query = query.where(status_expr.notin_(("watched", "hidden")))
elif show == "watched": elif show == "watched":
@ -175,22 +164,88 @@ def get_feed(
else: # all else: # all
query = query.where(status_expr != "hidden") query = query.where(status_expr != "hidden")
sorts = { return query, status_expr
"newest": Video.published_at.desc().nulls_last(),
"oldest": Video.published_at.asc().nulls_last(),
"views": Video.view_count.desc().nulls_last(), # Shared query parameters for /feed and /feed/count.
"duration_desc": Video.duration_seconds.desc().nulls_last(), def _feed_params(
"duration_asc": Video.duration_seconds.asc().nulls_last(), tags: list[int] = Query(default=[]),
"title": func.lower(Video.title).asc().nulls_last(), tag_mode: str = "or",
"subscribers": Channel.subscriber_count.desc().nulls_last(), channel_id: str | None = None,
"shuffle": func.md5(func.concat(Video.id, str(seed))), 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() rows = db.execute(query.offset(offset).limit(limit + 1)).all()
has_more = len(rows) > limit has_more = len(rows) > limit
items = [_serialize(r) for r in rows[:limit]] return {
return {"items": items, "has_more": has_more, "offset": offset, "limit": limit} "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") @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 import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import quota, state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
@ -79,7 +79,35 @@ def sync_status(
return { return {
"subscriptions": sub_count, "subscriptions": sub_count,
"channels_total": db.scalar(select(func.count()).select_from(Channel)), "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)), "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_used_today": quota.units_used_today(db),
"quota_remaining_today": quota.remaining_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.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all from app.sync.autotag import run_autotag_all
from app.sync.runner import ( from app.sync.runner import (
run_deep_backfill, run_deep_backfill,
@ -24,6 +25,9 @@ _scheduler: BackgroundScheduler | None = None
def _job(name: str, fn) -> None: def _job(name: str, fn) -> None:
db = SessionLocal() db = SessionLocal()
try: try:
if is_sync_paused(db):
logger.info("job %s skipped (sync paused)", name)
return
result = fn(db) result = fn(db)
logger.info("job %s -> %s", name, result) logger.info("job %s -> %s", name, result)
except Exception: 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 { 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 { api, type FeedFilters, type Video } from "../lib/api";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import VideoCard from "./VideoCard"; import VideoCard from "./VideoCard";
@ -42,6 +42,12 @@ export default function Feed({
useEffect(() => setOverrides({}), [filters]); useEffect(() => setOverrides({}), [filters]);
const countQuery = useQuery({
queryKey: ["feed-count", filters],
queryFn: () => api.feedCount(filters),
staleTime: 30_000,
});
const sentinel = useRef<HTMLDivElement | null>(null); const sentinel = useRef<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => { useEffect(() => {
@ -87,6 +93,11 @@ export default function Feed({
return ( return (
<div className="p-4"> <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" ? ( {view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]"> <div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => ( {items.map((v) => (

View file

@ -10,6 +10,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import type { FeedFilters, Me } from "../lib/api"; import type { FeedFilters, Me } from "../lib/api";
import SyncStatus from "./SyncStatus";
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) { function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className = "", ...rest } = props; const { className = "", ...rest } = props;
@ -91,6 +92,8 @@ export default function Header({
Sub<span className="text-accent">feed</span> Sub<span className="text-accent">feed</span>
</div> </div>
<SyncStatus />
<div className="flex-1 max-w-xl mx-auto relative"> <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" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input <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(); 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 = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
tags: (): Promise<Tag[]> => req("/api/tags"), 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> => feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`), 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) => setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), 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>) => savePrefs: (p: Record<string, any>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }), req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
}; };