feat: fix auth, provider health checks, search, and redesign UI
- Fix register/login: dict-style access on UserTable ORM objects - Fix HTMX auth: inject JWT token in all HTMX request headers - Fix FS7 search: use DLE AJAX endpoint /engine/ajax/search.php - Fix ZT search: use ?p=series&search=QUERY (not DLE format) - Fix provider health: load hardcoded providers + domain manager - Add self.id to all anime/series providers - Redesign homepage: Netflix-style horizontal scroll cards (.hc) - Redesign search results: grouped by title, poster + synopsis + 3 buttons - Add Télécharger dropdown: season download + episode picker - Fix navbar CSS: restore .tabs flex layout, remove orphan rules - Fix HTMX spinner: remove inline display:none, use CSS indicator - Add AGENTS.md files across project for developer documentation
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# Routers (app/routers/)
|
||||
|
||||
## OVERVIEW
|
||||
11 FastAPI APIRouter modules, each owning a URL prefix. All registered in `main.py:118-144`.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Router | File | Prefix | Purpose |
|
||||
|--------|------|--------|---------|
|
||||
| root_router | `router_root.py` | `/`, `/web` | Index page, web UI |
|
||||
| auth_router | `router_auth.py` | `/api/auth` | Register, login, JWT tokens |
|
||||
| downloads_router | `router_downloads.py` | `/api/download` | Task CRUD, pause/resume, file serve |
|
||||
| anime_router | `router_anime.py` | `/api/anime`, `/api/series` | Search, metadata, episodes, season download |
|
||||
| favorites_router | `router_favorites.py` | `/api/favorites` | Favorites toggle, list |
|
||||
| recommendations_router | `router_recommendations.py` | `/api/recommendations`, `/api/releases` | Personalized + latest releases |
|
||||
| watchlist_router | `router_watchlist.py` | `/api/watchlist` | Watchlist CRUD, scheduler, auto-download |
|
||||
| sonarr_router | `router_sonarr.py` | `/api/sonarr`, `/api/webhook/sonarr` | Webhook receiver, mappings |
|
||||
| player_router | `router_player.py` | `/player`, `/watch` | Video player pages |
|
||||
| static_router | `router_static.py` | `/static`, `/video` | Static files, video streaming (Range) |
|
||||
| settings_router | `router_settings.py` | `/api/settings` | User app settings |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
**Adding endpoints**: Identify the correct router by URL prefix → add to that file → import in `app/routers/__init__.py` (if new router) → register in `main.py`.
|
||||
|
||||
**Shared dependencies** (via FastAPI `Depends`):
|
||||
- `download_manager: DownloadManager = Depends(lambda: download_manager)` — singleton from main.py
|
||||
- `current_user: User = Depends(get_current_user_from_token)` — JWT auth
|
||||
- `templates: Jinja2Templates = Depends(lambda: templates)` — Jinja2 renderer
|
||||
|
||||
**Router registration** in `main.py` uses `app.include_router(router)`. Tags set per-router for OpenAPI.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Do NOT create a new router for a single endpoint — add to existing matching router
|
||||
- Do NOT use `Depends()` with direct module imports that create circular references
|
||||
- Do NOT duplicate URL prefixes across routers
|
||||
+91
-44
@@ -9,7 +9,15 @@ import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -35,10 +43,12 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["anime"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# Add custom filters to Jinja2
|
||||
def hash_filter(s):
|
||||
return hashlib.md5(s.encode()).hexdigest()[:10]
|
||||
|
||||
|
||||
templates.env.filters["hash"] = hash_filter
|
||||
|
||||
|
||||
@@ -52,6 +62,7 @@ async def get_providers_health():
|
||||
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
||||
"""Trigger a manual health check of all providers in the background"""
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
|
||||
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
||||
return {"status": "Health check triggered in background"}
|
||||
|
||||
@@ -59,6 +70,7 @@ async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
||||
def get_download_manager() -> DownloadManager:
|
||||
"""Get the download manager instance from main app"""
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
@@ -73,7 +85,7 @@ async def search_anime_unified(
|
||||
include_metadata: bool = False,
|
||||
html: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session)
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Search across all anime providers.
|
||||
@@ -83,12 +95,14 @@ async def search_anime_unified(
|
||||
start_time = time.time()
|
||||
|
||||
# Get user settings for disabled providers
|
||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
||||
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)
|
||||
search_tasks = []
|
||||
task_metadata = []
|
||||
@@ -96,19 +110,26 @@ async def search_anime_unified(
|
||||
# Generic YAML providers
|
||||
active_generic = providers_manager.get_active_providers()
|
||||
for provider in active_generic:
|
||||
if provider.id not in disabled_providers:
|
||||
search_tasks.append(provider.search(q))
|
||||
task_metadata.append({"id": provider.id, "type": "generic"})
|
||||
provider_id = getattr(provider, "id", None)
|
||||
if provider_id and provider_id not in disabled_providers:
|
||||
if hasattr(provider, "search"):
|
||||
search_tasks.append(provider.search(q))
|
||||
task_metadata.append({"id": provider_id, "type": "generic"})
|
||||
elif hasattr(provider, "search_anime"):
|
||||
search_tasks.append(provider.search_anime(q, lang))
|
||||
task_metadata.append({"id": provider_id, "type": "legacy"})
|
||||
|
||||
# Legacy providers
|
||||
# Legacy providers (already included in providers_manager, but keep for fallback)
|
||||
legacy_downloaders = {
|
||||
"anime-ultime": AnimeUltimeDownloader(),
|
||||
"neko-sama": NekoSamaDownloader(),
|
||||
"vostfree": VostfreeDownloader(),
|
||||
}
|
||||
for pid, dl in legacy_downloaders.items():
|
||||
if pid not in disabled_providers:
|
||||
search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
|
||||
if pid not in disabled_providers and pid not in {
|
||||
getattr(p, "id", None) for p in active_generic
|
||||
}:
|
||||
search_tasks.append(dl.search_anime(q, lang))
|
||||
task_metadata.append({"id": pid, "type": "legacy"})
|
||||
|
||||
# 2. Run searches in parallel
|
||||
@@ -118,25 +139,25 @@ async def search_anime_unified(
|
||||
seen_urls = set()
|
||||
enricher = await get_metadata_enricher()
|
||||
enrichment_tasks = []
|
||||
enrichment_mapping = []
|
||||
enrichment_mapping = []
|
||||
|
||||
for i, raw_result in enumerate(all_raw_results):
|
||||
provider_info = task_metadata[i]
|
||||
pid = provider_info["id"]
|
||||
|
||||
|
||||
if isinstance(raw_result, Exception):
|
||||
logger.error(f"Search failed for {pid}: {raw_result}")
|
||||
continue
|
||||
if not raw_result:
|
||||
continue
|
||||
|
||||
|
||||
if pid not in results:
|
||||
results[pid] = []
|
||||
|
||||
|
||||
for item in raw_result:
|
||||
item_dict = item.model_dump() if hasattr(item, "model_dump") else item
|
||||
url = item_dict.get("url")
|
||||
|
||||
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
if q.lower() in (item_dict.get("title") or "").lower():
|
||||
@@ -144,10 +165,16 @@ async def search_anime_unified(
|
||||
else:
|
||||
item_dict["_relevance_boost"] = 0.5
|
||||
results[pid].append(item_dict)
|
||||
|
||||
|
||||
# Prepare enrichment task for top 5 results per provider
|
||||
if len(results[pid]) <= 5:
|
||||
enrichment_tasks.append(enricher.enrich_metadata(item_dict.get("metadata", {}), item_dict.get("title", ""), url))
|
||||
enrichment_tasks.append(
|
||||
enricher.enrich_metadata(
|
||||
item_dict.get("metadata", {}),
|
||||
item_dict.get("title", ""),
|
||||
url,
|
||||
)
|
||||
)
|
||||
enrichment_mapping.append((pid, len(results[pid]) - 1))
|
||||
else:
|
||||
if "metadata" not in item_dict:
|
||||
@@ -170,18 +197,16 @@ async def search_anime_unified(
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
total_found = sum(len(r) for r in results.values())
|
||||
print(f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}")
|
||||
|
||||
print(
|
||||
f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
|
||||
)
|
||||
|
||||
# 6. Return HTML for HTMX or JSON for API
|
||||
if html or request.headers.get("HX-Request"):
|
||||
print("[SEARCH] Returning HTML response")
|
||||
return templates.TemplateResponse(
|
||||
"components/anime_search_results.html",
|
||||
{
|
||||
"request": request,
|
||||
"results": results,
|
||||
"settings": settings_obj
|
||||
}
|
||||
{"request": request, "results": results, "settings": settings_obj},
|
||||
)
|
||||
|
||||
print("[SEARCH] Returning JSON response")
|
||||
@@ -200,7 +225,7 @@ async def search_series_unified(
|
||||
lang: str = "vf",
|
||||
html: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session)
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Search across all TV series providers (FS7, etc.)
|
||||
@@ -213,14 +238,16 @@ async def search_series_unified(
|
||||
start_time = time.time()
|
||||
|
||||
# Get user settings for disabled providers
|
||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
||||
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(),
|
||||
"zonetelechargement": ZoneTelechargementDownloader()
|
||||
"zonetelechargement": ZoneTelechargementDownloader(),
|
||||
}
|
||||
search_tasks = []
|
||||
provider_ids = []
|
||||
@@ -236,22 +263,24 @@ async def search_series_unified(
|
||||
for provider_id, result in zip(provider_ids, search_results):
|
||||
if isinstance(result, Exception):
|
||||
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
|
||||
logger.error(f"Series search error for {provider_id}: {result}")
|
||||
elif result:
|
||||
results[provider_id] = result
|
||||
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||
else:
|
||||
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
total_found = sum(len(r) for r in results.values())
|
||||
print(f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}")
|
||||
print(
|
||||
f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
|
||||
)
|
||||
|
||||
# Return HTML for HTMX or JSON for API
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/series_search_results.html",
|
||||
{
|
||||
"request": request,
|
||||
"results": results,
|
||||
"settings": settings_obj
|
||||
}
|
||||
{"request": request, "results": results, "settings": settings_obj},
|
||||
)
|
||||
|
||||
return {"query": q, "lang": lang, "results": results}
|
||||
@@ -266,7 +295,10 @@ async def get_anime_metadata(url: str):
|
||||
metadata = await downloader.get_anime_metadata(url)
|
||||
return {"url": url, "metadata": metadata}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Downloader for {url} does not support metadata extraction")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Downloader for {url} does not support metadata extraction",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -284,7 +316,7 @@ async def get_anime_episodes(
|
||||
"""
|
||||
downloader = get_downloader(url)
|
||||
episodes = await downloader.get_episodes(url, lang)
|
||||
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
# Extract title from first episode or URL for the display
|
||||
anime_title = "Épisodes"
|
||||
@@ -297,12 +329,12 @@ async def get_anime_episodes(
|
||||
return templates.TemplateResponse(
|
||||
"components/episode_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"episodes": episodes,
|
||||
"anime_url": url,
|
||||
"request": request,
|
||||
"episodes": episodes,
|
||||
"anime_url": url,
|
||||
"anime_title": anime_title,
|
||||
"lang": lang
|
||||
}
|
||||
"lang": lang,
|
||||
},
|
||||
)
|
||||
|
||||
return {"url": url, "lang": lang, "episodes": episodes}
|
||||
@@ -329,10 +361,17 @@ async def download_anime_episode(
|
||||
request = DownloadRequest(url=url)
|
||||
task = download_manager.create_task(request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
|
||||
# Add toast notification for HTMX
|
||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Téléchargement lancé : {task.filename}", "type": "success"}})
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": f"Téléchargement lancé : {task.filename}",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
|
||||
@@ -381,6 +420,7 @@ async def search_anime_mal_details(
|
||||
):
|
||||
"""Search for anime on MyAnimeList and get full details"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
try:
|
||||
search_results = await fetcher.search_anime(q, limit=limit)
|
||||
@@ -401,6 +441,7 @@ async def search_anime_mal_details(
|
||||
async def translate_text(request: Request):
|
||||
"""Translate text from English to French using Google Translate"""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
text = body.get("text", "")
|
||||
@@ -408,7 +449,13 @@ async def translate_text(request: Request):
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
url = "https://translate.googleapis.com/translate_a/single"
|
||||
params = {"client": "gtx", "sl": "en", "tl": "fr", "dt": "t", "q": text[:5000]}
|
||||
params = {
|
||||
"client": "gtx",
|
||||
"sl": "en",
|
||||
"tl": "fr",
|
||||
"dt": "t",
|
||||
"q": text[:5000],
|
||||
}
|
||||
response = await client.get(url, params=params)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
+51
-19
@@ -10,6 +10,7 @@ Endpoints:
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -39,15 +40,48 @@ async def get_current_user_from_token(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_dict = user_manager.get_user(username)
|
||||
if user_dict is None:
|
||||
user = user_manager.get_user(username)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return User(**user_dict)
|
||||
return User(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
||||
HTTPBearer(auto_error=False)
|
||||
),
|
||||
) -> Optional[User]:
|
||||
if credentials is None:
|
||||
return None
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
if username is None:
|
||||
return None
|
||||
user = user_manager.get_user(username)
|
||||
if user is None:
|
||||
return None
|
||||
return User(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
@@ -69,15 +103,13 @@ async def register(user_data: UserCreate):
|
||||
)
|
||||
|
||||
user_response = User(
|
||||
id=user["id"],
|
||||
username=user["username"],
|
||||
email=user.get("email"),
|
||||
full_name=user.get("full_name"),
|
||||
is_active=user["is_active"],
|
||||
created_at=datetime.fromisoformat(user["created_at"]),
|
||||
last_login=datetime.fromisoformat(user["last_login"])
|
||||
if user.get("last_login")
|
||||
else None,
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -111,23 +143,23 @@ async def login(form_data: UserLogin):
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.get("is_active", True):
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user["username"]}, expires_delta=timedelta(days=7)
|
||||
data={"sub": user.username}, expires_delta=timedelta(days=7)
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"email": user.get("email"),
|
||||
"full_name": user.get("full_name"),
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -185,7 +217,7 @@ async def refresh_token(refresh_request: dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||
)
|
||||
if not user.get("is_active", True):
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Application settings routes for Ohm Stream Downloader API"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
@@ -8,7 +9,7 @@ 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.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
from app.providers import get_anime_providers, get_series_providers
|
||||
from app.providers_manager import providers_manager
|
||||
|
||||
@@ -19,23 +20,25 @@ 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)
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get current application settings for the user"""
|
||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
||||
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
|
||||
disabled_providers=settings_obj.disabled_providers,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,61 +47,69 @@ async def update_settings(
|
||||
update_data: AppSettingsUpdate,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session)
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Update application settings for the user"""
|
||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
||||
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"}})
|
||||
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}
|
||||
)
|
||||
|
||||
return settings_obj
|
||||
|
||||
|
||||
@router.get("/providers/availability")
|
||||
async def get_providers_availability(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session)
|
||||
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)
|
||||
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")
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -107,16 +118,18 @@ async def toggle_provider(
|
||||
provider_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session)
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Toggle a provider's enabled/disabled status"""
|
||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
||||
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)
|
||||
@@ -124,33 +137,39 @@ async def toggle_provider(
|
||||
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"}})
|
||||
|
||||
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)
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Return the settings UI fragment for HTMX"""
|
||||
# Reuse existing endpoints logic
|
||||
if current_user is None:
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
{"request": request, "settings": settings, "providers": providers},
|
||||
)
|
||||
|
||||
@@ -6,7 +6,15 @@ import re
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Request, Query
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
Request,
|
||||
Query,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
@@ -20,7 +28,7 @@ from app.models.watchlist import (
|
||||
WatchlistSettings,
|
||||
WatchlistStatus,
|
||||
)
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -28,6 +36,7 @@ templates = Jinja2Templates(directory="templates")
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
@@ -41,38 +50,56 @@ async def add_to_watchlist(
|
||||
from main import watchlist_manager
|
||||
|
||||
try:
|
||||
existing = watchlist_manager.get_by_anime_url(item_data.anime_url, current_user.id)
|
||||
existing = watchlist_manager.get_by_anime_url(
|
||||
item_data.anime_url, current_user.id
|
||||
)
|
||||
item = watchlist_manager.add(current_user.id, item_data)
|
||||
|
||||
msg = f"'{item.anime_title}' ajouté à la watchlist" if not existing else f"'{item.anime_title}' est déjà dans la watchlist"
|
||||
|
||||
msg = (
|
||||
f"'{item.anime_title}' ajouté à la watchlist"
|
||||
if not existing
|
||||
else f"'{item.anime_title}' est déjà dans la watchlist"
|
||||
)
|
||||
toast_type = "success" if not existing else "info"
|
||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": msg, "type": toast_type}})
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": msg, "type": toast_type}}
|
||||
)
|
||||
|
||||
return item
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[WatchlistItem])
|
||||
@router.get("")
|
||||
async def get_watchlist(
|
||||
request: Request,
|
||||
status: Optional[WatchlistStatus] = None,
|
||||
html: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get user's watchlist (supports HTML for HTMX)"""
|
||||
from main import watchlist_manager
|
||||
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/watchlist_items_list.html",
|
||||
{"request": request, "items": items}
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/watchlist_items_list.html", {"request": request, "items": items}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@@ -82,6 +109,7 @@ async def get_watchlist_settings(
|
||||
):
|
||||
"""Get global watchlist settings"""
|
||||
from main import watchlist_manager
|
||||
|
||||
return watchlist_manager.get_settings()
|
||||
|
||||
|
||||
@@ -97,9 +125,18 @@ async def update_watchlist_settings(
|
||||
try:
|
||||
updated_settings = watchlist_manager.update_settings(settings)
|
||||
if auto_download_scheduler.is_running():
|
||||
auto_download_scheduler.update_interval(updated_settings.check_interval_hours)
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres de la watchlist mis à jour", "type": "success"}})
|
||||
auto_download_scheduler.update_interval(
|
||||
updated_settings.check_interval_hours
|
||||
)
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": "Paramètres de la watchlist mis à jour",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
return updated_settings
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -112,6 +149,7 @@ async def get_watchlist_item(
|
||||
):
|
||||
"""Get a specific watchlist item"""
|
||||
from main import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
@@ -133,8 +171,15 @@ async def update_watchlist_item(
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
updated_item = watchlist_manager.update(item_id, update_data)
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{updated_item.anime_title}' mis à jour", "type": "success"}})
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": f"'{updated_item.anime_title}' mis à jour",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
return updated_item
|
||||
|
||||
|
||||
@@ -153,10 +198,17 @@ async def delete_from_watchlist(
|
||||
|
||||
title = item.anime_title
|
||||
if watchlist_manager.delete(item_id):
|
||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{title}' supprimé de la watchlist", "type": "info"}})
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": f"'{title}' supprimé de la watchlist",
|
||||
"type": "info",
|
||||
}
|
||||
}
|
||||
)
|
||||
# HTMX will handle removing the element if target is specified in the frontend
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||
|
||||
|
||||
@@ -168,10 +220,17 @@ async def check_watchlist_now(
|
||||
):
|
||||
"""Trigger an immediate check for new episodes"""
|
||||
from main import auto_download_scheduler
|
||||
|
||||
|
||||
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
|
||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Vérification de la watchlist lancée en arrière-plan", "type": "info"}})
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": "Vérification de la watchlist lancée en arrière-plan",
|
||||
"type": "info",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Check triggered"}
|
||||
|
||||
|
||||
@@ -181,4 +240,5 @@ async def get_watchlist_stats(
|
||||
):
|
||||
"""Get watchlist statistics for the user"""
|
||||
from main import watchlist_manager
|
||||
|
||||
return watchlist_manager.get_stats(current_user.id)
|
||||
|
||||
Reference in New Issue
Block a user