Nowy wertykal krotkich pionowych klipow tworcow, osobno od scen: inne kolumny (tworca, audio, lajki, nisze), inny cykl zycia, inna taksonomia, brak filtra 60 s. Tabele `clips` (unikalne source+source_id) i `clip_tags` z indeksem (tag, clip_id) pod skan po tagu od losowego uuid w przyszlym feedzie. Zrodlo nr 1: RedGifs, oficjalne API v2 z tokenem tymczasowym (zwiazany z IP i UA, na 401 odnowienie). Globalne `order=latest` bez search_text daje ~100 na strone, kazde zapytanie widzi maks. 10 000 wynikow, wiec katalog chodzi sie po niszach (skrypt backfillu). Pliki media.redgifs.com sa publiczne i bez podpisu (206 z VPS-a i z telefonu), do bazy wchodzi gotowy adres. Scheduler: job clips-latest co godzine (5 stron), zatrzymuje sie na stronie bez nowych. Reaper (HEAD, 403/404/410 -> dead_at) jako skrypt, upsert odzywia klip, ktory zrodlo znow podaje. API: GET /clips i /clips/stats do pilota; wlasciwy feed, pamiec widzianych i telemetria to etap 3. Sprawdzone tego dnia i odlozone: nsfwswipe (lustro v.redd.it, bez audio, stoi od 19.08), Pornhub Shorties (zrodlo nr 2, HLS z tokenem 2 h, Referer na segmentach, 0/110 performerow z kanonu).
158 lines
4.9 KiB
Python
158 lines
4.9 KiB
Python
"""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,
|
|
)
|