goon/app/models/clip.py
goon-foss 4f5de92bfc feat(clips): tabele klipow do feedu Swipe + ingest RedGifs (etap 2)
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).
2026-09-14 16:32:57 +02:00

85 lines
4.1 KiB
Python

"""Klipy do feedu Swipe (sekcja w stylu TikToka). Osobny wertykał, NIE sceny.
Klip to 5-120 s pionowego wideo od twórcy-amatora (RedGifs, Pornhub Shorties, nsfwswipe),
bez obsady z kanonu TPDB, bez dedupu ze scenami i bez filtra 60 s, którym grid ukrywa
krótkie rzeczy. Dlatego własne tabele zamiast wciskania tego w `scenes`:
inne kolumny (twórca, audio, lajki, nisze), inny cykl życia (twórcy kasują klipy, więc
reaper co dobę), inna taksonomia (tagi RedGifs to nie tagi Goona, 1495 vs nasze).
`(source, source_id)` identyfikuje klip u źródła (`redgifs` + id gifa). Adresy plików
RedGifs są publiczne i bez podpisu (`media.redgifs.com/<Id>.mp4`), więc trzymamy je
wprost i telefon gra direct, bez ekstraktora i bez VPS-a. Pornhub Shorties (etap
późniejszy) będzie miał `hd_url` puste i resolve na żądanie.
Tagi w osobnej tabeli z indeksem `(tag, clip_id)`: feed dobiera kandydatów skanem po tagu
od losowego uuid (jak `sort=random` w `list_scenes`), więc para w tej kolejności to jego
główny indeks. Nisze (społeczności RedGifs) jako tablica, bo służą tylko onboardingowi
i blacklistom, nie skanom.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPKMixin
class Clip(UUIDPKMixin, TimestampMixin, Base):
__tablename__ = "clips"
__table_args__ = (
UniqueConstraint("source", "source_id", name="uq_clips_source_source_id"),
Index("ix_clips_published_at", "published_at"),
Index("ix_clips_creator", "creator"),
)
#: `redgifs` | `pornhub` | `nsfwswipe`
source: Mapped[str] = mapped_column(String(16), nullable=False)
source_id: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
#: Pseudonim twórcy u źródła (RedGifs `userName`). Nie łączymy z `performers`.
creator: Mapped[str | None] = mapped_column(String(128), nullable=True)
creator_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
duration_sec: Mapped[float | None] = mapped_column(Float, nullable=True)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
has_audio: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
#: RedGifs `contentType`, np. "Solo Female".
content_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
niches: Mapped[list[str]] = mapped_column(ARRAY(String(64)), nullable=False, default=list)
hd_url: Mapped[str | None] = mapped_column(Text, nullable=True)
sd_url: Mapped[str | None] = mapped_column(Text, nullable=True)
poster_url: Mapped[str | None] = mapped_column(Text, nullable=True)
thumb_url: Mapped[str | None] = mapped_column(Text, nullable=True)
#: Źródło deklaruje wariant HLS (RedGifs `hls`). Informacyjne, gramy mp4.
hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
views: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
#: Data publikacji u źródła (RedGifs `createDate`), do odznaki „nowe" i sortu.
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dead_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dead_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
class ClipTag(Base):
__tablename__ = "clip_tags"
__table_args__ = (Index("ix_clip_tags_tag_clip", "tag", "clip_id"),)
clip_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("clips.id", ondelete="CASCADE"), primary_key=True
)
#: Slug tagu u źródła, małe litery, spacje jako myślniki ("big-tits").
tag: Mapped[str] = mapped_column(String(64), primary_key=True)