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:
parent
f73cbdb490
commit
dc73b43b71
10 changed files with 298 additions and 69 deletions
28
backend/alembic/versions/0006_app_state.py
Normal file
28
backend/alembic/versions/0006_app_state.py
Normal 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")
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
24
backend/app/state.py
Normal 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()
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
frontend/src/components/SyncStatus.tsx
Normal file
49
frontend/src/components/SyncStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) }),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue