feat(api): per-device saved searches (keyword favorites)

User-report (mobilism): scenes are often poorly titled, so saved keyword queries are a useful extra retrieval strategy. New saved_searches table (device-scoped via X-Device-Id, unique per device+query, 50/device cap) + GET/POST/DELETE /saved-searches. Migration 0024. Verified CRUD on prod: add trims+dedups idempotently, empty rejected 422, delete idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-16 13:52:18 +02:00
parent 0424cb9138
commit bcee5851e9
5 changed files with 187 additions and 0 deletions

View file

@ -0,0 +1,43 @@
"""saved searches: per-device saved keyword queries
Revision ID: 0024_saved_searches
Revises: 0023_bug_report_replies
Create Date: 2026-06-16
Zapisane słowa kluczowe per urządzenie (user-report mobilism: sceny słabo opisane
dodatkowe strategie wyszukiwania). Scope po device_id (X-Device-Id), unikat na
(device_id, query) żeby ten sam zapis był idempotentny.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0024_saved_searches"
down_revision: str | None = "0023_bug_report_replies"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"saved_searches",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("query", sa.String(length=256), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name="pk_saved_searches"),
sa.UniqueConstraint("device_id", "query", name="uq_saved_searches_device_query"),
)
op.create_index("ix_saved_searches_device_id", "saved_searches", ["device_id"])
def downgrade() -> None:
op.drop_index("ix_saved_searches_device_id", table_name="saved_searches")
op.drop_table("saved_searches")

108
app/api/saved_searches.py Normal file
View file

@ -0,0 +1,108 @@
"""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.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)
session.commit()
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()

View file

@ -18,6 +18,7 @@ from app.api.me import router as me_router
from app.api.movies import router as movies_router from app.api.movies import router as movies_router
from app.api.playback import movies_router as movies_playback_router from app.api.playback import movies_router as movies_playback_router
from app.api.playback import router as playback_router from app.api.playback import router as playback_router
from app.api.saved_searches import router as saved_searches_router
from app.api.scene_favorites import router as scene_favorites_router from app.api.scene_favorites import router as scene_favorites_router
from app.api.scenes import router as scenes_router from app.api.scenes import router as scenes_router
from app.api.seo import router as seo_router from app.api.seo import router as seo_router
@ -79,6 +80,7 @@ app.include_router(taxonomies_router)
app.include_router(favorites_router) app.include_router(favorites_router)
app.include_router(blacklist_router) app.include_router(blacklist_router)
app.include_router(bug_reports_router) app.include_router(bug_reports_router)
app.include_router(saved_searches_router)
app.include_router(expo_updates_router) app.include_router(expo_updates_router)
app.include_router(watch_router) app.include_router(watch_router)
app.include_router(me_router) app.include_router(me_router)

View file

@ -19,6 +19,7 @@ from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer, PerformerAlias, PerformerExternalRef from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
from app.models.saved_search import SavedSearch
from app.models.scene import ( from app.models.scene import (
Scene, Scene,
SceneExternalRef, SceneExternalRef,
@ -55,6 +56,7 @@ __all__ = [
"MoviePlayProgress", "MoviePlayProgress",
"ScenePlayProgress", "ScenePlayProgress",
"PlaybackSource", "PlaybackSource",
"SavedSearch",
"Scene", "Scene",
"SceneExternalRef", "SceneExternalRef",
"SceneFingerprint", "SceneFingerprint",

View file

@ -0,0 +1,32 @@
"""Saved searches — zapisane słowa kluczowe per urządzenie.
User-report mobilism: sceny bywają słabo opisane, więc potrzeba dodatkowych strategii
wyszukiwania zapisywanie często używanych zapytań. Scope per device_id (X-Device-Id),
jak reszta stanu usera. Unikat (device_id, query) ten sam zapis idempotentny.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class SavedSearch(Base):
__tablename__ = "saved_searches"
__table_args__ = (
UniqueConstraint("device_id", "query", name="uq_saved_searches_device_query"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
device_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
query: Mapped[str] = mapped_column(String(256), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)