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:
parent
59b7b5a651
commit
f1b8b8d466
4 changed files with 127 additions and 1 deletions
34
alembic/versions/20260902_0034_favorite_sites.py
Normal file
34
alembic/versions/20260902_0034_favorite_sites.py
Normal 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")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
25
app/models/favorite_site.py
Normal file
25
app/models/favorite_site.py
Normal 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
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue