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
+3 -2
View File
@@ -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
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
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.
- **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,
progression en temps réel (SSE), persistance au redémarrage.
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
+26 -4
View File
@@ -25,6 +25,10 @@ class User:
username: str
is_admin: bool
is_active: bool
content_preference: str = "both" # anime | serie | both
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
# ---------------------------------------------------------------- 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:
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(),),
)
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)
return None
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:
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"]:
return None
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)
)
+12
View File
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
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'))
);
@@ -89,8 +91,18 @@ class Database:
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA)
await self._migrate()
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:
if self._conn is not None:
await self._conn.close()
+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}
+8
View File
@@ -89,6 +89,14 @@ button { font-family: inherit; }
}
.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 */
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
+7 -1
View File
@@ -11,7 +11,7 @@
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<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>
<nav class="topnav">
<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>
</nav>
<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>
<form method="post" action="/auth/logout" style="display:inline">
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
+129
View File
@@ -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"}