goon/app/api/clips.py
goon-foss 538a72107e perf(clips): explore po podzapytaniu okna, kandydaci bez ORM i bez tagow, max(uuid) przez tekst
Strona feedu spadla z 310-470 ms do 50-130 ms (srednio 85 ms na 30 stronach,
0 powtorek). Postgres nie ma max(uuid): koniec okna liczony po rzutowaniu na
tekst (ten sam porzadek dla malych hex w stalym formacie).
2026-09-14 20:34:19 +02:00

664 lines
25 KiB
Python

"""Klipy (feed Swipe): feed z afinicznością, pamięć widzianych, telemetria, ulubione,
blacklista, onboarding. Logika bez bazy w `app/clips_feed.py`.
**Feed** `GET /clips/feed`: strona = 80 % z puli afiniczności (tagi, nisze, twórcy z wag
urządzenia) + 20 % explore (okno po kluczu głównym od kursora, losowanie ważone lajkami).
Bez wag (nowe urządzenie, brak onboardingu) 100 % explore. Każdy podany klip ląduje
w `clip_seen` od razu, więc nigdy nie wraca. Widoczność: żywy, do `clips_feed_max_duration_sec`,
z plikiem, nie widziany, nie na blackliście (tag / nisza / twórca).
**Zdarzenia** `POST /clips/events`: paczka z apki (co ~5 s). Zapis do `clip_feed_events`,
podniesienie `clip_seen.outcome`, ulubione przy like/unlike, delty na wagi afiniczności
(`clips_feed.apply_event`).
Adresy plików i posterów są publiczne (`media.redgifs.com`), więc jadą do apki wprost,
bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do toru.
"""
from __future__ import annotations
import logging
import time
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import clips_feed as cf
from app.api.device import get_device_id
from app.auth import require_api_key
from app.config import get_settings
from app.db import get_session
from app.models.clip import Clip, ClipTag
from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen
log = logging.getLogger(__name__)
router = APIRouter(prefix="/clips", tags=["clips"], dependencies=[Depends(require_api_key)])
_EXPLORE_WINDOW = 2000
_AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
_UUID_ZERO = uuid.UUID(int=0)
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
# ---- schematy ---------------------------------------------------------------------------
class ClipOut(BaseModel):
id: uuid.UUID
source: str
source_id: str
title: str | None
creator: str | None
creator_verified: bool
duration_sec: float | None
width: int | None
height: int | None
has_audio: bool
content_type: str | None
niches: list[str]
tags: list[str]
hd_url: str | None
sd_url: str | None
poster_url: str | None
thumb_url: str | None
likes: int
views: int
published_at: datetime | None
is_favorite: bool = False
class ClipListOut(BaseModel):
items: list[ClipOut]
limit: int
offset: int
has_more: bool
class ClipStatsOut(BaseModel):
total: int
alive: int
dead: int
with_audio: int
verified: int
by_source: dict[str, int]
newest_published_at: datetime | None
tags: int
class FeedItemOut(BaseModel):
clip: ClipOut
#: `affinity` | `explore`, do telemetrii i do pomiaru, czy personalizacja coś daje.
slot: str
is_fresh: bool
class FeedPageOut(BaseModel):
items: list[FeedItemOut]
next_cursor: str
seed: str
#: True gdy okno explore doszło do końca katalogu i zawinęło się na początek.
wrapped: bool
#: Ile klipów to urządzenie już widziało (pigułka „N nowych").
served_total: int
personalized: bool
class FeedEventIn(BaseModel):
clip_id: uuid.UUID
kind: str = Field(min_length=1, max_length=16)
dwell_ms: int | None = Field(default=None, ge=0, le=3_600_000)
reason: str | None = Field(default=None, max_length=160)
slot: str | None = Field(default=None, max_length=12)
position: int | None = Field(default=None, ge=0, le=32767)
class FeedEventsIn(BaseModel):
session_id: str | None = Field(default=None, max_length=32)
events: list[FeedEventIn] = Field(max_length=100)
class OnboardingIn(BaseModel):
tags: list[str] = Field(default_factory=list, max_length=20)
niches: list[str] = Field(default_factory=list, max_length=20)
class OnboardingOptionOut(BaseModel):
value: str
count: int
class OnboardingOptionsOut(BaseModel):
tags: list[OnboardingOptionOut]
niches: list[OnboardingOptionOut]
class AffinityOut(BaseModel):
tags: dict[str, float]
niches: dict[str, float]
creators: dict[str, float]
events_seen: int
class BlacklistItemOut(BaseModel):
kind: str
value: str
# ---- pomocnicze -------------------------------------------------------------------------
def _tags_for(session: Session, clip_ids: list[uuid.UUID]) -> dict[uuid.UUID, list[str]]:
if not clip_ids:
return {}
out: dict[uuid.UUID, list[str]] = {}
for cid, tag in session.execute(
select(ClipTag.clip_id, ClipTag.tag).where(ClipTag.clip_id.in_(clip_ids)).order_by(ClipTag.tag)
):
out.setdefault(cid, []).append(tag)
return out
def _favorites_for(session: Session, device_id: str, clip_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not clip_ids:
return set()
return {
r[0]
for r in session.execute(
select(ClipFavorite.clip_id).where(ClipFavorite.device_id == device_id, ClipFavorite.clip_id.in_(clip_ids))
)
}
def _clip_out(r: Clip, tags: list[str], fav: bool = False) -> ClipOut:
return ClipOut(
id=r.id,
source=r.source,
source_id=r.source_id,
title=r.title,
creator=r.creator,
creator_verified=r.creator_verified,
duration_sec=r.duration_sec,
width=r.width,
height=r.height,
has_audio=r.has_audio,
content_type=r.content_type,
niches=list(r.niches or []),
tags=tags,
hd_url=r.hd_url,
sd_url=r.sd_url,
poster_url=r.poster_url,
thumb_url=r.thumb_url,
likes=r.likes,
views=r.views,
published_at=r.published_at,
is_favorite=fav,
)
def _load_affinity(session: Session, device_id: str) -> tuple[ClipAffinity | None, cf.Affinity]:
row = session.get(ClipAffinity, device_id)
if row is None:
return None, cf.Affinity()
return row, cf.Affinity(
tags=dict(row.tag_weights or {}), niches=dict(row.niche_weights or {}), creators=dict(row.creator_weights or {})
)
def _save_affinity(session: Session, device_id: str, row: ClipAffinity | None, aff: cf.Affinity, events: int) -> None:
if row is None:
row = ClipAffinity(device_id=device_id)
session.add(row)
row.tag_weights = aff.tags
row.niche_weights = aff.niches
row.creator_weights = aff.creators
row.events_seen = (row.events_seen or 0) + events
def _blacklist(session: Session, device_id: str) -> dict[str, set[str]]:
out: dict[str, set[str]] = {"tag": set(), "niche": set(), "creator": set()}
for kind, value in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id)
):
out.setdefault(kind, set()).add(value)
return out
def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
clauses = [
Clip.dead_at.is_(None),
Clip.duration_sec.is_not(None),
Clip.duration_sec <= get_settings().clips_feed_max_duration_sec,
(Clip.hd_url.is_not(None)) | (Clip.sd_url.is_not(None)),
~select(ClipSeen.clip_id)
.where(ClipSeen.device_id == device_id, ClipSeen.clip_id == Clip.id)
.exists(),
]
if bl["creator"]:
clauses.append((Clip.creator.is_(None)) | (~Clip.creator.in_(list(bl["creator"]))))
if bl["niche"]:
clauses.append(~Clip.niches.overlap(list(bl["niche"])))
if bl["tag"]:
clauses.append(
~select(ClipTag.clip_id)
.where(ClipTag.clip_id == Clip.id, ClipTag.tag.in_(list(bl["tag"])))
.exists()
)
return clauses
_CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches)
def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
"""Wiersze `_CAND_COLS` → Candidate. Tagi tylko na życzenie: pula explore punktuje
wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem."""
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
return [
cf.Candidate(id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []))
for r in rows
]
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
if not ids:
return []
rows = session.execute(select(*_CAND_COLS).where(Clip.id.in_(ids), *clauses)).all()
return _rows_to_candidates(session, rows, with_tags=True)
def _explore_candidates(
session: Session, pk: uuid.UUID, clauses: list, limit: int
) -> tuple[list[cf.Candidate], uuid.UUID, bool]:
"""Widoczni kandydaci z okna `_EXPLORE_WINDOW` id po `pk` (podzapytanie, bez
przenoszenia 2000 uuid przez SQL). Zwraca (kandydaci, koniec okna, czy okno było
ostatnie). Ostatnie okno = katalog wyczerpany od `pk`, następna strona zawija do zera."""
window = (
select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW)
).subquery()
# Postgres nie ma max(uuid); tekstowo porządek jest ten sam (małe hex, stały format).
end_txt, n = session.execute(
select(func.max(cast(window.c.id, String)), func.count()).select_from(window)
).one()
if not n:
return [], _UUID_ZERO, True
end_id = uuid.UUID(end_txt)
rows = session.execute(
select(*_CAND_COLS).where(Clip.id.in_(select(window.c.id)), *clauses).order_by(Clip.id).limit(limit)
).all()
last = n < _EXPLORE_WINDOW
return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
ids: set[uuid.UUID] = set()
for tag in aff.top("tags", _AFFINITY_TAGS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag, ClipTag.clip_id > start).order_by(ClipTag.clip_id).limit(_PER_TAG)
).all()
if len(rows) < _PER_TAG:
rows += session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag).order_by(ClipTag.clip_id).limit(_PER_TAG - len(rows))
).all()
ids.update(r[0] for r in rows)
for niche in aff.top("niches", _AFFINITY_NICHES):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.niches.overlap([niche]), Clip.id > start).order_by(Clip.id).limit(_PER_NICHE)
).all()
ids.update(r[0] for r in rows)
for creator in aff.top("creators", _AFFINITY_CREATORS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.creator == creator, Clip.id > start).order_by(Clip.id).limit(_PER_CREATOR)
).all()
if len(rows) < _PER_CREATOR:
rows += session.execute(
select(Clip.id).where(Clip.creator == creator).order_by(Clip.id).limit(_PER_CREATOR - len(rows))
).all()
ids.update(r[0] for r in rows)
return ids
# ---- feed -------------------------------------------------------------------------------
@router.get("/feed", response_model=FeedPageOut)
def clip_feed(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seed: str | None = Query(default=None, max_length=16),
cursor: str | None = Query(default=None, max_length=200),
limit: int = Query(default=10, ge=1, le=30),
) -> FeedPageOut:
t0 = time.monotonic()
decoded = cf.decode_cursor(cursor) if cursor else None
if decoded:
seed, pk, page = decoded
else:
seed = (seed or cf.new_seed())[:16]
pk, page = cf.seed_start_uuid(seed), 0
rng = cf.page_rng(seed, page)
_row, aff = _load_affinity(session, device_id)
personalized = not aff.is_empty()
bl = _blacklist(session, device_id)
clauses = _visible_clauses(device_id, bl)
n_aff, n_explore = cf.split_counts(limit, personalized)
picked_aff: list[cf.Candidate] = []
if n_aff:
cand_ids = _affinity_candidate_ids(session, aff, rng)
picked_aff = cf.pick_affinity(_candidates(session, list(cand_ids), clauses), aff, n_aff)
taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
# Braki z afinicznosci (za malo kandydatow po filtrach) dopelnia explore.
picked_explore: list[cf.Candidate] = []
wrapped = False
next_pk = pk
want = n_explore + max(0, n_aff - len(picked_aff))
for _ in range(3):
pool, next_pk, last = _explore_candidates(session, next_pk, clauses, limit=max(60, want * 15))
pool = [c for c in pool if c.id not in taken]
picked_explore += cf.pick_explore(pool, want - len(picked_explore), rng)
taken.update(c.id for c in picked_explore)
if last:
wrapped = True
if len(picked_explore) >= want or last:
break
page_items = cf.compose_page(picked_aff, picked_explore, rng)
clip_ids = [c.id for c, _ in page_items]
if clip_ids:
session.execute(
pg_insert(ClipSeen)
.values([{"device_id": device_id, "clip_id": cid, "outcome": "served"} for cid in clip_ids])
.on_conflict_do_nothing()
)
rows = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()} if clip_ids else {}
tags = _tags_for(session, clip_ids)
favs = _favorites_for(session, device_id, clip_ids)
served_total = session.execute(
select(func.count()).select_from(ClipSeen).where(ClipSeen.device_id == device_id)
).scalar_one()
session.commit()
items = [
FeedItemOut(
clip=_clip_out(rows[c.id], tags.get(c.id, []), c.id in favs),
slot=slot,
is_fresh=cf.is_fresh(rows[c.id].published_at),
)
for c, slot in page_items
if c.id in rows
]
log.info(
"clips/feed: device=%s page=%d served=%d aff=%d explore=%d wrapped=%s ms=%d",
device_id[:8], page, len(items), len(picked_aff), len(picked_explore), wrapped,
int((time.monotonic() - t0) * 1000),
)
return FeedPageOut(
items=items,
next_cursor=cf.encode_cursor(seed, next_pk, page + 1),
seed=seed,
wrapped=wrapped,
served_total=int(served_total),
personalized=personalized,
)
# ---- zdarzenia --------------------------------------------------------------------------
@router.post("/events", status_code=204)
def clip_events(
body: FeedEventsIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> Response:
events = [e for e in body.events if e.kind in cf.ALLOWED_KINDS]
if not events:
return Response(status_code=204)
clip_ids = list({e.clip_id for e in events})
clips = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()}
tags = _tags_for(session, clip_ids)
seen = {
r.clip_id: r
for r in session.execute(
select(ClipSeen).where(ClipSeen.device_id == device_id, ClipSeen.clip_id.in_(clip_ids))
).scalars()
}
row, aff = _load_affinity(session, device_id)
changed = 0
for e in events:
session.add(
ClipFeedEvent(
device_id=device_id,
session_id=body.session_id,
clip_id=e.clip_id,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
slot=e.slot,
position=e.position,
)
)
clip = clips.get(e.clip_id)
if clip is None:
continue
# clip_seen: podnies wynik, nigdy nie cofaj.
outcome = e.kind if e.kind in cf.OUTCOME_RANK else None
if outcome:
s = seen.get(e.clip_id)
if s is None:
s = ClipSeen(device_id=device_id, clip_id=e.clip_id, outcome=outcome)
session.add(s)
seen[e.clip_id] = s
elif cf.OUTCOME_RANK[outcome] > cf.OUTCOME_RANK.get(s.outcome, 0):
s.outcome = outcome
if e.kind == "like":
session.execute(
pg_insert(ClipFavorite).values(device_id=device_id, clip_id=e.clip_id).on_conflict_do_nothing()
)
elif e.kind == "unlike":
session.execute(
text("DELETE FROM clip_favorites WHERE device_id = :d AND clip_id = :c"),
{"d": device_id, "c": e.clip_id},
)
if cf.apply_event(
aff,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
tags=tags.get(e.clip_id, []),
niches=list(clip.niches or []),
creator=clip.creator,
):
changed += 1
if changed:
_save_affinity(session, device_id, row, aff, changed)
session.commit()
return Response(status_code=204)
# ---- onboarding, afinicznosc ------------------------------------------------------------
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
Liczone z bazy, cache 10 min w procesie."""
hit = _OPTIONS_CACHE.get("v1")
if hit and time.monotonic() - hit[0] < 600:
return hit[1]
tags = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
)
]
niches = [
OnboardingOptionOut(value=v, count=n)
for v, n in session.execute(
text("SELECT n, count(*) FROM clips, unnest(niches) n WHERE dead_at IS NULL GROUP BY n ORDER BY 2 DESC LIMIT 30")
)
]
out = OnboardingOptionsOut(tags=tags, niches=niches)
_OPTIONS_CACHE["v1"] = (time.monotonic(), out)
return out
@router.post("/onboarding", response_model=AffinityOut)
def onboarding(
body: OnboardingIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
for t in body.tags:
t = t.strip().lower()[:64]
if t:
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
for n in body.niches:
n = n.strip()[:64]
if n:
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
_save_affinity(session, device_id, row, aff, 0)
session.commit()
row2 = session.get(ClipAffinity, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row2.events_seen if row2 else 0)
@router.get("/affinity", response_model=AffinityOut)
def get_affinity(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row.events_seen if row else 0)
@router.delete("/affinity", status_code=204)
def reset_affinity(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seen: bool = Query(default=False, description="True = zapomnij tez widziane klipy (feed od zera)"),
) -> Response:
"""„Resetuj moj feed": kasuje wagi; z `seen=true` takze pamiec widzianych."""
session.execute(text("DELETE FROM clip_affinity WHERE device_id = :d"), {"d": device_id})
if seen:
session.execute(text("DELETE FROM clip_seen WHERE device_id = :d"), {"d": device_id})
session.commit()
return Response(status_code=204)
# ---- ulubione, blacklista ----------------------------------------------------------------
@router.get("/favorites", response_model=ClipListOut)
def clip_favorites(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
rows = session.execute(
select(Clip)
.join(ClipFavorite, ClipFavorite.clip_id == Clip.id)
.where(ClipFavorite.device_id == device_id)
.order_by(ClipFavorite.created_at.desc())
.offset(offset)
.limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
tags = _tags_for(session, [r.id for r in rows])
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), True) for r in rows], limit=limit, offset=offset, has_more=has_more
)
@router.get("/blacklist", response_model=list[BlacklistItemOut])
def clip_blacklist(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> list[BlacklistItemOut]:
return [
BlacklistItemOut(kind=k, value=v)
for k, v in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id).order_by(ClipBlacklist.kind, ClipBlacklist.value)
)
]
@router.post("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_add(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
if kind not in _BLACKLIST_KINDS or not value.strip():
raise HTTPException(status_code=400, detail="kind must be tag|niche|creator")
session.execute(
pg_insert(ClipBlacklist).values(device_id=device_id, kind=kind, value=value.strip()[:128]).on_conflict_do_nothing()
)
session.commit()
return Response(status_code=204)
@router.delete("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_remove(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
session.execute(
text("DELETE FROM clip_blacklist WHERE device_id = :d AND kind = :k AND value = :v"),
{"d": device_id, "k": kind, "v": value.strip()[:128]},
)
session.commit()
return Response(status_code=204)
# ---- listing i statystyki (pilot) --------------------------------------------------------
@router.get("", response_model=ClipListOut)
def list_clips(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
tag: str | None = Query(default=None, max_length=64),
source: str | None = Query(default=None, max_length=16),
creator: str | None = Query(default=None, max_length=128),
has_audio: bool | None = None,
max_duration_sec: float | None = Query(default=None, gt=0),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
q = select(Clip).where(Clip.dead_at.is_(None))
if tag:
q = q.where(Clip.id.in_(select(ClipTag.clip_id).where(ClipTag.tag == tag)))
if source:
q = q.where(Clip.source == source)
if creator:
q = q.where(Clip.creator == creator)
if has_audio is not None:
q = q.where(Clip.has_audio.is_(has_audio))
if max_duration_sec is not None:
q = q.where(Clip.duration_sec <= max_duration_sec)
rows = session.execute(
q.order_by(Clip.published_at.desc().nullslast(), Clip.id).offset(offset).limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
ids = [r.id for r in rows]
tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids)
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows], limit=limit, offset=offset, has_more=has_more
)
@router.get("/stats", response_model=ClipStatsOut)
def clip_stats(session: Session = Depends(get_session)) -> ClipStatsOut:
row = session.execute(
select(
func.count(),
func.count().filter(Clip.dead_at.is_(None)),
func.count().filter(Clip.dead_at.is_not(None)),
func.count().filter(Clip.has_audio.is_(True), Clip.dead_at.is_(None)),
func.count().filter(Clip.creator_verified.is_(True), Clip.dead_at.is_(None)),
func.max(Clip.published_at),
)
).one()
by_source = {s: n for s, n in session.execute(select(Clip.source, func.count()).group_by(Clip.source))}
tags = session.execute(select(func.count(func.distinct(ClipTag.tag)))).scalar_one()
return ClipStatsOut(
total=row[0], alive=row[1], dead=row[2], with_audio=row[3], verified=row[4],
by_source=by_source, newest_published_at=row[5], tags=tags,
)