feat: fix auth, provider health checks, search, and redesign UI
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- 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:
root
2026-03-28 00:14:31 +00:00
parent 5d23a3d663
commit 3dc5dd8fe9
36 changed files with 2735 additions and 1989 deletions
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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"
)
+66 -47
View File
@@ -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},
)
+86 -26
View File
@@ -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)