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:
@@ -117,9 +117,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
|||||||
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
|
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
|
||||||
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
|
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
|
||||||
endpoint « prepare » : MP4 direct ou HLS relayé par le proxy du site).
|
endpoint « prepare » : MP4 direct ou HLS relayé par le proxy du site).
|
||||||
|
- **Préférence de contenus par compte** : sélecteur topbar ⛨ Animés / 📺 Séries /
|
||||||
|
✨ Les deux — la recherche et les nouveautés filtrent par type de média, et les
|
||||||
|
sources hors périmètre ne sont même pas interrogées.
|
||||||
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
|
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
|
||||||
- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel).
|
|
||||||
- **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h.
|
|
||||||
- **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons,
|
- **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons,
|
||||||
progression en temps réel (SSE), persistance au redémarrage.
|
progression en temps réel (SSE), persistance au redémarrage.
|
||||||
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
|
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
|
||||||
|
|||||||
+26
-4
@@ -25,6 +25,10 @@ class User:
|
|||||||
username: str
|
username: str
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
content_preference: str = "both" # anime | serie | both
|
||||||
|
|
||||||
|
|
||||||
|
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- mots de passe
|
# ---------------------------------------------------------------- mots de passe
|
||||||
@@ -56,7 +60,8 @@ async def create_user(username: str, password: str) -> User:
|
|||||||
|
|
||||||
async def authenticate(username: str, password: str) -> User | None:
|
async def authenticate(username: str, password: str) -> User | None:
|
||||||
row = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?",
|
"SELECT id, username, password_hash, is_admin, is_active, content_preference "
|
||||||
|
"FROM users WHERE username = ?",
|
||||||
(username.strip(),),
|
(username.strip(),),
|
||||||
)
|
)
|
||||||
if row is None or not verify_password(password, row["password_hash"]):
|
if row is None or not verify_password(password, row["password_hash"]):
|
||||||
@@ -66,18 +71,35 @@ async def authenticate(username: str, password: str) -> User | None:
|
|||||||
logger.warning("Compte désactivé : %r", username)
|
logger.warning("Compte désactivé : %r", username)
|
||||||
return None
|
return None
|
||||||
return User(
|
return User(
|
||||||
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
is_admin=bool(row["is_admin"]),
|
||||||
|
is_active=True,
|
||||||
|
content_preference=row["content_preference"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: int) -> User | None:
|
async def get_user(user_id: int) -> User | None:
|
||||||
row = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,)
|
"SELECT id, username, is_admin, is_active, content_preference FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
)
|
)
|
||||||
if row is None or not row["is_active"]:
|
if row is None or not row["is_active"]:
|
||||||
return None
|
return None
|
||||||
return User(
|
return User(
|
||||||
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
is_admin=bool(row["is_admin"]),
|
||||||
|
is_active=True,
|
||||||
|
content_preference=row["content_preference"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_content_preference(user_id: int, preference: str) -> None:
|
||||||
|
if preference not in VALID_CONTENT_PREFERENCES:
|
||||||
|
raise ValueError(f"Préférence invalide : {preference!r}")
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE users SET content_preference = ? WHERE id = ?", (preference, user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
content_preference TEXT NOT NULL DEFAULT 'both'
|
||||||
|
CHECK (content_preference IN ('anime','serie','both')),
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -89,8 +91,18 @@ class Database:
|
|||||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
await self._conn.execute("PRAGMA foreign_keys=ON")
|
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
await self._conn.executescript(SCHEMA)
|
await self._conn.executescript(SCHEMA)
|
||||||
|
await self._migrate()
|
||||||
await self._conn.commit()
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def _migrate(self) -> None:
|
||||||
|
"""Migrations légères : colonnes ajoutées après coup pour les bases existantes."""
|
||||||
|
cursor = await self._conn.execute("PRAGMA table_info(users)")
|
||||||
|
columns = {row["name"] for row in await cursor.fetchall()}
|
||||||
|
if "content_preference" not in columns:
|
||||||
|
await self._conn.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN content_preference TEXT NOT NULL DEFAULT 'both'"
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._conn is not None:
|
if self._conn is not None:
|
||||||
await self._conn.close()
|
await self._conn.close()
|
||||||
|
|||||||
+20
-2
@@ -3,7 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
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 fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
@@ -107,4 +107,22 @@ async def logout(user: CurrentUser) -> RedirectResponse:
|
|||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(user: CurrentUser) -> dict:
|
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 fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.routers.auth import CurrentUser, current_user
|
from app.routers.auth import CurrentUser, current_user
|
||||||
|
from app.routers.search import _allowed_media_types
|
||||||
from app.services.discover import discover
|
from app.services.discover import discover
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)."""
|
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
|
||||||
must_watch = await discover.must_watch(must_watch_limit)
|
must_watch = await discover.must_watch(must_watch_limit)
|
||||||
for_you = await discover.for_you(user.id, for_you_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}
|
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 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 (
|
from app.scrapers.base import (
|
||||||
ScrapeError,
|
ScrapeError,
|
||||||
SourceScraper,
|
SourceScraper,
|
||||||
@@ -27,13 +27,35 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
|
|||||||
import_all_scrapers()
|
import_all_scrapers()
|
||||||
kitsu = KitsuService()
|
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 = []
|
sources = []
|
||||||
for source in all_sources():
|
for source in all_sources():
|
||||||
if await is_source_enabled(source.name):
|
if await is_source_enabled(source.name):
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
return sources
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources")
|
@router.get("/sources")
|
||||||
@@ -52,9 +74,10 @@ async def list_sources() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/search")
|
@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."""
|
"""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]:
|
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
|
||||||
try:
|
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))
|
outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
|
||||||
results = [item for items, _ in outcomes for item in items]
|
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]
|
failed = [name for _, name in outcomes if name]
|
||||||
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
|
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.icon-btn:hover { color: var(--text); background: var(--surface-2); }
|
.icon-btn:hover { color: var(--text); background: var(--surface-2); }
|
||||||
|
|
||||||
|
.pref-select {
|
||||||
|
background: var(--surface); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
font: inherit; font-size: 0.85rem;
|
||||||
|
padding: 0.35rem 0.5rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.pref-select:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------ layout */
|
/* ------------------------------------------------------------ layout */
|
||||||
|
|
||||||
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
|
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar" x-data="{ username: '' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => username = u.username).catch(() => {})">
|
<header class="topbar" x-data="{ username: '', pref: 'both' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => { username = u.username; pref = u.content_preference || 'both'; }).catch(() => {})">
|
||||||
<a class="logo" href="/">OHM<span>STREAM</span></a>
|
<a class="logo" href="/">OHM<span>STREAM</span></a>
|
||||||
<nav class="topnav">
|
<nav class="topnav">
|
||||||
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a>
|
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a>
|
||||||
@@ -22,6 +22,12 @@
|
|||||||
<a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
|
<a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
<select class="pref-select" x-model="pref" x-show="username" title="Types de contenus"
|
||||||
|
@change="fetch('/auth/preferences', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content_preference: pref }) }).then(() => location.reload())">
|
||||||
|
<option value="anime">⛩ Animés</option>
|
||||||
|
<option value="serie">📺 Séries</option>
|
||||||
|
<option value="both">✨ Les deux</option>
|
||||||
|
</select>
|
||||||
<div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
|
<div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
|
||||||
<form method="post" action="/auth/logout" style="display:inline">
|
<form method="post" action="/auth/logout" style="display:inline">
|
||||||
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
|
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests de la préférence de contenus par utilisateur (animés / séries / les deux)."""
|
||||||
|
|
||||||
|
from app.scrapers.base import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class MixedSource:
|
||||||
|
"""Source factice renvoyant animés, séries et films."""
|
||||||
|
|
||||||
|
name = "mixed"
|
||||||
|
label = "Mixed"
|
||||||
|
base_url = "https://mixed.example"
|
||||||
|
media_types = ("anime", "serie")
|
||||||
|
|
||||||
|
async def search(self, query: str) -> list[SearchResult]:
|
||||||
|
return [
|
||||||
|
SearchResult(source="mixed", source_id="a1", title="Animé X", url="https://mixed.example/a1"),
|
||||||
|
SearchResult(source="mixed", source_id="s1", title="Série Y", url="https://mixed.example/s1", media_type="serie"),
|
||||||
|
SearchResult(source="mixed", source_id="f1", title="Film Z", url="https://mixed.example/f1", media_type="film"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def latest(self) -> list[SearchResult]:
|
||||||
|
return await self.search("")
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeOnlySource:
|
||||||
|
name = "animeonly"
|
||||||
|
label = "AnimeOnly"
|
||||||
|
base_url = "https://animeonly.example"
|
||||||
|
media_types = ("anime",)
|
||||||
|
|
||||||
|
async def search(self, query: str) -> list[SearchResult]:
|
||||||
|
return [
|
||||||
|
SearchResult(source="animeonly", source_id="a2", title="Animé W", url="https://animeonly.example/a2")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def latest(self) -> list[SearchResult]:
|
||||||
|
return await self.search("")
|
||||||
|
|
||||||
|
|
||||||
|
async def _patch_sources(monkeypatch, *sources):
|
||||||
|
import app.routers.search as search_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(search_module, "all_sources", lambda: list(sources))
|
||||||
|
import app.services.discover as discover_module
|
||||||
|
|
||||||
|
async def enabled(name):
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||||
|
discover_module.discover._cache.clear()
|
||||||
|
monkeypatch.setattr(discover_module, "all_sources", lambda: list(sources))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- endpoints
|
||||||
|
|
||||||
|
|
||||||
|
async def test_me_exposes_default_preference(client, admin_cookies):
|
||||||
|
r = await client.get("/auth/me", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["content_preference"] == "both"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preference_roundtrip(client, admin_cookies):
|
||||||
|
r = await client.put(
|
||||||
|
"/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert (await client.get("/auth/me", cookies=admin_cookies)).json()["content_preference"] == "serie"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preference_rejects_invalid_value(client, admin_cookies):
|
||||||
|
r = await client.put(
|
||||||
|
"/auth/preferences", json={"content_preference": "documentaire"}, cookies=admin_cookies
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- filtrage recherche
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_both_keeps_everything(client, admin_cookies, monkeypatch):
|
||||||
|
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||||
|
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 4 # animés + série + film
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_anime_filters_serie(client, admin_cookies, monkeypatch):
|
||||||
|
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||||
|
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||||
|
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||||
|
types = {x["media_type"] for x in r.json()["results"]}
|
||||||
|
assert types == {"anime", "film"} # les films restent avec les animés
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypatch):
|
||||||
|
"""Préférence séries : les sources anime-only ne sont même pas interrogées."""
|
||||||
|
await _patch_sources(monkeypatch, MixedSource(), AnimeOnlySource())
|
||||||
|
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||||
|
|
||||||
|
queried = []
|
||||||
|
|
||||||
|
class SpySource(AnimeOnlySource):
|
||||||
|
async def search(self, query: str) -> list[SearchResult]:
|
||||||
|
queried.append(self.name)
|
||||||
|
return await super().search(query)
|
||||||
|
|
||||||
|
import app.routers.search as search_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(search_module, "all_sources", lambda: [MixedSource(), SpySource()])
|
||||||
|
|
||||||
|
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert queried == [] # source anime-only ignorée
|
||||||
|
assert {x["media_type"] for x in r.json()["results"]} == {"serie"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- filtrage découverte
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_latest_filtered_by_preference(client, admin_cookies, monkeypatch):
|
||||||
|
await _patch_sources(monkeypatch, MixedSource())
|
||||||
|
|
||||||
|
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||||
|
assert {i["media_type"] for i in r.json()["latest"]} == {"anime", "serie", "film"}
|
||||||
|
|
||||||
|
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
|
||||||
|
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||||
|
assert {i["media_type"] for i in r.json()["latest"]} == {"serie"}
|
||||||
Reference in New Issue
Block a user