feat: fix auth, provider health checks, search, and redesign UI
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

- 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:
root
2026-03-28 00:14:31 +00:00
parent 5d23a3d663
commit 3dc5dd8fe9
36 changed files with 2735 additions and 1989 deletions
+90 -82
View File
@@ -1,4 +1,5 @@
"""French-Manga.net anime streaming site downloader"""
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
@@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite):
"french-manga.net",
"w16.french-manga.net",
"w15.french-manga.net",
"www.french-manga.net"
"www.french-manga.net",
]
def __init__(self):
super().__init__()
self.id = "french-manga"
self.base_url = "https://w16.french-manga.net"
def can_handle(self, url: str) -> bool:
@@ -29,9 +31,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def search_anime(
self,
query: str,
lang: str = "vostfr"
self, query: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Search for anime on French-Manga.
@@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite):
# French-Manga uses a search endpoint
search_url = f"{self.base_url}/index.php?do=search"
params = {
'do': 'search',
'subaction': 'search',
'story': query,
'x': '0',
'y': '0'
"do": "search",
"subaction": "search",
"story": query,
"x": "0",
"y": "0",
}
response = await self.client.post(search_url, data=params)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
results = []
# Look for search results in article or story classes
for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()):
title_elem = item.find(['h2', 'h3', 'h4'])
link_elem = item.find('a', href=True)
img_elem = item.find('img')
for item in soup.find_all(
"article", class_=lambda x: x and "story" in x.lower()
):
title_elem = item.find(["h2", "h3", "h4"])
link_elem = item.find("a", href=True)
img_elem = item.find("img")
if title_elem and link_elem:
title = title_elem.get_text(strip=True)
url = link_elem['href']
url = link_elem["href"]
# Ensure absolute URL
if url.startswith('/'):
if url.startswith("/"):
url = self.base_url + url
cover_image = ""
if img_elem and img_elem.get('src'):
cover_image = img_elem['src']
if cover_image.startswith('/'):
if img_elem and img_elem.get("src"):
cover_image = img_elem["src"]
if cover_image.startswith("/"):
cover_image = self.base_url + cover_image
results.append({
'title': title,
'url': url,
'cover_image': cover_image,
'lang': lang
})
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"lang": lang,
}
)
logger.info(f"Found {len(results)} anime results for query: {query}")
return results
@@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
return []
async def get_episodes(
self,
anime_url: str,
lang: str = "vostfr"
self, anime_url: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Get episode list for an anime.
@@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
episodes = []
# Look for episode links (typically in a list or table)
# French-Manga usually has episode links in <a> tags with episode numbers
for link in soup.find_all('a', href=True):
href = link['href']
for link in soup.find_all("a", href=True):
href = link["href"]
text = link.get_text(strip=True)
# Pattern: Episode links usually contain "episode" or numbers
if re.search(r'episode?\s*\d+', text.lower()):
episode_num = re.search(r'(\d+)', text)
if re.search(r"episode?\s*\d+", text.lower()):
episode_num = re.search(r"(\d+)", text)
if episode_num:
episode_number = int(episode_num.group(1))
# Ensure absolute URL
if href.startswith('/'):
if href.startswith("/"):
href = self.base_url + href
episodes.append({
'episode_number': episode_number,
'url': href,
'title': text,
'host': 'french-manga'
})
episodes.append(
{
"episode_number": episode_number,
"url": href,
"title": text,
"host": "french-manga",
}
)
# Sort by episode number
episodes.sort(key=lambda x: x['episode_number'])
episodes.sort(key=lambda x: x["episode_number"])
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes
@@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
# Extract title
title = ""
title_elem = soup.find('h1') or soup.find('h2', class_='title')
title_elem = soup.find("h1") or soup.find("h2", class_="title")
if title_elem:
title = title_elem.get_text(strip=True)
# Extract synopsis
synopsis = ""
synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower())
synopsis_elem = soup.find(
"div", class_=lambda x: x and "story" in x.lower()
)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
# Extract cover image
poster_image = ""
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower())
if img_elem and img_elem.get('src'):
poster_image = img_elem['src']
if poster_image.startswith('/'):
img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
if img_elem and img_elem.get("src"):
poster_image = img_elem["src"]
if poster_image.startswith("/"):
poster_image = self.base_url + poster_image
# Extract genres
genres = []
genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/'))
genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/"))
for link in genre_links[:10]: # Limit to 10 genres
genre = link.get_text(strip=True)
if genre:
@@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite):
# Extract rating (if available)
rating = ""
rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower())
rating_elem = soup.find(
["span", "div"], class_=lambda x: x and "rating" in x.lower()
)
if rating_elem:
rating = rating_elem.get_text(strip=True)
return {
'title': title,
'synopsis': synopsis,
'genres': genres,
'rating': rating,
'release_year': '',
'studio': '',
'poster_image': poster_image,
'total_episodes': len(await self.get_episodes(anime_url)),
'status': '',
'languages': ['vf', 'vostfr']
"title": title,
"synopsis": synopsis,
"genres": genres,
"rating": rating,
"release_year": "",
"studio": "",
"poster_image": poster_image,
"total_episodes": len(await self.get_episodes(anime_url)),
"status": "",
"languages": ["vf", "vostfr"],
}
except Exception as e:
logger.error(f"Error getting anime metadata: {e}")
return {
'title': '',
'synopsis': '',
'genres': [],
'rating': '',
'release_year': '',
'studio': '',
'poster_image': '',
'total_episodes': 0,
'status': '',
'languages': ['vf', 'vostfr']
"title": "",
"synopsis": "",
"genres": [],
"rating": "",
"release_year": "",
"studio": "",
"poster_image": "",
"total_episodes": 0,
"status": "",
"languages": ["vf", "vostfr"],
}
async def get_download_link(self, url: str) -> tuple[str, str]:
@@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
# Look for iframe or video player
iframe = soup.find('iframe', src=True)
iframe = soup.find("iframe", src=True)
if iframe:
video_url = iframe['src']
video_url = iframe["src"]
else:
# Look for video tag directly
video = soup.find('video', src=True)
video = soup.find("video", src=True)
if video:
video_url = video['src']
video_url = video["src"]
else:
# Try to find in script tags
scripts = soup.find_all('script')
scripts = soup.find_all("script")
for script in scripts:
if script.string:
# Look for iframe or video URLs in JavaScript
@@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
if match:
video_url = match.group(1)
break
if 'video_url' in locals():
if "video_url" in locals():
break
if 'video_url' not in locals():
if "video_url" not in locals():
raise ValueError("Could not find video player URL")
# Ensure absolute URL
if video_url.startswith('//'):
video_url = 'https:' + video_url
elif video_url.startswith('/'):
if video_url.startswith("//"):
video_url = "https:" + video_url
elif video_url.startswith("/"):
video_url = self.base_url + video_url
# Extract episode title
title_elem = soup.find('h1') or soup.find('h2')
title_elem = soup.find("h1") or soup.find("h2")
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
episode_title = sanitize_filename(episode_title)