diff --git a/alembic/versions/20260616_0024_saved_searches.py b/alembic/versions/20260616_0024_saved_searches.py new file mode 100644 index 0000000..6877e76 --- /dev/null +++ b/alembic/versions/20260616_0024_saved_searches.py @@ -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") diff --git a/app/api/saved_searches.py b/app/api/saved_searches.py new file mode 100644 index 0000000..ad07a24 --- /dev/null +++ b/app/api/saved_searches.py @@ -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() diff --git a/app/main.py b/app/main.py index 5bf7392..77b003f 100644 --- a/app/main.py +++ b/app/main.py @@ -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.playback import movies_router as movies_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.scenes import router as scenes_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(blacklist_router) app.include_router(bug_reports_router) +app.include_router(saved_searches_router) app.include_router(expo_updates_router) app.include_router(watch_router) app.include_router(me_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 21eff72..d430569 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,6 +19,7 @@ from app.models.movie_playback_source import MoviePlaybackSource from app.models.performer import Performer, PerformerAlias, PerformerExternalRef from app.models.play_progress import MoviePlayProgress, ScenePlayProgress from app.models.playback_source import PlaybackSource +from app.models.saved_search import SavedSearch from app.models.scene import ( Scene, SceneExternalRef, @@ -55,6 +56,7 @@ __all__ = [ "MoviePlayProgress", "ScenePlayProgress", "PlaybackSource", + "SavedSearch", "Scene", "SceneExternalRef", "SceneFingerprint", diff --git a/app/models/saved_search.py b/app/models/saved_search.py new file mode 100644 index 0000000..128efaa --- /dev/null +++ b/app/models/saved_search.py @@ -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 + )