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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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