Zgloszenie 4cccc70f: sxyprn na liscie sites ladowal sie ok 18 sekund. Odtworzone: /scenes?origin=tube:sxyprncom&sort=release_date schodzilo 19,23 s, a to samo z sort=created_at 0,08 s. Ekran serwisu sortuje po dacie premiery, czyli release_date DESC NULLS LAST, created_at DESC, id DESC, a istniejacy ix_scenes_release_date jest ROSNACY: czytany od tylu daje NULL-e na poczatku i nie obsluguje kluczy rozstrzygajacych. Planner leial wiec pelnym skanem scenes (3,9 mln) i playback_sources (3,7 mln). Po zalozeniu pasujacego indeksu strumieniuje w docelowej kolejnosci i konczy po 25 trafieniach: sxyprn 19,23 s -> 0,11 s, hqporner 7,99 s -> 0,87 s. Drugi indeks po originie, bo playback_sources nie mial zadnego. GOON-4Y: add_saved_search sprawdzal istnienie i wstawial w dwoch krokach, wiec dwa rownolegle zapisy tego samego zapytania (podwojne tapniecie, ponowienie po timeoucie) oba widzialy pusto i drugi dostawal 500 na uq_saved_searches_device_query. Teraz IntegrityError konczy sie oddaniem istniejacego wiersza. Test: 6 rownoleglych zapisow -> 6x200 i jeden wiersz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.1 KiB
Python
125 lines
4.1 KiB
Python
"""Saved searches, zapisane słowa kluczowe per urządzenie.
|
|
|
|
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
|
|
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
|
|
|
|
Endpointy:
|
|
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
|
|
POST /saved-searches dodaj {query} (idempotent na device+query)
|
|
DELETE /saved-searches/{id} usuń
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, field_validator
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.device import get_device_id
|
|
from app.auth import require_api_key
|
|
from app.db import get_session
|
|
from app.models.saved_search import SavedSearch
|
|
|
|
router = APIRouter(
|
|
prefix="/saved-searches",
|
|
tags=["saved-searches"],
|
|
dependencies=[Depends(require_api_key)],
|
|
)
|
|
|
|
# Limit zapisów per urządzenie, to wygodny skrót, nie nieograniczona kolekcja.
|
|
MAX_PER_DEVICE = 50
|
|
|
|
|
|
class SavedSearchOut(BaseModel):
|
|
id: uuid.UUID
|
|
query: str
|
|
created_at: datetime
|
|
|
|
|
|
class SavedSearchIn(BaseModel):
|
|
query: str
|
|
|
|
@field_validator("query")
|
|
@classmethod
|
|
def _clean(cls, v: str) -> str:
|
|
v = (v or "").strip()
|
|
if not v:
|
|
raise ValueError("query empty")
|
|
return v[:256]
|
|
|
|
|
|
@router.get("", response_model=list[SavedSearchOut])
|
|
def list_saved_searches(
|
|
session: Annotated[Session, Depends(get_session)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> list[SavedSearch]:
|
|
rows = session.execute(
|
|
select(SavedSearch)
|
|
.where(SavedSearch.device_id == device_id)
|
|
.order_by(SavedSearch.created_at.desc())
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
|
|
@router.post("", response_model=SavedSearchOut, status_code=status.HTTP_200_OK)
|
|
def add_saved_search(
|
|
payload: SavedSearchIn,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> SavedSearch:
|
|
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
|
|
existing = session.execute(
|
|
select(SavedSearch).where(
|
|
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
return existing
|
|
count = session.execute(
|
|
select(SavedSearch.id).where(SavedSearch.device_id == device_id)
|
|
).scalars().all()
|
|
if len(count) >= MAX_PER_DEVICE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"saved-search limit ({MAX_PER_DEVICE}) reached",
|
|
)
|
|
row = SavedSearch(device_id=device_id, query=payload.query)
|
|
session.add(row)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError:
|
|
# Wyscig na `uq_saved_searches_device_query`: sprawdzenie wyzej i INSERT to dwie
|
|
# osobne operacje, wiec dwa rownolegle zapisy tego samego zapytania (podwojne
|
|
# tapniecie, ponowienie po timeoucie) oba widza pusto i oba wstawiaja. Drugi
|
|
# dostawal 500 zamiast idempotentnej odpowiedzi (GOON-4Y). Skoro wiersz juz
|
|
# istnieje, to dokladnie ten, ktory user chcial zapisac, wiec go oddajemy.
|
|
session.rollback()
|
|
existing = session.execute(
|
|
select(SavedSearch).where(
|
|
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is None:
|
|
raise
|
|
return existing
|
|
session.refresh(row)
|
|
return row
|
|
|
|
|
|
@router.delete("/{search_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def remove_saved_search(
|
|
search_id: uuid.UUID,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> None:
|
|
row = session.get(SavedSearch, search_id)
|
|
# Tylko własne urządzenie może usuwać; cudze/nieistniejące → idempotentny no-op.
|
|
if row is None or row.device_id != device_id:
|
|
return
|
|
session.delete(row)
|
|
session.commit()
|