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:
+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()
|
||||
|
||||
Reference in New Issue
Block a user