Phase 3: HTMX & Alpine.js integration, router refactoring, and UI modernization
- Modernized the frontend with HTMX for server-driven UI and Alpine.js for client state. - Refactored anime, player, and recommendation logic into modular routers. - Updated README.md to reflect the latest project state and technologies (v2.4). - Added Plyr.io for an improved streaming experience. - Improved project structure with componentized templates. - Added Playwright and Vitest configuration for frontend testing.
This commit is contained in:
@@ -176,16 +176,20 @@ async def search_anime_unified(
|
||||
|
||||
@router.get("/series/search")
|
||||
async def search_series_unified(
|
||||
request: Request,
|
||||
q: str,
|
||||
lang: str = "vf",
|
||||
html: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Search across all TV series providers (FS7, etc.)
|
||||
Returns HTML for HTMX requests or if html=True parameter is set.
|
||||
"""
|
||||
import asyncio
|
||||
from app.downloaders.series_sites import FS7Downloader
|
||||
|
||||
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}")
|
||||
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}. html={html}")
|
||||
start_time = time.time()
|
||||
results = {}
|
||||
series_downloaders = {"fs7": FS7Downloader()}
|
||||
search_tasks = []
|
||||
@@ -205,6 +209,17 @@ async def search_series_unified(
|
||||
elif result:
|
||||
results[provider_id] = result
|
||||
|
||||
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')}")
|
||||
|
||||
# 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}
|
||||
)
|
||||
|
||||
return {"query": q, "lang": lang, "results": results}
|
||||
|
||||
|
||||
@@ -224,12 +239,38 @@ async def get_anime_metadata(url: str):
|
||||
|
||||
@router.get("/anime/episodes")
|
||||
async def get_anime_episodes(
|
||||
request: Request,
|
||||
url: str,
|
||||
lang: str = "vostfr",
|
||||
html: bool = Query(False),
|
||||
):
|
||||
"""Get list of episodes for an anime"""
|
||||
"""
|
||||
Get list of episodes for an anime.
|
||||
Returns HTML for HTMX requests or JSON for API.
|
||||
"""
|
||||
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"
|
||||
if episodes and len(episodes) > 0:
|
||||
# Try to get a cleaner title from the first episode if available
|
||||
first_ep = episodes[0]
|
||||
if "|" in first_ep.get("url", ""):
|
||||
anime_title = first_ep.get("url").split("|")[-1].split(" - ")[0]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/episode_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"episodes": episodes,
|
||||
"anime_url": url,
|
||||
"anime_title": anime_title,
|
||||
"lang": lang
|
||||
}
|
||||
)
|
||||
|
||||
return {"url": url, "lang": lang, "episodes": episodes}
|
||||
|
||||
|
||||
@@ -243,6 +284,7 @@ async def get_anime_providers_list():
|
||||
async def download_anime_episode(
|
||||
url: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
episode: str | None = None,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
@@ -253,6 +295,10 @@ 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"}})
|
||||
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
|
||||
|
||||
@@ -20,10 +20,53 @@ def get_download_manager():
|
||||
return download_manager
|
||||
|
||||
|
||||
def get_templates():
|
||||
from main import templates
|
||||
from app.downloaders import get_downloader
|
||||
|
||||
return templates
|
||||
@router.get("/api/player/embed")
|
||||
async def get_player_embed(request: Request, url: str):
|
||||
"""
|
||||
Get an embedded video player for a given episode URL.
|
||||
This route extracts the direct video link and returns an HTML fragment.
|
||||
"""
|
||||
from main import templates
|
||||
|
||||
try:
|
||||
# 1. Get the downloader for the anime site (e.g. Anime-Sama)
|
||||
downloader = get_downloader(url)
|
||||
if not downloader:
|
||||
raise HTTPException(status_code=400, detail="No downloader found for this URL")
|
||||
|
||||
# 2. Get the video player URL (embed URL like VidMoly, DoodStream, etc.)
|
||||
video_url, _ = await downloader.get_download_link(url)
|
||||
|
||||
# 3. Get the direct video file link from the player
|
||||
player_handler = get_downloader(video_url)
|
||||
if not player_handler:
|
||||
# If no direct extractor, we might have to use an iframe
|
||||
return templates.TemplateResponse(
|
||||
"components/player_embed.html",
|
||||
{
|
||||
"request": request,
|
||||
"video_url": video_url,
|
||||
"is_iframe": True
|
||||
}
|
||||
)
|
||||
|
||||
direct_url, filename = await player_handler.get_download_link(video_url)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/player_embed.html",
|
||||
{
|
||||
"request": request,
|
||||
"video_url": direct_url,
|
||||
"filename": filename,
|
||||
"is_iframe": False
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Embed error: {e}", exc_info=True)
|
||||
return f"<div class='error-msg'>Erreur lors de l'extraction de la vidéo : {str(e)}</div>"
|
||||
|
||||
|
||||
@router.get("/video/{task_id}")
|
||||
|
||||
@@ -2,49 +2,78 @@
|
||||
Recommendations and releases routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
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
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def get_recommendations(limit: int = 15):
|
||||
async def get_recommendations(
|
||||
request: Request,
|
||||
limit: int = 15,
|
||||
html: bool = Query(False),
|
||||
):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/recommendations_list.html",
|
||||
{"request": request, "recommendations": recommendations}
|
||||
)
|
||||
|
||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/releases/latest")
|
||||
async def get_latest_releases(limit: int = 20):
|
||||
async def get_latest_releases(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
):
|
||||
"""Get latest anime releases"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/releases_list.html",
|
||||
{"request": request, "releases": releases}
|
||||
)
|
||||
|
||||
return {
|
||||
"releases": releases,
|
||||
"count": len(releases),
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -68,8 +97,6 @@ async def get_seasonal_anime(
|
||||
"season": season or "current",
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
@@ -87,8 +114,6 @@ async def get_scheduled_anime(day: Optional[str] = None):
|
||||
|
||||
return {"anime": anime, "count": len(anime), "day": day or "today"}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
@@ -109,8 +134,6 @@ async def get_top_anime(
|
||||
|
||||
return {"anime": anime, "count": len(anime)}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
@@ -126,8 +149,6 @@ async def get_download_statistics():
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
Reference in New Issue
Block a user