diff --git a/README.md b/README.md index 3d9740e..e54e3db 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/app/auth.py b/app/auth.py index b4bb664..1bf3d5f 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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) ) diff --git a/app/db.py b/app/db.py index c86dd49..6c50835 100644 --- a/app/db.py +++ b/app/db.py @@ -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() diff --git a/app/routers/auth.py b/app/routers/auth.py index a601205..c52d9c0 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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} diff --git a/app/routers/discover.py b/app/routers/discover.py index 6bd4964..2cb73cc 100644 --- a/app/routers/discover.py +++ b/app/routers/discover.py @@ -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} diff --git a/app/routers/search.py b/app/routers/search.py index e449450..546e649 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -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} diff --git a/app/static/css/style.css b/app/static/css/style.css index 7157814..4d62208 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; } diff --git a/app/templates/base.html b/app/templates/base.html index 75f5b82..50650ec 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,7 +11,7 @@
-