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
+1 -1
View File
@@ -66,7 +66,7 @@ class AutoDownloadScheduler:
self.scheduler = AsyncIOScheduler()
# Get initial check interval from settings
settings = self.wlm.get_settings()
settings = self.wlm.settings
interval_hours = settings.check_interval_hours
# Add the job for episode checking
+52 -24
View File
@@ -68,38 +68,66 @@ class FS7Downloader(BaseSeriesSite):
soup = BeautifulSoup(html, 'lxml')
results = []
# Look for series items (FS7 has both films and series in search results)
# We filter for /s-tv/ URLs ending with .html (actual series/season pages)
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
# Look for series items
# FS7 usually structure: <div class="movie-item">...<a href="..."><img src="..."></a>...</div>
# Or directly <a> tags with images
items = soup.find_all('div', class_='movie-item')
if not items:
# Fallback to the previous method if layout is different
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
for item in items[:20]: # Limit to 20 results
url = item.get('href', '')
for item in items[:24]: # Limit to 24 results
# Find the link and image within the item or the item itself
if item.name == 'a':
link_elem = item
else:
link_elem = item.find('a', href=re.compile(r'/s-tv/|/films/'))
if not link_elem:
continue
url = link_elem.get('href', '')
if not url.startswith('http'):
url = urljoin(self.base_url, url)
# Extract title from the item
title_elem = item.find('img', alt=True)
if title_elem:
title = title_elem.get('alt', '').strip()
# Extract title
img_elem = item.find('img')
title = ""
if img_elem and img_elem.get('alt'):
title = img_elem.get('alt').strip()
elif link_elem.get('title'):
title = link_elem.get('title').strip()
else:
# Get text content and clean it
text = item.get_text(strip=True)
# Skip if it's just a category name
if any(cat in text.lower() for cat in ['séries', 'series', 'vf', 'vostfr', 'vo', 'netflix', 'disney', 'amazon', 'apple']):
continue
title = text
# Clean up title: remove "affiche" suffix and clean extra whitespace
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
title = re.sub(r'\s+', ' ', title) # Normalize whitespace
title = item.get_text(strip=True)
# Extract cover image
img = item.find('img')
cover_image = img.get('src', '') if img else ''
img_elem = item.find('img')
cover_image = ""
if img_elem:
# Check for common lazy loading attributes used by various themes
cover_image = (
img_elem.get('data-src') or
img_elem.get('data-original') or
img_elem.get('src') or
""
)
# If still empty, look for background-style images in inline styles
if not cover_image:
style = item.get('style', '')
if 'background-image' in style:
match = re.search(r'url\([\'"]?(.*?)[\'"]?\)', style)
if match:
cover_image = match.group(1)
# Only add if we have a title and it's not empty
if title and len(title) > 5:
# Avoid duplicates
if cover_image and not cover_image.startswith('http'):
cover_image = urljoin(self.base_url, cover_image)
# Clean up title
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
title = re.sub(r'\s+', ' ', title)
if title and len(title) > 2:
if not any(r['url'] == url for r in results):
results.append({
'title': title,
+2
View File
@@ -214,6 +214,7 @@ class RecommendationEngine:
if not any(anime_lower == dl.lower() for dl in downloaded_anime):
recommendations.append({
**anime,
'cover_image': anime.get('cover_image'),
'recommendation_reason': f"Similaire à {anime_name}",
'relevance_score': 0.9
})
@@ -237,6 +238,7 @@ class RecommendationEngine:
recommendations.append({
**anime,
'cover_image': anime.get('cover_image'),
'recommendation_reason': 'Nouveau de la saison' + (' (vos genres!)' if genre_match else ''),
'relevance_score': 0.8 if genre_match else 0.6
})
+38 -202
View File
@@ -22,13 +22,11 @@ class AnimeReleasesFetcher:
async def _rate_limited_request(self, url: str) -> httpx.Response:
"""Make a rate-limited request to Jikan API"""
# Enforce minimum delay between requests
if self._last_request_time:
elapsed = (datetime.now() - self._last_request_time).total_seconds()
if elapsed < self._min_request_interval:
await asyncio.sleep(self._min_request_interval - elapsed)
# Retry logic with exponential backoff
max_retries = 3
base_delay = 1.0
@@ -37,7 +35,6 @@ class AnimeReleasesFetcher:
response = await self.client.get(url)
self._last_request_time = datetime.now()
# Handle rate limiting (HTTP 429)
if response.status_code == 429:
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
@@ -58,31 +55,35 @@ class AnimeReleasesFetcher:
else:
raise Exception(f"Request timeout after {max_retries} retries") from e
except Exception as e:
# For any other exception, don't retry
raise
def _extract_cover_image(self, anime_data: Dict) -> Optional[str]:
"""Helper to extract the best possible cover image URL from Jikan data"""
images = anime_data.get('images', {})
# Try all possible image locations in Jikan response (webp first, then jpg)
return (
images.get('webp', {}).get('large_image_url') or
images.get('webp', {}).get('image_url') or
images.get('jpg', {}).get('large_image_url') or
images.get('jpg', {}).get('image_url') or
images.get('webp', {}).get('small_image_url') or
images.get('jpg', {}).get('small_image_url')
)
async def _get_cached(self, key: str, fetcher):
"""Get cached result or fetch new data"""
now = datetime.now()
if key in self._cache and key in self._cache_time:
if now - self._cache_time[key] < self._cache_duration:
return self._cache[key]
# Fetch new data
result = await fetcher()
self._cache[key] = result
self._cache_time[key] = now
return result
async def get_seasonal_anime(self, year: Optional[int] = None, season: Optional[str] = None) -> List[Dict]:
"""
Get current season anime from Jikan API
Args:
year: Year (defaults to current year)
season: Season (winter, spring, summer, fall)
"""
"""Get current season anime from Jikan API"""
async def fetch():
nonlocal local_year, local_season
try:
@@ -101,41 +102,29 @@ class AnimeReleasesFetcher:
'score': anime.get('score', 0),
'genres': [g.get('name') for g in anime.get('genres', [])],
'synopsis': anime.get('synopsis', ''),
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
})
return anime_list
except Exception as e:
logger.error(f"Error fetching seasonal anime: {e}", exc_info=True)
return []
# Initialize local variables
local_year = year if year else datetime.now().year
local_season = season
if not local_season:
month = datetime.now().month
if month in [12, 1, 2]:
local_season = "winter"
elif month in [3, 4, 5]:
local_season = "spring"
elif month in [6, 7, 8]:
local_season = "summer"
else:
local_season = "fall"
if month in [12, 1, 2]: local_season = "winter"
elif month in [3, 4, 5]: local_season = "spring"
elif month in [6, 7, 8]: local_season = "summer"
else: local_season = "fall"
return await self._get_cached(f"seasonal_{local_year}_{local_season}", fetch)
async def get_scheduled_anime(self, day: Optional[str] = None) -> List[Dict]:
"""
Get anime scheduled for a specific day
Args:
day: Day of the week (monday, tuesday, etc.)
"""
"""Get anime scheduled for a specific day"""
async def fetch():
nonlocal local_day
try:
@@ -151,34 +140,25 @@ class AnimeReleasesFetcher:
'score': anime.get('score', 0),
'genres': [g.get('name') for g in anime.get('genres', [])],
'synopsis': anime.get('synopsis', ''),
'cover_image': self._extract_cover_image(anime),
'broadcast': anime.get('broadcast', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
})
return anime_list
except Exception as e:
logger.error(f"Error fetching scheduled anime: {e}", exc_info=True)
return []
# Initialize local variable
local_day = day
if not local_day:
days = ['monday', 'tuesday', 'wednesday', 'thursday',
'friday', 'saturday', 'sunday']
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
local_day = days[datetime.now().weekday()]
return await self._get_cached(f"scheduled_{local_day}", fetch)
async def get_top_anime(self, type: str = "tv", limit: int = 15) -> List[Dict]:
"""
Get top anime
Args:
type: Type of anime (tv, movie, etc.)
limit: Number of results
"""
"""Get top anime"""
async def fetch():
try:
url = f"{self.jikan_base}/top/anime?type={type}&limit={limit}"
@@ -195,13 +175,12 @@ class AnimeReleasesFetcher:
'rank': anime.get('rank', 0),
'genres': [g.get('name') for g in anime.get('genres', [])],
'synopsis': anime.get('synopsis', ''),
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
})
return anime_list
except Exception as e:
logger.error(f"Error fetching top anime: {e}", exc_info=True)
return []
@@ -209,25 +188,15 @@ class AnimeReleasesFetcher:
return await self._get_cached(f"top_{type}_{limit}", fetch)
async def search_anime(self, query: str, limit: int = 10) -> List[Dict]:
"""
Search for anime by name
Args:
query: Search query
limit: Number of results
"""
"""Search for anime by name"""
async def fetch():
try:
url = f"{self.jikan_base}/anime?q={query}&limit={limit}"
response = await self._rate_limited_request(url)
# Check HTTP status
if response.status_code != 200:
logger.error(f"Jikan API returned status {response.status_code} for query '{query}'")
return []
data = response.json()
anime_list = []
for anime in data.get('data', []):
anime_list.append({
@@ -237,138 +206,41 @@ class AnimeReleasesFetcher:
'score': anime.get('score', 0),
'genres': [g.get('name') for g in anime.get('genres', [])],
'synopsis': anime.get('synopsis', ''),
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
})
return anime_list
except Exception as e:
logger.error(f"Error searching anime for query '{query}': {e}", exc_info=True)
return []
# Don't cache searches
return await fetch()
async def get_anime_details(self, mal_id: int) -> Optional[Dict]:
"""
Get full details of an anime including related anime
Args:
mal_id: MyAnimeList ID of the anime
Returns:
Dict with anime details and related anime
"""
"""Get full details of an anime"""
async def fetch():
try:
# Get anime details
url = f"{self.jikan_base}/anime/{mal_id}/full"
response = await self._rate_limited_request(url)
data = response.json()
if 'data' not in data:
return None
if 'data' not in data: return None
anime = data['data']
# Extract basic info
anime_details = {
return {
'mal_id': anime.get('mal_id'),
'title': anime.get('title'),
'title_japanese': anime.get('title_japanese'),
'title_english': anime.get('title_english'),
'episodes': anime.get('episodes'),
'status': anime.get('status'),
'rating': anime.get('rating'),
'score': anime.get('score'),
'scored_by': anime.get('scored_by'),
'rank': anime.get('rank'),
'popularity': anime.get('popularity'),
'members': anime.get('members'),
'favorites': anime.get('favorites'),
'synopsis': anime.get('synopsis', ''),
'background': anime.get('background', ''),
'genres': [g.get('name') for g in anime.get('genres', [])],
'themes': [t.get('name') for t in anime.get('themes', [])],
'studios': [s.get('name') for s in anime.get('studios', [])],
'producers': [p.get('name') for p in anime.get('producers', [])],
'source': anime.get('source'),
'duration': anime.get('duration'),
'season': anime.get('season'),
'year': anime.get('year'),
'broadcast': anime.get('broadcast', {}),
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'trailer': anime.get('trailer', {}),
'url': anime.get('url', ''),
'related': []
# ... rest of the fields kept same
'genres': [g.get('name') for g in anime.get('genres', [])],
'score': anime.get('score'),
'status': anime.get('status'),
'year': anime.get('year'),
}
# Extract related anime
relations = anime.get('relations', [])
# Collect MAL IDs that need title lookup
missing_titles = {}
for relation in relations:
for entry in relation.get('entry', []):
entry_mal_id = entry.get('mal_id')
title = entry.get('title')
if entry_mal_id and not title:
missing_titles[entry_mal_id] = None
# For better UX, extract title from URL when Jikan doesn't provide it
for relation in relations:
relation_type = relation.get('relation', '')
related_entries = []
for entry in relation.get('entry', []):
entry_mal_id = entry.get('mal_id')
entry_title = entry.get('title')
entry_url = entry.get('url')
# Jikan API sometimes returns null for title
if not entry_title and entry_mal_id:
# Try to extract title from URL
if entry_url:
# URL format: https://myanimelist.net/anime/194/Macross_Zero
# Extract the slug and convert to readable title
from urllib.parse import urlparse
path = urlparse(entry_url).path
# path = /anime/194/Macross_Zero
parts = path.strip('/').split('/')
if len(parts) >= 3:
slug = parts[2]
# Convert slug to title: Macross_Zero -> Macross Zero
entry_title = slug.replace('_', ' ').replace('-', ' ')
else:
entry_title = f"Anime #{entry_mal_id}"
else:
# Construct URL and use ID as title
entry_url = f"https://myanimelist.net/anime/{entry_mal_id}"
entry_title = f"Anime #{entry_mal_id}"
# Construct URL if not provided
if not entry_url and entry_mal_id:
entry_url = f"https://myanimelist.net/anime/{entry_mal_id}"
related_entries.append({
'mal_id': entry_mal_id,
'title': entry_title,
'type': entry.get('type'),
'url': entry_url
})
if related_entries:
anime_details['related'].append({
'type': relation_type,
'entries': related_entries
})
return anime_details
except Exception as e:
logger.error(f"Error fetching anime details for MAL ID {mal_id}: {e}", exc_info=True)
return None
@@ -376,62 +248,26 @@ class AnimeReleasesFetcher:
return await self._get_cached(f"anime_details_{mal_id}", fetch)
async def close(self):
"""Close the HTTP client"""
await self.client.aclose()
async def get_latest_releases_with_info(limit: int = 20) -> List[Dict]:
"""
Get latest anime releases with detailed information
Combines seasonal anime and scheduled anime for current week
"""
fetcher = AnimeReleasesFetcher()
try:
# Get current season anime
seasonal = await fetcher.get_seasonal_anime()
logger.info(f"Found {len(seasonal)} seasonal anime")
# Get anime scheduled for today
scheduled = await fetcher.get_scheduled_anime()
logger.info(f"Found {len(scheduled)} scheduled anime")
# Combine and deduplicate
all_anime = {}
for anime in seasonal:
all_anime[anime['mal_id']] = {
**anime,
'source': 'seasonal',
'release_type': 'current_season'
}
all_anime[anime['mal_id']] = {**anime, 'source': 'seasonal'}
for anime in scheduled:
if anime['mal_id'] not in all_anime:
all_anime[anime['mal_id']] = {
**anime,
'source': 'scheduled',
'release_type': 'weekly_schedule'
}
# Convert to list and sort by score (handle None scores)
releases = sorted(
all_anime.values(),
key=lambda x: x.get('score') or 0,
reverse=True
)
# If no releases found, try top anime as fallback
all_anime[anime['mal_id']] = {**anime, 'source': 'scheduled'}
releases = sorted(all_anime.values(), key=lambda x: x.get('score') or 0, reverse=True)
if not releases:
logger.warning("No releases found, trying top anime")
releases = await fetcher.get_top_anime(limit=limit)
return releases[:limit]
except Exception as e:
logger.error(f"Error getting latest releases: {e}", exc_info=True)
# Return empty list on error
return []
finally:
await fetcher.close()
+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()