Files
ohm_streaming/app/downloaders/anime_sites/frenchmanga.py
T
root 3dc5dd8fe9
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
feat: fix auth, provider health checks, search, and redesign UI
- 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
2026-03-28 00:14:31 +00:00

308 lines
10 KiB
Python

"""French-Manga.net anime streaming site downloader"""
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
from typing import List, Dict, Any
from app.utils import sanitize_filename
import logging
logger = logging.getLogger(__name__)
class FrenchMangaDownloader(BaseAnimeSite):
"""Downloader for french-manga.net anime streaming site"""
# Known domains for French-Manga
BASE_DOMAINS = [
"french-manga.net",
"w16.french-manga.net",
"w15.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:
"""Check if this downloader can handle the given URL"""
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def search_anime(
self, query: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Search for anime on French-Manga.
Args:
query: Search query (anime title)
lang: Language preference (vostfr, vf)
Returns:
List of anime with title, url, cover_image
"""
try:
# 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",
}
response = await self.client.post(search_url, data=params)
response.raise_for_status()
html = response.text
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")
if title_elem and link_elem:
title = title_elem.get_text(strip=True)
url = link_elem["href"]
# Ensure absolute URL
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("/"):
cover_image = self.base_url + cover_image
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
except Exception as e:
logger.error(f"Error searching anime: {e}")
return []
async def get_episodes(
self, anime_url: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Get episode list for an anime.
Args:
anime_url: URL of the anime page
lang: Language preference
Returns:
List of episodes with episode_number, url, title
"""
try:
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
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"]
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 episode_num:
episode_number = int(episode_num.group(1))
# Ensure absolute URL
if href.startswith("/"):
href = self.base_url + href
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"])
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes
except Exception as e:
logger.error(f"Error getting episodes: {e}")
return []
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get detailed metadata for an anime.
Args:
anime_url: URL of the anime page
Returns:
Dict with metadata (synopsis, genres, rating, etc.)
"""
try:
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
# Extract title
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()
)
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("/"):
poster_image = self.base_url + poster_image
# Extract genres
genres = []
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:
genres.append(genre)
# Extract rating (if available)
rating = ""
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"],
}
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"],
}
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Get download link from episode page.
For French-Manga, this returns the video player URL.
The actual video extraction will be handled by the video player downloaders.
Args:
url: Episode page URL
Returns:
Tuple of (video_player_url, episode_title)
"""
try:
response = await self.client.get(url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
# Look for iframe or video player
iframe = soup.find("iframe", src=True)
if iframe:
video_url = iframe["src"]
else:
# Look for video tag directly
video = soup.find("video", src=True)
if video:
video_url = video["src"]
else:
# Try to find in script tags
scripts = soup.find_all("script")
for script in scripts:
if script.string:
# Look for iframe or video URLs in JavaScript
patterns = [
r'iframe.*?src=["\']([^"\']+)["\']',
r'video.*?src=["\']([^"\']+)["\']',
]
for pattern in patterns:
match = re.search(pattern, script.string, re.IGNORECASE)
if match:
video_url = match.group(1)
break
if "video_url" in locals():
break
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("/"):
video_url = self.base_url + video_url
# Extract episode title
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)
logger.info(f"Extracted video player URL: {video_url[:60]}...")
return video_url, episode_title
except Exception as e:
logger.error(f"Error getting download link: {e}")
raise ValueError(f"Failed to extract download link: {str(e)}")