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
+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()