Phase 3: HTMX & Alpine.js integration, router refactoring, and UI modernization
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

- 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:
root
2026-03-26 10:34:26 +00:00
parent a684237725
commit 9f85908ff3
31 changed files with 3413 additions and 2201 deletions
+48 -2
View File
@@ -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}
+46 -3
View File
@@ -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}")
+36 -15
View File
@@ -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()