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:
@@ -1,4 +1,5 @@
|
||||
"""Zone-Telechargement series site downloader"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
@@ -18,94 +19,106 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "zonetelechargement"
|
||||
self.provider_id = "zonetelechargement"
|
||||
self.default_domain = "zone-telechargement.cam"
|
||||
self.test_tlds = ["cam", "net", "org", "blue", "lol", "work"]
|
||||
self.base_url = None # Will be set dynamically
|
||||
|
||||
self.client.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
})
|
||||
self.default_domain = "zone-telechargement.golf"
|
||||
self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"]
|
||||
self.base_url = f"https://{self.default_domain}"
|
||||
self._domain_checked = False
|
||||
|
||||
self.client.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
)
|
||||
|
||||
async def _ensure_base_url(self):
|
||||
"""Ensure base_url is set to the current active domain"""
|
||||
if not self.base_url:
|
||||
if self._domain_checked:
|
||||
return
|
||||
self._domain_checked = True
|
||||
try:
|
||||
active_domain = await DomainManager.get_active_domain(
|
||||
self.provider_id,
|
||||
self.default_domain,
|
||||
self.test_tlds,
|
||||
test_path="/"
|
||||
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
|
||||
)
|
||||
self.base_url = f"https://{active_domain}"
|
||||
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Domain check failed for Zone-Telechargement, using default: {e}"
|
||||
)
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
"""Check if this downloader can handle the given URL"""
|
||||
return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
|
||||
|
||||
async def search_anime(
|
||||
self,
|
||||
query: str,
|
||||
lang: str = "vf"
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Search for series on Zone-Telechargement"""
|
||||
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
|
||||
"""Search for series on Zone-Telechargement.
|
||||
|
||||
ZT uses server-side rendered search: GET /?p=series&search=QUERY.
|
||||
Results are in div.cover_global containers with nested cover_infos_title links.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
logger.info(f"Searching Zone-Telechargement for: {query}")
|
||||
|
||||
# ZT uses POST or GET for search depending on the version
|
||||
# Most modern versions use: /index.php?do=search
|
||||
search_url = f"{self.base_url}/index.php?do=search"
|
||||
|
||||
# Form data for search
|
||||
data = {
|
||||
"do": "search",
|
||||
"subaction": "search",
|
||||
"search_start": "0",
|
||||
"full_search": "0",
|
||||
"result_from": "1",
|
||||
"story": query
|
||||
}
|
||||
|
||||
response = await self.client.post(search_url, data=data)
|
||||
search_url = f"{self.base_url}/"
|
||||
params = {"p": "series", "search": query}
|
||||
|
||||
response = await self.client.get(search_url, params=params)
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
results = []
|
||||
|
||||
# Look for items
|
||||
items = soup.find_all('div', class_='shm-item') or soup.find_all('div', class_='movie-item')
|
||||
|
||||
for item in items[:24]:
|
||||
link_elem = item.find('a', class_='shm-title') or item.find('a')
|
||||
if not link_elem:
|
||||
for cover_div in soup.find_all("div", class_="cover_global")[:24]:
|
||||
link_in_cover = cover_div.find("a", class_="mainimg")
|
||||
if not link_in_cover:
|
||||
link_in_cover = cover_div.find("a")
|
||||
|
||||
if not link_in_cover:
|
||||
continue
|
||||
|
||||
url = link_elem.get('href', '')
|
||||
if not url.startswith('http'):
|
||||
url = link_in_cover.get("href", "")
|
||||
if not url.startswith("http"):
|
||||
url = urljoin(self.base_url, url)
|
||||
|
||||
title = link_elem.get_text(strip=True)
|
||||
|
||||
img_elem = item.find('img')
|
||||
img = cover_div.find("img")
|
||||
cover_image = ""
|
||||
if img_elem:
|
||||
cover_image = img_elem.get('data-src') or img_elem.get('src') or ""
|
||||
|
||||
if cover_image and not cover_image.startswith('http'):
|
||||
cover_image = urljoin(self.base_url, cover_image)
|
||||
if img:
|
||||
cover_image = img.get("data-src") or img.get("src") or ""
|
||||
if cover_image and not cover_image.startswith("http"):
|
||||
cover_image = urljoin(self.base_url, cover_image)
|
||||
|
||||
title = ""
|
||||
info_div = cover_div.find("div", class_="cover_infos_title")
|
||||
if info_div:
|
||||
title_link = info_div.find("a")
|
||||
if title_link:
|
||||
title = title_link.get_text(strip=True)
|
||||
else:
|
||||
title = info_div.get_text(strip=True)
|
||||
else:
|
||||
title = link_in_cover.get("title", "")
|
||||
if not title:
|
||||
title = link_in_cover.get_text(strip=True)
|
||||
|
||||
if title and len(title) > 2:
|
||||
results.append({
|
||||
'title': title,
|
||||
'url': url,
|
||||
'cover_image': cover_image,
|
||||
'provider_id': self.provider_id
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": url,
|
||||
"cover_image": cover_image,
|
||||
"provider_id": self.provider_id,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Zone-Telechargement found {len(results)} results for '{query}'"
|
||||
)
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
@@ -113,39 +126,35 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
||||
return []
|
||||
|
||||
async def get_episodes(
|
||||
self,
|
||||
anime_url: str,
|
||||
lang: str = "vf"
|
||||
self, anime_url: str, lang: str = "vf"
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Extract episodes from a series page"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
html = await self._fetch_page(anime_url)
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
episodes = []
|
||||
|
||||
|
||||
# ZT typically lists episodes in a table or list of links
|
||||
# Links often look like: /telecharger-series/.../saison-X-episode-Y.html
|
||||
links = soup.find_all('a', href=re.compile(r'episode-\d+'))
|
||||
|
||||
links = soup.find_all("a", href=re.compile(r"episode-\d+"))
|
||||
|
||||
for i, link in enumerate(links):
|
||||
href = link.get('href', '')
|
||||
if not href.startswith('http'):
|
||||
href = link.get("href", "")
|
||||
if not href.startswith("http"):
|
||||
href = urljoin(self.base_url, href)
|
||||
|
||||
|
||||
title = link.get_text(strip=True)
|
||||
ep_match = re.search(r'episode\s*(\d+)', title.lower())
|
||||
ep_match = re.search(r"episode\s*(\d+)", title.lower())
|
||||
ep_number = int(ep_match.group(1)) if ep_match else i + 1
|
||||
|
||||
episodes.append({
|
||||
'episode_number': ep_number,
|
||||
'url': href,
|
||||
'title': title
|
||||
})
|
||||
|
||||
|
||||
episodes.append(
|
||||
{"episode_number": ep_number, "url": href, "title": title}
|
||||
)
|
||||
|
||||
# Sort by episode number
|
||||
episodes.sort(key=lambda x: x['episode_number'])
|
||||
episodes.sort(key=lambda x: x["episode_number"])
|
||||
return episodes
|
||||
|
||||
except Exception as e:
|
||||
@@ -157,32 +166,40 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
html = await self._fetch_page(anime_url)
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
metadata = {
|
||||
'title': "",
|
||||
'synopsis': "",
|
||||
'genres': [],
|
||||
'poster_image': "",
|
||||
'status': "Unknown"
|
||||
"title": "",
|
||||
"synopsis": "",
|
||||
"genres": [],
|
||||
"poster_image": "",
|
||||
"status": "Unknown",
|
||||
}
|
||||
|
||||
title_elem = soup.find('h1')
|
||||
|
||||
title_elem = soup.find("h1")
|
||||
if title_elem:
|
||||
metadata['title'] = title_elem.get_text(strip=True)
|
||||
|
||||
metadata["title"] = title_elem.get_text(strip=True)
|
||||
|
||||
# Synopsis
|
||||
syn_elem = soup.find('div', class_='shm-description') or soup.find('div', class_='movie-desc')
|
||||
syn_elem = soup.find("div", class_="shm-description") or soup.find(
|
||||
"div", class_="movie-desc"
|
||||
)
|
||||
if syn_elem:
|
||||
metadata['synopsis'] = syn_elem.get_text(strip=True)
|
||||
|
||||
metadata["synopsis"] = syn_elem.get_text(strip=True)
|
||||
|
||||
# Poster
|
||||
img_elem = soup.find('div', class_='shm-img').find('img') if soup.find('div', class_='shm-img') else None
|
||||
img_elem = (
|
||||
soup.find("div", class_="shm-img").find("img")
|
||||
if soup.find("div", class_="shm-img")
|
||||
else None
|
||||
)
|
||||
if img_elem:
|
||||
metadata['poster_image'] = urljoin(self.base_url, img_elem.get('src', ''))
|
||||
|
||||
metadata["poster_image"] = urljoin(
|
||||
self.base_url, img_elem.get("src", "")
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting metadata from Zone-Telechargement: {e}")
|
||||
return {}
|
||||
@@ -192,19 +209,25 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
html = await self._fetch_page(url)
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
# Look for video player links (Uptobox, 1fichier, etc.)
|
||||
# ZT often has multiple hosts
|
||||
links = soup.find_all('a', href=re.compile(r'uptobox|1fichier|doodstream|vidmoly'))
|
||||
|
||||
links = soup.find_all(
|
||||
"a", href=re.compile(r"uptobox|1fichier|doodstream|vidmoly")
|
||||
)
|
||||
|
||||
if links:
|
||||
player_url = links[0].get('href', '')
|
||||
title = soup.find('h1').get_text(strip=True) if soup.find('h1') else "Episode"
|
||||
player_url = links[0].get("href", "")
|
||||
title = (
|
||||
soup.find("h1").get_text(strip=True)
|
||||
if soup.find("h1")
|
||||
else "Episode"
|
||||
)
|
||||
return player_url, title
|
||||
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting download link from Zone-Telechargement: {e}")
|
||||
return "", ""
|
||||
|
||||
Reference in New Issue
Block a user