"""Klipy (feed Swipe), etap 2: podstawowy listing i statystyki do pilota. Właściwy feed z afinicznością, pamięcią widzianych i telemetrią to etap 3 (`/clips/feed`, `/clips/events`). Tu tylko to, czego trzeba, żeby zweryfikować ingest i odtwarzanie z apki/emulatora: lista najnowszych żywych klipów z filtrem po tagu, źródle i audio oraz zbiorcze liczniki. """ from __future__ import annotations import uuid from datetime import datetime from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth import require_api_key from app.db import get_session from app.models.clip import Clip, ClipTag router = APIRouter(prefix="/clips", tags=["clips"], dependencies=[Depends(require_api_key)]) 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 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 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 @router.get("", response_model=ClipListOut) def list_clips( session: Session = Depends(get_session), 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] tags = _tags_for(session, [r.id for r in rows]) items = [ 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.get(r.id, []), 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, ) for r in rows ] return ClipListOut(items=items, 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, )