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
This commit is contained in:
@@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
|
||||
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "vostfree"
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||
|
||||
@@ -16,35 +20,35 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
"""Extract download link from vostfree URL"""
|
||||
try:
|
||||
response = await self.client.get(url, follow_redirects=True)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
# Method 1: Look for iframe players
|
||||
iframes = soup.find_all('iframe')
|
||||
iframes = soup.find_all("iframe")
|
||||
for iframe in iframes:
|
||||
src = iframe.get('src', '')
|
||||
if src and any(p in src for p in ['player', 'video', 'stream']):
|
||||
if not src.startswith('http'):
|
||||
src = iframe.get("src", "")
|
||||
if src and any(p in src for p in ["player", "video", "stream"]):
|
||||
if not src.startswith("http"):
|
||||
src = urljoin(str(response.url), src)
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
# Method 2: Look for video tags
|
||||
videos = soup.find_all('video')
|
||||
videos = soup.find_all("video")
|
||||
for video in videos:
|
||||
src = video.get('src')
|
||||
src = video.get("src")
|
||||
if src:
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
sources = video.find_all('source')
|
||||
sources = video.find_all("source")
|
||||
for source in sources:
|
||||
src = source.get('src', '')
|
||||
if src and any(ext in src for ext in ['mp4', 'm3u8']):
|
||||
src = source.get("src", "")
|
||||
if src and any(ext in src for ext in ["mp4", "m3u8"]):
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
# Method 3: Look in scripts
|
||||
scripts = soup.find_all('script')
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
if script.string:
|
||||
patterns = [
|
||||
@@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, script.string)
|
||||
for match in matches:
|
||||
match = match.replace('\\/', '/')
|
||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
||||
match = match.replace("\\/", "/")
|
||||
if any(ext in match for ext in ["mp4", "m3u8"]):
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return match, filename
|
||||
|
||||
@@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
raise Exception(f"Error extracting Vostfree link: {str(e)}")
|
||||
|
||||
def _generate_filename(self, url: str) -> str:
|
||||
parts = url.split('/')
|
||||
parts = url.split("/")
|
||||
anime_name = "anime"
|
||||
episode = "1"
|
||||
|
||||
for part in parts:
|
||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
||||
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
||||
if match:
|
||||
episode = match.group(1)
|
||||
|
||||
@@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
||||
try:
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
episodes = []
|
||||
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
|
||||
episode_links = soup.find_all("a", href=re.compile(r"episode", re.I))
|
||||
|
||||
for link in episode_links:
|
||||
href = link.get('href', '')
|
||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
||||
href = link.get("href", "")
|
||||
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
||||
if match:
|
||||
episode_num = match.group(1)
|
||||
if not href.startswith('http'):
|
||||
if not href.startswith("http"):
|
||||
href = urljoin(anime_url, href)
|
||||
|
||||
episodes.append({'episode': episode_num, 'url': href})
|
||||
episodes.append({"episode": episode_num, "url": href})
|
||||
|
||||
# Deduplicate and sort
|
||||
seen = set()
|
||||
unique_episodes = []
|
||||
for ep in episodes:
|
||||
if ep['episode'] not in seen:
|
||||
seen.add(ep['episode'])
|
||||
if ep["episode"] not in seen:
|
||||
seen.add(ep["episode"])
|
||||
unique_episodes.append(ep)
|
||||
|
||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
||||
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||
return unique_episodes
|
||||
|
||||
except Exception as e:
|
||||
@@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
try:
|
||||
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
metadata = {
|
||||
'synopsis': None,
|
||||
'genres': [],
|
||||
'rating': None,
|
||||
'release_year': None,
|
||||
'studio': None,
|
||||
'poster_image': None,
|
||||
'banner_image': None,
|
||||
'total_episodes': None,
|
||||
'status': None,
|
||||
'alternative_titles': []
|
||||
"synopsis": None,
|
||||
"genres": [],
|
||||
"rating": None,
|
||||
"release_year": None,
|
||||
"studio": None,
|
||||
"poster_image": None,
|
||||
"banner_image": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
"alternative_titles": [],
|
||||
}
|
||||
|
||||
# Extract synopsis
|
||||
synopsis_selectors = [
|
||||
'div.synopsis',
|
||||
'div.description',
|
||||
"div.synopsis",
|
||||
"div.description",
|
||||
'div[class*="synopsis"]',
|
||||
'div[class*="desc"]',
|
||||
'p.synopsis',
|
||||
'.anime-synopsis'
|
||||
"p.synopsis",
|
||||
".anime-synopsis",
|
||||
]
|
||||
|
||||
for selector in synopsis_selectors:
|
||||
@@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
if synopsis_elem:
|
||||
synopsis = synopsis_elem.get_text(strip=True)
|
||||
if len(synopsis) > 50:
|
||||
metadata['synopsis'] = synopsis
|
||||
metadata["synopsis"] = synopsis
|
||||
break
|
||||
|
||||
# Extract genres
|
||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
||||
genre_links = soup.find_all("a", href=re.compile(r"genre|tag|type", re.I))
|
||||
if genre_links:
|
||||
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
||||
metadata["genres"] = [
|
||||
link.get_text(strip=True) for link in genre_links[:5]
|
||||
]
|
||||
|
||||
# Extract rating
|
||||
rating_selectors = [
|
||||
'span.rating',
|
||||
'div.rating',
|
||||
'span.score',
|
||||
"span.rating",
|
||||
"div.rating",
|
||||
"span.score",
|
||||
'div[class*="rating"]',
|
||||
'div[class*="score"]'
|
||||
'div[class*="score"]',
|
||||
]
|
||||
|
||||
for selector in rating_selectors:
|
||||
rating_elem = soup.select_one(selector)
|
||||
if rating_elem:
|
||||
rating_text = rating_elem.get_text(strip=True)
|
||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||
if rating_match:
|
||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||
break
|
||||
|
||||
# Extract release year
|
||||
page_text = soup.get_text()
|
||||
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
|
||||
year_matches = re.findall(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||
if year_matches:
|
||||
import datetime
|
||||
|
||||
current_year = datetime.datetime.now().year + 2
|
||||
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
|
||||
valid_years = [
|
||||
int(y) for y in year_matches if 1950 <= int(y) <= current_year
|
||||
]
|
||||
if valid_years:
|
||||
from collections import Counter
|
||||
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
||||
|
||||
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
|
||||
|
||||
# Extract poster image
|
||||
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
|
||||
poster_elem = soup.select_one("img.poster, img.cover, .anime-poster img")
|
||||
if poster_elem:
|
||||
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
||||
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
|
||||
"data-src"
|
||||
)
|
||||
|
||||
# Extract poster from og:image
|
||||
og_image = soup.find('meta', property='og:image')
|
||||
if og_image and not metadata['poster_image']:
|
||||
metadata['poster_image'] = og_image.get('content')
|
||||
og_image = soup.find("meta", property="og:image")
|
||||
if og_image and not metadata["poster_image"]:
|
||||
metadata["poster_image"] = og_image.get("content")
|
||||
|
||||
# Extract total episodes
|
||||
episodes_count = len(await self.get_episodes(anime_url))
|
||||
if episodes_count > 0:
|
||||
metadata['total_episodes'] = episodes_count
|
||||
metadata["total_episodes"] = episodes_count
|
||||
|
||||
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
||||
return metadata
|
||||
@@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
||||
return {}
|
||||
|
||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||
async def search_anime(
|
||||
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search for anime on vostfree
|
||||
|
||||
@@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
|
||||
|
||||
@@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
if response.status_code == 200:
|
||||
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
||||
result = {
|
||||
'title': query,
|
||||
'url': str(response.url),
|
||||
'type': 'direct',
|
||||
'metadata': None
|
||||
"title": query,
|
||||
"url": str(response.url),
|
||||
"type": "direct",
|
||||
"metadata": None,
|
||||
}
|
||||
|
||||
if include_metadata:
|
||||
metadata = await self.get_anime_metadata(str(response.url))
|
||||
result['metadata'] = metadata
|
||||
result["metadata"] = metadata
|
||||
|
||||
return [result]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user