From 0c03f4f4a62e7ba465e78d43d163fc38c1ece03b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Mar 2026 16:12:29 +0000 Subject: [PATCH] feat: add Settings tab with provider management and language preferences - Implemented AppSettings model and table using SQLModel. - Created Settings router with endpoints for preferences and provider toggling. - Added Settings tab to the UI with real-time health status of providers. - Integrated language and provider filtering into anime and series search logic. - Updated templates to respect user-defined settings. --- app/database.py | 1 + app/models/__init__.py | 1 + app/models/settings.py | 58 +++++++ app/routers/__init__.py | 7 +- app/routers/router_anime.py | 44 ++++- app/routers/router_settings.py | 160 ++++++++++++++++++ main.py | 2 + static/js/main.js | 4 +- templates/components/anime_card.html | 13 +- .../components/anime_search_results.html | 2 +- templates/components/header.html | 9 + templates/components/series_card.html | 13 +- .../components/series_search_results.html | 2 +- templates/components/settings_section.html | 84 +++++++++ templates/index.html | 8 + 15 files changed, 383 insertions(+), 25 deletions(-) create mode 100644 app/models/settings.py create mode 100644 app/routers/router_settings.py create mode 100644 templates/components/settings_section.html diff --git a/app/database.py b/app/database.py index 50cdf26..93e4b12 100644 --- a/app/database.py +++ b/app/database.py @@ -22,6 +22,7 @@ def create_db_and_tables(): from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable from app.models.favorites import FavoriteTable from app.models.sonarr import SonarrMappingTable, SonarrConfigTable + from app.models.settings import AppSettingsTable SQLModel.metadata.create_all(engine) diff --git a/app/models/__init__.py b/app/models/__init__.py index 3363b09..21bcbca 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -69,3 +69,4 @@ from .auth import UserTable from .watchlist import WatchlistItemTable, WatchlistSettingsTable from .favorites import FavoriteTable from .sonarr import SonarrMappingTable, SonarrConfigTable +from .settings import AppSettingsTable diff --git a/app/models/settings.py b/app/models/settings.py new file mode 100644 index 0000000..d43af5a --- /dev/null +++ b/app/models/settings.py @@ -0,0 +1,58 @@ +"""Models for application settings with SQLModel support""" +import uuid +import json +from pydantic import BaseModel, Field as PydanticField +from typing import Optional, List, Dict +from datetime import datetime +from sqlmodel import SQLModel, Field, Column, String + + +class AppSettingsBase(SQLModel): + """Base schema for application settings""" + default_lang: str = Field(default="vostfr") + theme: str = Field(default="dark") + + # Store list of disabled providers as a JSON string + disabled_providers_json: str = Field(default="[]", sa_column=Column(String)) + + @property + def disabled_providers(self) -> List[str]: + try: + return json.loads(self.disabled_providers_json or "[]") + except: + return [] + + @disabled_providers.setter + def disabled_providers(self, value: List[str]): + self.disabled_providers_json = json.dumps(value or []) + + +class AppSettingsTable(AppSettingsBase, table=True): + """Database table for application settings""" + __tablename__ = "app_settings" + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + primary_key=True, + index=True, + nullable=False + ) + user_id: str = Field(foreign_key="users.id", index=True, unique=True) + updated_at: datetime = Field(default_factory=datetime.now) + + +class AppSettings(BaseModel): + """Application settings (API Response)""" + default_lang: str = "vostfr" + theme: str = "dark" + disabled_providers: List[str] = [] + + class Config: + from_attributes = True + + +class AppSettingsUpdate(BaseModel): + """Model for updating application settings""" + default_lang: Optional[str] = None + theme: Optional[str] = None + disabled_providers: Optional[List[str]] = None diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 896ff4b..1cc67a9 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -10,8 +10,9 @@ from app.routers.router_recommendations import router as recommendations_router from app.routers.router_watchlist import router as watchlist_router from app.routers.router_sonarr import router as sonarr_router from app.routers.router_player import router as player_router -from app.routers.router_static import router as static_router -from app.routers.router_root import router as root_router +from .router_static import router as static_router +from .router_root import router as root_router +from .router_settings import router as settings_router __all__ = [ "auth_router", @@ -24,4 +25,6 @@ __all__ = [ "player_router", "static_router", "root_router", + "settings_router", ] + diff --git a/app/routers/router_anime.py b/app/routers/router_anime.py index 7d37620..0e29f09 100644 --- a/app/routers/router_anime.py +++ b/app/routers/router_anime.py @@ -11,7 +11,12 @@ import hashlib from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response from fastapi.templating import Jinja2Templates +from sqlmodel import Session, select +from app.database import get_session +from app.models.settings import AppSettingsTable +from app.routers.router_auth import get_current_user_from_token +from app.models.auth import User from app.download_manager import DownloadManager from app.downloaders import ( AnimeSamaDownloader, @@ -67,6 +72,8 @@ async def search_anime_unified( lang: str = "vostfr", include_metadata: bool = False, html: bool = Query(False), + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) ): """ Search across all anime providers. @@ -75,6 +82,11 @@ async def search_anime_unified( print(f"\n[SEARCH] Starting search for '{q}'. html={html}") start_time = time.time() + # Get user settings for disabled providers + statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + settings_obj = session.exec(statement).first() + disabled_providers = settings_obj.disabled_providers if settings_obj else [] + results = {} # 1. Prepare search tasks (Generic + Legacy) @@ -84,8 +96,9 @@ async def search_anime_unified( # Generic YAML providers active_generic = providers_manager.get_active_providers() for provider in active_generic: - search_tasks.append(provider.search(q)) - task_metadata.append({"id": provider.id, "type": "generic"}) + if provider.id not in disabled_providers: + search_tasks.append(provider.search(q)) + task_metadata.append({"id": provider.id, "type": "generic"}) # Legacy providers legacy_downloaders = { @@ -94,8 +107,9 @@ async def search_anime_unified( "vostfree": VostfreeDownloader(), } for pid, dl in legacy_downloaders.items(): - search_tasks.append(dl.search_anime(q, lang, include_metadata=False)) - task_metadata.append({"id": pid, "type": "legacy"}) + if pid not in disabled_providers: + search_tasks.append(dl.search_anime(q, lang, include_metadata=False)) + task_metadata.append({"id": pid, "type": "legacy"}) # 2. Run searches in parallel all_raw_results = await asyncio.gather(*search_tasks, return_exceptions=True) @@ -163,7 +177,11 @@ async def search_anime_unified( print("[SEARCH] Returning HTML response") return templates.TemplateResponse( "components/anime_search_results.html", - {"request": request, "results": results} + { + "request": request, + "results": results, + "settings": settings_obj + } ) print("[SEARCH] Returning JSON response") @@ -181,6 +199,8 @@ async def search_series_unified( q: str, lang: str = "vf", html: bool = Query(False), + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) ): """ Search across all TV series providers (FS7, etc.) @@ -191,6 +211,12 @@ async def search_series_unified( print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}. html={html}") start_time = time.time() + + # Get user settings for disabled providers + statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + settings_obj = session.exec(statement).first() + disabled_providers = settings_obj.disabled_providers if settings_obj else [] + results = {} series_downloaders = { "fs7": FS7Downloader(), @@ -200,7 +226,7 @@ async def search_series_unified( provider_ids = [] for provider_id, provider in get_series_providers().items(): - if provider_id in series_downloaders: + if provider_id in series_downloaders and provider_id not in disabled_providers: downloader = series_downloaders[provider_id] search_tasks.append(downloader.search_anime(q, lang)) provider_ids.append(provider_id) @@ -221,7 +247,11 @@ async def search_series_unified( if html or request.headers.get("HX-Request"): return templates.TemplateResponse( "components/series_search_results.html", - {"request": request, "results": results} + { + "request": request, + "results": results, + "settings": settings_obj + } ) return {"query": q, "lang": lang, "results": results} diff --git a/app/routers/router_settings.py b/app/routers/router_settings.py new file mode 100644 index 0000000..44ea36c --- /dev/null +++ b/app/routers/router_settings.py @@ -0,0 +1,160 @@ +"""Application settings routes for Ohm Stream Downloader API""" +import json +from typing import List, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.templating import Jinja2Templates +from sqlmodel import Session, select + +from app.database import get_session +from app.models.auth import User, UserTable +from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate +from app.routers.router_auth import get_current_user_from_token +from app.providers import get_anime_providers, get_series_providers +from app.providers_manager import providers_manager + +router = APIRouter(prefix="/api/settings", tags=["settings"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("", response_model=AppSettings) +async def get_settings( + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) +): + """Get current application settings for the user""" + statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + settings_obj = session.exec(statement).first() + + if not settings_obj: + # Create default settings if they don't exist + settings_obj = AppSettingsTable(user_id=current_user.id) + session.add(settings_obj) + session.commit() + session.refresh(settings_obj) + + return AppSettings( + default_lang=settings_obj.default_lang, + theme=settings_obj.theme, + disabled_providers=settings_obj.disabled_providers + ) + + +@router.patch("", response_model=AppSettings) +async def update_settings( + update_data: AppSettingsUpdate, + response: Response, + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) +): + """Update application settings for the user""" + statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + settings_obj = session.exec(statement).first() + + if not settings_obj: + settings_obj = AppSettingsTable(user_id=current_user.id) + session.add(settings_obj) + + if update_data.default_lang is not None: + settings_obj.default_lang = update_data.default_lang + if update_data.theme is not None: + settings_obj.theme = update_data.theme + if update_data.disabled_providers is not None: + settings_obj.disabled_providers = update_data.disabled_providers + + session.add(settings_obj) + session.commit() + session.refresh(settings_obj) + + response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}) + + return AppSettings( + default_lang=settings_obj.default_lang, + theme=settings_obj.theme, + disabled_providers=settings_obj.disabled_providers + ) + + +@router.get("/providers/availability") +async def get_providers_availability( + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) +): + """Get list of providers with their availability and enabled status""" + # Get user settings + statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + settings_obj = session.exec(statement).first() + disabled_providers = settings_obj.disabled_providers if settings_obj else [] + + # Get health status + health_status = providers_manager.get_all_status() + + # Combine anime and series providers + all_providers = {**get_anime_providers(), **get_series_providers()} + + result = [] + for pid, info in all_providers.items(): + status_info = health_status.get(pid, {"status": "unknown"}) + result.append({ + "id": pid, + "name": info["name"], + "icon": info.get("icon", "🎬"), + "status": status_info["status"], + "enabled": pid not in disabled_providers, + "error": status_info.get("error") + }) + + return result + + +@router.post("/providers/{provider_id}/toggle") +async def toggle_provider( + provider_id: str, + response: Response, + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) +): + """Toggle a provider's enabled/disabled status""" + statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + settings_obj = session.exec(statement).first() + + if not settings_obj: + settings_obj = AppSettingsTable(user_id=current_user.id) + session.add(settings_obj) + + disabled = settings_obj.disabled_providers + if provider_id in disabled: + disabled.remove(provider_id) + enabled = True + else: + disabled.append(provider_id) + enabled = False + + settings_obj.disabled_providers = disabled + session.add(settings_obj) + session.commit() + + status_text = "activé" if enabled else "désactivé" + response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Fournisseur {provider_id} {status_text}", "type": "success"}}) + + return {"id": provider_id, "enabled": enabled} + + +@router.get("/ui") +async def get_settings_ui( + request: Request, + current_user: User = Depends(get_current_user_from_token), + session: Session = Depends(get_session) +): + """Return the settings UI fragment for HTMX""" + # Reuse existing endpoints logic + settings = await get_settings(current_user, session) + providers = await get_providers_availability(current_user, session) + + return templates.TemplateResponse( + "components/settings_section.html", + { + "request": request, + "settings": settings, + "providers": providers + } + ) diff --git a/main.py b/main.py index 715f59b..620a2de 100644 --- a/main.py +++ b/main.py @@ -126,6 +126,7 @@ from app.routers import ( player_router, static_router, root_router, + settings_router, ) @@ -140,6 +141,7 @@ app.include_router(watchlist_router) app.include_router(sonarr_router) app.include_router(player_router) app.include_router(static_router) +app.include_router(settings_router) if __name__ == "__main__": diff --git a/static/js/main.js b/static/js/main.js index 523629f..4d76393 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -26,7 +26,7 @@ function switchTab(tabName) { // Handle URL hash on page load if (window.location.hash) { const hash = window.location.hash.substring(1); - const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads']; + const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads', 'settings']; if (validTabs.includes(hash)) { // Short delay to ensure Alpine is ready setTimeout(() => switchTab(hash), 100); @@ -37,7 +37,7 @@ if (window.location.hash) { window.addEventListener('hashchange', function() { if (window.location.hash) { const hash = window.location.hash.substring(1); - const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads']; + const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads', 'settings']; if (validTabs.includes(hash)) { switchTab(hash); } diff --git a/templates/components/anime_card.html b/templates/components/anime_card.html index bdc9182..2fa1b2a 100644 --- a/templates/components/anime_card.html +++ b/templates/components/anime_card.html @@ -1,4 +1,4 @@ -{% macro anime_card(anime, in_watchlist=False) %} +{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %} @@ -17,7 +17,7 @@
diff --git a/templates/components/header.html b/templates/components/header.html index a9206a3..f7bb119 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -65,5 +65,14 @@ Téléchargements + diff --git a/templates/components/series_card.html b/templates/components/series_card.html index d498cfc..08f1aac 100644 --- a/templates/components/series_card.html +++ b/templates/components/series_card.html @@ -1,4 +1,4 @@ -{% macro series_card(series, in_watchlist=False) %} +{% macro series_card(series, in_watchlist=False, lang='vf') %}
diff --git a/templates/components/settings_section.html b/templates/components/settings_section.html new file mode 100644 index 0000000..be31d87 --- /dev/null +++ b/templates/components/settings_section.html @@ -0,0 +1,84 @@ +
+
+

⚙️ Paramètres

+
+ + +
+

Général

+ +
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+

Disponibilité des Fournisseurs

+ +
+ +
+ {% for provider in providers %} +
+
+ {{ provider.icon }} +
+
{{ provider.name }}
+
+ + + {{ provider.status | upper }} + +
+
+
+ + +
+ {% endfor %} +
+
+
+ + diff --git a/templates/index.html b/templates/index.html index 7eb5c09..7962492 100644 --- a/templates/index.html +++ b/templates/index.html @@ -142,6 +142,14 @@ {% include "components/downloads_section.html" %}
+
+
+
+
Chargement des paramètres... +
+
+
+