feat(favorites): przypinane strony (favorite_sites) z endpointami list/add/remove

Prosba z apki 2026-09-02: ulubione strony maja siedziec na gorze zakladki Sites. Po
stronie serwera jak reszta ulubionych, bo kod synchronizacji to device_id i tabele
kluczowane po nim przechodza na nowe urzadzenie razem. Origin bez klucza obcego
(zrodla nie maja tabeli po origin), za to przy dodawaniu sprawdzany w playback_sources,
a sam napis ograniczony wzorcem, zeby nie dalo sie wpisac smieci.

Migracja 0034 (create table, bez blokad). Sprawdzone TestClientem w kontenerze api:
dodanie, powtorne dodanie (created=false), lista w kolejnosci dodania, nieznany origin
404, zly wzorzec 422, usuniecie 204.
This commit is contained in:
goon-foss 2026-09-02 22:44:35 +02:00
parent 59b7b5a651
commit f1b8b8d466
4 changed files with 127 additions and 1 deletions

View file

@ -0,0 +1,34 @@
"""ulubione strony: przypinanie zrodel na gorze zakladki Sites
Prosba z apki 2026-09-02. Tabela jak favorite_studios, kluczowana po device_id, wiec
kod synchronizacji (= device_id) przenosi ja na inne urzadzenie razem z reszta.
Origin bez klucza obcego: zrodla nie maja tabeli kluczowanej po origin.
Revision ID: 0034_favorite_sites
Revises: 0033_effective_date_idx
Create Date: 2026-09-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0034_favorite_sites"
down_revision = "0033_effective_date_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"favorite_sites",
sa.Column("device_id", sa.String(64), primary_key=True),
sa.Column("origin", sa.String(64), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
def downgrade() -> None:
op.drop_table("favorite_sites")

View file

@ -25,7 +25,7 @@ import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Path, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import exists, func, select from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -35,6 +35,7 @@ from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.favorite_movie import FavoriteMovie from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_site import FavoriteSite
from app.models.favorite_studio import FavoriteStudio from app.models.favorite_studio import FavoriteStudio
from app.models.movie import Movie from app.models.movie import Movie
from app.models.performer import Performer from app.models.performer import Performer
@ -442,6 +443,70 @@ def mark_studio_seen(
return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at) return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at)
# ---------- Sites (origins) ----------
# Przypiete strony w zakladce Sites (prosba z apki 2026-09-02). Bez licznikow "+N": lista
# zrodel i tak pokazuje swiezosc per strona, a badge liczy sie tylko dla performerow,
# studiow i filmow.
_ORIGIN_RE = r"^[a-z0-9][a-z0-9:._-]{1,63}$"
class FavoriteSiteListOut(BaseModel):
items: list[str] # origins, w kolejnosci dodania
total: int
class FavoriteSiteAddOut(BaseModel):
origin: str
created: bool
@router.get("/sites", response_model=FavoriteSiteListOut)
def list_favorite_sites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteListOut:
rows = session.execute(
select(FavoriteSite.origin)
.where(FavoriteSite.device_id == device_id)
.order_by(FavoriteSite.created_at)
).scalars().all()
return FavoriteSiteListOut(items=list(rows), total=len(rows))
@router.post("/sites/{origin}", response_model=FavoriteSiteAddOut)
def add_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteAddOut:
from app.models.playback_source import PlaybackSource
znany = session.execute(
select(1).where(PlaybackSource.origin == origin).limit(1)
).scalar_one_or_none()
if znany is None:
raise HTTPException(status_code=404, detail="unknown origin")
if session.get(FavoriteSite, (device_id, origin)) is not None:
return FavoriteSiteAddOut(origin=origin, created=False)
session.add(FavoriteSite(device_id=device_id, origin=origin))
session.commit()
return FavoriteSiteAddOut(origin=origin, created=True)
@router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT)
def remove_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
fav = session.get(FavoriteSite, (device_id, origin))
if fav is None:
return
session.delete(fav)
session.commit()
# ── Favorite movies ──────────────────────────────────────────────────────── # ── Favorite movies ────────────────────────────────────────────────────────
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc # Movies nie mają child scenes per-favorite (jak performerki/studia), więc
# `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego # `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego

View file

@ -5,6 +5,7 @@ from app.models.external_record import ExternalRecord
from app.models.favorite_movie import FavoriteMovie from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_scene import FavoriteScene from app.models.favorite_scene import FavoriteScene
from app.models.favorite_site import FavoriteSite
from app.models.favorite_studio import FavoriteStudio from app.models.favorite_studio import FavoriteStudio
from app.models.ingest_run import IngestRun from app.models.ingest_run import IngestRun
from app.models.merge_candidate import MergeCandidate from app.models.merge_candidate import MergeCandidate
@ -46,6 +47,7 @@ __all__ = [
"FavoriteMovie", "FavoriteMovie",
"FavoritePerformer", "FavoritePerformer",
"FavoriteScene", "FavoriteScene",
"FavoriteSite",
"FavoriteStudio", "FavoriteStudio",
"IngestRun", "IngestRun",
"MergeCandidate", "MergeCandidate",

View file

@ -0,0 +1,25 @@
"""Ulubione strony (origins), single-user, in-app. Mirror FavoriteStudio struktury.
Prosba z apki 2026-09-02: przypiac wybrane strony na gorze zakladki Sites. Po stronie
serwera, a nie w SecureStore, bo reszta ulubionych tez jest tu i przechodzi na inne
urzadzenie kodem synchronizacji (kod = device_id). Origin to zwykly napis
(`tube:hqpornercom`), bez FK: zrodla nie maja tabeli kluczowanej po origin.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class FavoriteSite(Base):
__tablename__ = "favorite_sites"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
origin: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)