Préférence de contenus par compte : animés / séries / les deux

- Colonne users.content_preference (anime|serie|both, défaut both) avec
  migration automatique des bases existantes.
- GET /auth/me expose la préférence ; PUT /auth/preferences la met à
  jour (422 si valeur invalide).
- /api/search : filtre les résultats par media_type ET ignore les
  sources hors périmètre (une source anime-only n'est pas interrogée
  en mode séries). Les films restent associés aux animés.
- /api/discover : nouveautés filtrées selon la même règle.
- Sélecteur compact dans la topbar (⛩/📺/✨), persisté serveur.
- 7 tests dédiés (roundtrip, rejet invalide, filtrage search/discover,
  skip de sources) — 114 au total, ruff clean.

Prête pour la première source de séries : il ne reste qu'un module
SourceScraper avec media_types=("serie",) à écrire.
This commit is contained in:
Roman
2026-09-23 15:02:59 +00:00
parent 65761885ba
commit 28f1bbde31
9 changed files with 237 additions and 15 deletions
+20 -2
View File
@@ -3,7 +3,7 @@
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from app import auth
@@ -107,4 +107,22 @@ async def logout(user: CurrentUser) -> RedirectResponse:
@router.get("/me")
async def me(user: CurrentUser) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"content_preference": user.content_preference,
}
@router.put("/preferences")
async def update_preferences(
user: CurrentUser,
content_preference: Annotated[str, Body(embed=True)],
) -> dict:
"""Préférence de contenus : animés, séries, ou les deux."""
try:
await auth.set_content_preference(user.id, content_preference)
except ValueError as exc:
raise HTTPException(422, detail=str(exc)) from exc
return {"content_preference": content_preference}
+3 -1
View File
@@ -6,6 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.routers.auth import CurrentUser, current_user
from app.routers.search import _allowed_media_types
from app.services.discover import discover
logger = logging.getLogger(__name__)
@@ -23,5 +24,6 @@ async def get_discover(
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
must_watch = await discover.must_watch(must_watch_limit)
for_you = await discover.for_you(user.id, for_you_limit)
latest = await discover.latest(latest_limit)
allowed = _allowed_media_types(user.content_preference)
latest = [i for i in await discover.latest(latest_limit) if i.get("media_type", "anime") in allowed]
return {"latest": latest, "must_watch": must_watch, "for_you": for_you}
+29 -5
View File
@@ -7,7 +7,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.routers.auth import current_user
from app.routers.auth import CurrentUser, current_user
from app.scrapers.base import (
ScrapeError,
SourceScraper,
@@ -27,13 +27,35 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
import_all_scrapers()
kitsu = KitsuService()
# Types de médias gardés selon la préférence : les films restent avec les animés
# (cinéma d'animation), une future source séries les exclurait donc du mode « séries ».
_PREFERENCE_MEDIA_TYPES = {
"anime": {"anime", "film"},
"serie": {"serie"},
"both": {"anime", "serie", "film"},
}
def _allowed_media_types(preference: str) -> set[str]:
return _PREFERENCE_MEDIA_TYPES.get(preference, _PREFERENCE_MEDIA_TYPES["both"])
async def enabled_sources(allowed: set[str] | None = None) -> list[SourceScraper]:
"""Sources activées, limitées à celles pouvant servir les types de médias autorisés."""
sources = []
for source in all_sources():
if not await is_source_enabled(source.name):
continue
if allowed is not None and not set(source.media_types) & allowed:
continue
sources.append(source)
return sources
async def enabled_sources() -> list[SourceScraper]:
sources = []
for source in all_sources():
if await is_source_enabled(source.name):
sources.append(source)
return sources
@router.get("/sources")
@@ -52,9 +74,10 @@ async def list_sources() -> list[dict]:
@router.get("/search")
async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
async def search(q: Annotated[str, Query(min_length=2)], user: CurrentUser) -> dict:
"""Recherche unifiée : une requête interroge toutes les sources activées."""
sources = await enabled_sources()
allowed = _allowed_media_types(user.content_preference)
sources = await enabled_sources(allowed)
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
try:
@@ -66,6 +89,7 @@ async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
results = [item for items, _ in outcomes for item in items]
results = [r for r in results if r.get("media_type", "anime") in allowed]
failed = [name for _, name in outcomes if name]
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}