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:
+20
-2
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user