3dc5dd8fe9
- 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
2089 lines
86 KiB
Python
2089 lines
86 KiB
Python
from .base import BaseAnimeSite
|
|
from bs4 import BeautifulSoup
|
|
import re
|
|
import subprocess
|
|
import json
|
|
import httpx
|
|
import logging
|
|
from typing import Optional
|
|
from urllib.parse import urljoin, unquote
|
|
|
|
import binascii
|
|
from Crypto.Cipher import AES
|
|
from Crypto.Util.Padding import unpad
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Lpayer encryption key (from Anime-Sama-Downloader project)
|
|
LPAYER_KEY = b"kiemtienmua911ca"
|
|
LPAYER_IV = b"1234567890oiuytr"
|
|
|
|
|
|
def _decrypt_lpayer(hex_str: str) -> Optional[str]:
|
|
"""Decrypt Lpayer video URL using AES"""
|
|
try:
|
|
data = binascii.unhexlify(hex_str)
|
|
cipher = AES.new(LPAYER_KEY, AES.MODE_CBC, LPAYER_IV)
|
|
decrypted = unpad(cipher.decrypt(data), AES.block_size)
|
|
return decrypted.decode("utf-8")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class AnimeSamaDownloader(BaseAnimeSite):
|
|
"""Downloader for anime-sama.org / anime-sama.store"""
|
|
|
|
# Static list of known domains (will be updated dynamically)
|
|
BASE_DOMAINS = [
|
|
"anime-sama.to",
|
|
"www.anime-sama.to",
|
|
"anime-sama.tv",
|
|
"www.anime-sama.tv",
|
|
"anime-sama.si",
|
|
"www.anime-sama.si",
|
|
"anime-sama.org",
|
|
"anime-sama.store",
|
|
"anime-sama.eu",
|
|
]
|
|
|
|
def __init__(self):
|
|
"""Initialize AnimeSamaDownloader with working player cache"""
|
|
super().__init__() # Call parent __init__ to initialize client
|
|
self.id = "anime-sama"
|
|
self._working_players = {} # Cache: anime_url -> working player name
|
|
|
|
@classmethod
|
|
async def get_current_domain(cls) -> str:
|
|
"""
|
|
Fetch the current active domain by testing known domains
|
|
Returns the current working domain (e.g., 'anime-sama.to')
|
|
"""
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
|
# Test known domains in order of recency
|
|
for test_domain in [
|
|
"anime-sama.to",
|
|
"anime-sama.tv",
|
|
"anime-sama.si",
|
|
"anime-sama.org",
|
|
]:
|
|
try:
|
|
test_url = f"https://{test_domain}/catalogue"
|
|
response = await client.get(test_url)
|
|
|
|
# Check if we got a valid page (not 404 and has content)
|
|
if response.status_code == 200 and len(response.text) > 1000:
|
|
# Check if it's the real anime-sama site (has catalog cards)
|
|
if (
|
|
"catalogue" in response.text
|
|
or "catalog-card" in response.text
|
|
):
|
|
logger.info(f"Working domain found: {test_domain}")
|
|
return test_domain
|
|
except Exception as e:
|
|
logger.debug(f"Domain {test_domain} failed: {e}")
|
|
continue
|
|
|
|
logger.warning("Could not determine working domain, using default")
|
|
return "anime-sama.to"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching current domain: {e}")
|
|
return "anime-sama.to"
|
|
|
|
@classmethod
|
|
async def update_domains(cls) -> None:
|
|
"""
|
|
Update the BASE_DOMAINS list with the current active domain
|
|
This should be called periodically to keep up with domain changes
|
|
"""
|
|
try:
|
|
current_domain = await cls.get_current_domain()
|
|
|
|
# Add the current domain and its www variant if not already present
|
|
domains_to_add = [current_domain]
|
|
if not current_domain.startswith("www."):
|
|
domains_to_add.append(f"www.{current_domain}")
|
|
|
|
for domain in domains_to_add:
|
|
if domain not in cls.BASE_DOMAINS:
|
|
# Insert at the beginning for priority
|
|
cls.BASE_DOMAINS.insert(0, domain)
|
|
logger.info(f"Added new domain: {domain}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating domains: {e}")
|
|
|
|
def can_handle(self, url: str) -> bool:
|
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
|
|
|
async def get_download_link(
|
|
self, url: str, target_filename: Optional[str] = None
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Extract download link from anime-sama URL
|
|
Anime-Sama uses third-party video hosts (vidmoly, etc.)
|
|
We'll try to extract the video URL from these hosts
|
|
"""
|
|
try:
|
|
logger.debug(f"Extracting link from: {url}")
|
|
|
|
# Check if URL is a direct video URL (.mp4, .m3u8, .mkv)
|
|
# If so, return it directly without extraction
|
|
if url.endswith(".mp4") or url.endswith(".m3u8") or url.endswith(".mkv"):
|
|
# Extract filename from URL
|
|
from urllib.parse import urlparse, unquote
|
|
|
|
parsed = urlparse(url)
|
|
path = unquote(parsed.path)
|
|
filename = (
|
|
path.split("/")[-1] if path.split("/")[-1] else "direct_video.mp4"
|
|
)
|
|
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
|
return url, filename
|
|
|
|
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
|
|
if "|" in url:
|
|
parts = url.split("|")
|
|
video_url = parts[0]
|
|
anime_page_url = parts[1] if len(parts) > 1 else None
|
|
episode_title = parts[2] if len(parts) > 2 else None
|
|
|
|
logger.debug(
|
|
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
|
)
|
|
|
|
# Use fallback method for pipe-separated URLs (tries multiple players)
|
|
return await self.get_download_link_with_fallback(
|
|
video_url,
|
|
anime_page_url=anime_page_url,
|
|
episode_title=episode_title,
|
|
)
|
|
|
|
# Check if this is a third-party host URL
|
|
if "vidmoly.to" in url or "vidmoly.biz" in url or "vidmoly" in url:
|
|
return await self._extract_from_vidmoly(url)
|
|
# Handle direct Lpayer URLs (not embedded in anime-sama pages)
|
|
elif "lpayer." in url and url.startswith("https://lpayer.embed4me.com/"):
|
|
# Direct video URL - return with fixed filename
|
|
logger.info(f"Using direct Lpayer URL: {url[:80]}...")
|
|
return url, "lpayer_video.mp4"
|
|
# Handle Lpayer embedded pages (non-direct URLs)
|
|
elif "lpayer." in url:
|
|
# Embedded page - use fallback
|
|
logger.info(f"Using fallback for Lpayer embedded page: {url[:80]}...")
|
|
return await self.get_download_link_with_fallback(
|
|
url, anime_page_url=url, episode_title=None
|
|
)
|
|
# Handle Smoothpre URLs
|
|
elif "smoothpre" in url.lower():
|
|
logger.info(f"Using fallback for Smoothpre: {url[:80]}...")
|
|
return await self.get_download_link_with_fallback(
|
|
url, anime_page_url=None, episode_title=None
|
|
)
|
|
# If it's an anime-sama page, try to find the video
|
|
if "anime-sama" in url.lower():
|
|
if "dingtez" in url or "dingz" in url:
|
|
return await self._extract_from_dingetz(url)
|
|
elif "wupstream" in url or "wup" in url:
|
|
return await self._extract_from_wupstream(url)
|
|
elif "doodstream" in url or "dood" in url:
|
|
return await self._extract_from_doodstream(url)
|
|
elif "streamtape" in url:
|
|
return await self._extract_from_streamtape(url)
|
|
elif "voe" in url:
|
|
return await self._extract_from_voe(url)
|
|
|
|
logger.debug(f"Processing anime-sama page: {url}")
|
|
response = await self.client.get(url, follow_redirects=True)
|
|
final_url = str(response.url)
|
|
soup = BeautifulSoup(response.text, "lxml")
|
|
|
|
logger.debug(f"Final URL after redirects: {final_url}")
|
|
|
|
# Look for iframe with video player
|
|
iframes = soup.find_all("iframe")
|
|
logger.debug(f"Found {len(iframes)} iframes")
|
|
|
|
for iframe in iframes:
|
|
src = iframe.get("src", "")
|
|
if src and any(
|
|
provider in src
|
|
for provider in [
|
|
"vidmoly",
|
|
"player",
|
|
"stream",
|
|
"play",
|
|
"embed",
|
|
"smoothpre",
|
|
]
|
|
):
|
|
if not src.startswith("http"):
|
|
src = urljoin(final_url, src)
|
|
logger.debug(f"Found iframe: {src}")
|
|
# Try to extract video from the player
|
|
try:
|
|
# For vidmoly, extract and return the video URL directly
|
|
if "vidmoly" in src:
|
|
logger.debug(f"Extracting from vidmoly iframe: {src}")
|
|
video_url, filename = await self._extract_from_vidmoly(
|
|
src, anime_page_url=url, episode_title="Episode"
|
|
)
|
|
return video_url, filename
|
|
# For smoothpre, use the smoothpre extractor
|
|
elif "smoothpre" in src.lower():
|
|
logger.debug(f"Extracting from smoothpre iframe: {src}")
|
|
(
|
|
video_url,
|
|
filename,
|
|
) = await self._extract_from_smoothpre(
|
|
src, anime_page_url=url, episode_title="Episode"
|
|
)
|
|
return video_url, filename
|
|
else:
|
|
video_url = await self._extract_from_player(src)
|
|
if video_url:
|
|
filename = self._generate_filename(final_url)
|
|
return video_url, filename
|
|
except Exception as e:
|
|
logger.debug(f"Error extracting from iframe: {e}")
|
|
continue
|
|
|
|
# Look for video tags
|
|
videos = soup.find_all("video")
|
|
logger.debug(f"Found {len(videos)} video tags")
|
|
for video in videos:
|
|
src = video.get("src", "")
|
|
if src:
|
|
if not src.startswith("http"):
|
|
src = urljoin(final_url, src)
|
|
filename = self._generate_filename(final_url)
|
|
return src, filename
|
|
|
|
sources = video.find_all("source")
|
|
for source in sources:
|
|
src = source.get("src", "")
|
|
if src:
|
|
if not src.startswith("http"):
|
|
src = urljoin(final_url, src)
|
|
filename = self._generate_filename(final_url)
|
|
return src, filename
|
|
|
|
# If we couldn't find video in iframe, the page structure might have changed
|
|
# Save HTML for debugging
|
|
logger.debug(
|
|
f"Could not find video link on page. HTML snippet:\n{soup.prettify()[:1000]}"
|
|
)
|
|
|
|
raise Exception("Could not find video link on page")
|
|
|
|
except Exception as e:
|
|
raise Exception(f"Error extracting AnimeSama link: {str(e)}")
|
|
|
|
async def _extract_from_vidmoly(
|
|
self, url: str, anime_page_url: str = None, episode_title: str = None
|
|
) -> tuple[str, str]:
|
|
"""Extract video URL from vidmoly player - delegate to VidMolyDownloader"""
|
|
try:
|
|
logger.debug(f"Extracting from vidmoly: {url}")
|
|
logger.debug(f"Delegating to VidMolyDownloader...")
|
|
|
|
# Import VidMolyDownloader
|
|
from ..video_players.vidmoly import VidMolyDownloader
|
|
|
|
# Generate the target filename first
|
|
if episode_title and anime_page_url:
|
|
anime_name = self._generate_anime_name(anime_page_url)
|
|
season_num = self._extract_season_number(anime_page_url)
|
|
if season_num:
|
|
target_filename = (
|
|
f"{anime_name} - S{season_num} - {episode_title}.mp4"
|
|
)
|
|
else:
|
|
target_filename = f"{anime_name} - {episode_title}.mp4"
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (episode: {episode_title})"
|
|
)
|
|
elif anime_page_url:
|
|
target_filename = self._generate_filename_from_anime_url(anime_page_url)
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (no episode title)"
|
|
)
|
|
else:
|
|
target_filename = None
|
|
logger.debug(f"No target_filename generated")
|
|
|
|
# Use VidMolyDownloader to extract and download
|
|
vidmoly_downloader = VidMolyDownloader()
|
|
|
|
# Pass the target filename to VidMolyDownloader if available
|
|
if target_filename:
|
|
video_url, temp_filename = await vidmoly_downloader.get_download_link(
|
|
url, target_filename=target_filename
|
|
)
|
|
else:
|
|
video_url, temp_filename = await vidmoly_downloader.get_download_link(
|
|
url
|
|
)
|
|
|
|
# Use the target filename
|
|
filename = target_filename if target_filename else temp_filename
|
|
|
|
logger.debug(f"Got video: {filename}")
|
|
|
|
# Rename the file if needed
|
|
import os
|
|
|
|
if temp_filename != filename:
|
|
# temp_filename might be a full path or just the name
|
|
temp_path = (
|
|
temp_filename
|
|
if os.path.isabs(temp_filename)
|
|
else os.path.join("downloads", temp_filename)
|
|
)
|
|
|
|
if os.path.exists(temp_path):
|
|
final_path = os.path.join("downloads", filename)
|
|
if os.path.exists(final_path):
|
|
os.remove(final_path)
|
|
os.rename(temp_path, final_path)
|
|
logger.debug(f"Renamed {temp_filename} -> {filename}")
|
|
else:
|
|
logger.debug(f"Warning: temp file not found: {temp_path}")
|
|
|
|
# Return the video_url from VidMoly extractor (local path for M3U8, or URL for MP4)
|
|
# NOT the original VidMoly embed URL!
|
|
return video_url, filename
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Vidmoly extraction error: {e}")
|
|
raise Exception(f"Error extracting from vidmoly: {str(e)}")
|
|
|
|
async def _extract_from_sendvid(
|
|
self, url: str, anime_page_url: str = None, episode_title: str = None
|
|
) -> tuple[str, str]:
|
|
"""Extract video URL from sendvid player - delegate to SendVidDownloader"""
|
|
try:
|
|
logger.debug(f"Extracting from sendvid: {url}")
|
|
logger.debug(f"Delegating to SendVidDownloader...")
|
|
|
|
# Import SendVidDownloader
|
|
from ..video_players.sendvid import SendVidDownloader
|
|
|
|
# Generate the target filename first
|
|
if episode_title and anime_page_url:
|
|
anime_name = self._generate_anime_name(anime_page_url)
|
|
season_num = self._extract_season_number(anime_page_url)
|
|
if season_num:
|
|
target_filename = (
|
|
f"{anime_name} - S{season_num} - {episode_title}.mp4"
|
|
)
|
|
else:
|
|
target_filename = f"{anime_name} - {episode_title}.mp4"
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (episode: {episode_title})"
|
|
)
|
|
elif anime_page_url:
|
|
target_filename = self._generate_filename_from_anime_url(anime_page_url)
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (no episode title)"
|
|
)
|
|
else:
|
|
target_filename = None
|
|
logger.debug(f"No target_filename generated")
|
|
|
|
# Use SendVidDownloader to extract the video URL
|
|
sendvid_downloader = SendVidDownloader()
|
|
|
|
# Pass the target filename to SendVidDownloader if available
|
|
if target_filename:
|
|
video_url, filename = await sendvid_downloader.get_download_link(
|
|
url, target_filename=target_filename
|
|
)
|
|
else:
|
|
video_url, filename = await sendvid_downloader.get_download_link(url)
|
|
|
|
# Use the target filename
|
|
filename = target_filename if target_filename else filename
|
|
|
|
logger.debug(f"Got video: {filename}")
|
|
|
|
# Return the direct video URL (SendVid provides direct MP4 links)
|
|
# The download_manager will handle the actual download
|
|
return video_url, filename
|
|
|
|
except Exception as e:
|
|
logger.debug(f"SendVid extraction error: {e}")
|
|
raise Exception(f"Error extracting from sendvid: {str(e)}")
|
|
|
|
async def _extract_from_sibnet(
|
|
self, url: str, anime_page_url: str = None, episode_title: str = None
|
|
) -> tuple[str, str]:
|
|
"""Extract video URL from sibnet player - delegate to SibnetDownloader"""
|
|
try:
|
|
logger.debug(f"Extracting from sibnet: {url}")
|
|
logger.debug(f"Delegating to SibnetDownloader...")
|
|
|
|
# Import SibnetDownloader
|
|
from ..video_players.sibnet import SibnetDownloader
|
|
|
|
# Generate the target filename first
|
|
if episode_title and anime_page_url:
|
|
anime_name = self._generate_anime_name(anime_page_url)
|
|
season_num = self._extract_season_number(anime_page_url)
|
|
if season_num:
|
|
target_filename = (
|
|
f"{anime_name} - S{season_num} - {episode_title}.mp4"
|
|
)
|
|
else:
|
|
target_filename = f"{anime_name} - {episode_title}.mp4"
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (episode: {episode_title})"
|
|
)
|
|
elif anime_page_url:
|
|
target_filename = self._generate_filename_from_anime_url(anime_page_url)
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (no episode title)"
|
|
)
|
|
else:
|
|
target_filename = None
|
|
logger.debug(f"No target_filename generated")
|
|
|
|
# Use SibnetDownloader to extract the video URL
|
|
sibnet_downloader = SibnetDownloader()
|
|
video_url, temp_filename = await sibnet_downloader.get_download_link(url)
|
|
|
|
# Use the target filename if available
|
|
filename = target_filename if target_filename else temp_filename
|
|
|
|
logger.debug(f"Got video: {filename}")
|
|
logger.debug(f"Video URL: {video_url[:100]}...")
|
|
|
|
# Return the direct video URL (Sibnet provides direct MP4 links)
|
|
# The download_manager will handle the actual download
|
|
return video_url, filename
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Sibnet extraction error: {e}")
|
|
raise Exception(f"Error extracting from sibnet: {str(e)}")
|
|
|
|
def _generate_filename_from_anime_url(self, anime_url: str) -> str:
|
|
"""Generate filename from anime-sama anime page URL"""
|
|
try:
|
|
# Extract anime name and season from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/
|
|
# Format: /catalogue/{anime}/saison{N}/{lang}/
|
|
parts = anime_url.split("/")
|
|
anime_name = "Anime"
|
|
season_num = None
|
|
|
|
for i, part in enumerate(parts):
|
|
if part == "catalogue" and i + 1 < len(parts):
|
|
anime_name = parts[i + 1].replace("-", " ").title()
|
|
|
|
# Extract season number
|
|
for part in parts:
|
|
if "saison" in part.lower():
|
|
try:
|
|
season_num = int(
|
|
part.replace("saison", "").replace("Saison", "")
|
|
)
|
|
break
|
|
except:
|
|
pass
|
|
|
|
episode = "01"
|
|
if season_num:
|
|
return f"{anime_name} - S{season_num} - Episode {episode}.mp4"
|
|
else:
|
|
return f"{anime_name} - Episode {episode}.mp4"
|
|
except:
|
|
return "Anime - Episode 01.Mp4"
|
|
|
|
def _generate_anime_name(self, anime_url: str) -> str:
|
|
"""Extract just the anime name from anime-sama URL"""
|
|
try:
|
|
# Extract anime name from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/
|
|
parts = anime_url.split("/")
|
|
for i, part in enumerate(parts):
|
|
if part == "catalogue" and i + 1 < len(parts):
|
|
return parts[i + 1].replace("-", " ").title()
|
|
# Fallback
|
|
return "Anime"
|
|
except:
|
|
return "Anime"
|
|
|
|
def _extract_season_number(self, anime_url: str) -> int | None:
|
|
"""Extract season number from anime-sama URL"""
|
|
try:
|
|
parts = anime_url.split("/")
|
|
for part in parts:
|
|
if "saison" in part.lower():
|
|
return int(part.replace("saison", "").replace("Saison", ""))
|
|
return None
|
|
except:
|
|
return None
|
|
|
|
async def _extract_from_lpayer(
|
|
self, url: str, anime_page_url: str = None, episode_title: str = None
|
|
) -> tuple[str, str]:
|
|
"""Extract video URL from lpayer player - delegate to LpayerDownloader"""
|
|
try:
|
|
logger.debug(f"Extracting from lpayer: {url}")
|
|
logger.debug(f"Delegating to LpayerDownloader...")
|
|
|
|
# Import LpayerDownloader
|
|
from ..video_players.lpayer import LpayerDownloader
|
|
|
|
# Generate the target filename first
|
|
if episode_title and anime_page_url:
|
|
anime_name = self._generate_anime_name(anime_page_url)
|
|
season_num = self._extract_season_number(anime_page_url)
|
|
if season_num:
|
|
target_filename = (
|
|
f"{anime_name} - S{season_num} - {episode_title}.mp4"
|
|
)
|
|
else:
|
|
target_filename = f"{anime_name} - {episode_title}.mp4"
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (episode: {episode_title})"
|
|
)
|
|
elif anime_page_url:
|
|
target_filename = self._generate_filename_from_anime_url(anime_page_url)
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (no episode title)"
|
|
)
|
|
else:
|
|
target_filename = None
|
|
logger.debug(f"No target_filename generated")
|
|
|
|
# Use LpayerDownloader to extract the video URL
|
|
lpayer_downloader = LpayerDownloader()
|
|
video_url, temp_filename = await lpayer_downloader.get_download_link(url)
|
|
|
|
# Use the target filename if available
|
|
filename = target_filename if target_filename else temp_filename
|
|
|
|
logger.debug(f"Got video: {filename}")
|
|
logger.debug(f"Video URL: {video_url[:100] if video_url else 'None'}...")
|
|
|
|
# Return the direct video URL
|
|
# The download_manager will handle the actual download
|
|
return video_url, filename
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Lpayer extraction error: {e}")
|
|
# Re-raise with clearer message
|
|
raise Exception(
|
|
f"Lpayer player not supported - this video host requires manual download. Try another host (VidMoly, SendVid, Sibnet). Error: {str(e)}"
|
|
)
|
|
|
|
async def _extract_from_lpayer_api(
|
|
self,
|
|
url: str,
|
|
anime_page_url: str = None,
|
|
episode_title: str = None,
|
|
target_filename: str = None,
|
|
) -> tuple[str, str]:
|
|
"""Extract video URL from Lplayer using API decryption"""
|
|
import requests
|
|
|
|
# Extract video ID from URL
|
|
match = re.search(r"#([a-zA-Z0-9]+)", url)
|
|
if not match:
|
|
match = re.search(r"[?&]id=([a-zA-Z0-9]+)", url)
|
|
if not match:
|
|
raise Exception("Could not extract Lplayer video ID")
|
|
|
|
video_id = match.group(1)
|
|
api_url = f"https://lpayer.embed4me.com/api/v1/video?id={video_id}&w=1920&h=1080&r=https://lpayer.embed4me.com/"
|
|
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
|
|
"Referer": "https://lpayer.embed4me.com/",
|
|
}
|
|
|
|
response = requests.get(api_url, headers=headers, timeout=30)
|
|
|
|
if response.status_code != 200:
|
|
raise Exception(f"Lplayer API returned {response.status_code}")
|
|
|
|
hex_data = response.text.strip()
|
|
if hex_data.startswith('"') and hex_data.endswith('"'):
|
|
hex_data = hex_data[1:-1]
|
|
|
|
decrypted = _decrypt_lpayer(hex_data)
|
|
if not decrypted:
|
|
raise Exception("Failed to decrypt Lplayer response")
|
|
|
|
data = json.loads(decrypted)
|
|
m3u8_url = data.get("source")
|
|
|
|
if not m3u8_url:
|
|
raise Exception("No source found in Lplayer response")
|
|
|
|
# Use yt-dlp to get direct video URL from m3u8
|
|
cmd = [
|
|
"yt-dlp",
|
|
"--referer",
|
|
"https://lpayer.embed4me.com/",
|
|
"--skip-download",
|
|
"--dump-json",
|
|
"--no-warnings",
|
|
m3u8_url,
|
|
]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
|
|
# Use target_filename if provided, otherwise fallback to default
|
|
filename = target_filename if target_filename else f"lpayer_{video_id}.mp4"
|
|
|
|
if result.returncode == 0 and result.stdout:
|
|
yt_data = json.loads(result.stdout)
|
|
if "formats" in yt_data:
|
|
# Get best mp4 format (highest resolution)
|
|
formats = yt_data["formats"]
|
|
mp4_formats = [f for f in formats if f.get("ext") == "mp4"]
|
|
if mp4_formats:
|
|
# Sort by resolution (height) descending
|
|
mp4_formats.sort(key=lambda x: x.get("height", 0), reverse=True)
|
|
video_url = mp4_formats[0].get("url")
|
|
else:
|
|
video_url = formats[0].get("url")
|
|
else:
|
|
video_url = yt_data.get("url")
|
|
|
|
if video_url:
|
|
return video_url, filename
|
|
|
|
# If yt-dlp fails, return m3u8 URL anyway (let download manager handle it)
|
|
return m3u8_url, filename
|
|
|
|
async def _extract_from_smoothpre(
|
|
self, url: str, anime_page_url: str = None, episode_title: str = None
|
|
) -> tuple[str, str]:
|
|
"""Extract video URL from smoothpre player - delegate to SmoothpreDownloader"""
|
|
try:
|
|
logger.debug(f"Extracting from smoothpre: {url}")
|
|
logger.debug(f"Delegating to SmoothpreDownloader...")
|
|
|
|
# Import SmoothpreDownloader
|
|
from ..video_players.smoothpre import SmoothpreDownloader
|
|
|
|
# Generate the target filename first
|
|
if episode_title and anime_page_url:
|
|
anime_name = self._generate_anime_name(anime_page_url)
|
|
season_num = self._extract_season_number(anime_page_url)
|
|
if season_num:
|
|
target_filename = (
|
|
f"{anime_name} - S{season_num} - {episode_title}.mp4"
|
|
)
|
|
else:
|
|
target_filename = f"{anime_name} - {episode_title}.mp4"
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (episode: {episode_title})"
|
|
)
|
|
elif anime_page_url:
|
|
target_filename = self._generate_filename_from_anime_url(anime_page_url)
|
|
logger.debug(
|
|
f"Generated filename: {target_filename} (no episode title)"
|
|
)
|
|
else:
|
|
target_filename = None
|
|
logger.debug(f"No target_filename generated")
|
|
|
|
# Use SmoothpreDownloader to extract the video URL
|
|
smoothpre_downloader = SmoothpreDownloader()
|
|
video_url, temp_filename = await smoothpre_downloader.get_download_link(
|
|
url, target_filename=target_filename
|
|
)
|
|
|
|
# Use the target filename if available
|
|
filename = target_filename if target_filename else temp_filename
|
|
|
|
logger.debug(f"Got video: {filename}")
|
|
logger.debug(f"Video URL: {video_url[:100] if video_url else 'None'}...")
|
|
|
|
# Return the direct video URL
|
|
# The download_manager will handle the actual download
|
|
return video_url, filename
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Smoothpre extraction error: {e}")
|
|
raise Exception(f"Error extracting from smoothpre: {str(e)}")
|
|
|
|
async def _extract_from_player(self, player_url: str) -> str | None:
|
|
"""Try to extract direct video URL from player iframe"""
|
|
try:
|
|
response = await self.client.get(player_url)
|
|
soup = BeautifulSoup(response.text, "lxml")
|
|
|
|
# Check for video tags
|
|
videos = soup.find_all("video")
|
|
for video in videos:
|
|
src = video.get("src") or video.get("data-src")
|
|
if src:
|
|
return src
|
|
|
|
# Check for source tags
|
|
sources = soup.find_all("source")
|
|
for source in sources:
|
|
src = source.get("src")
|
|
if src and any(ext in src for ext in ["mp4", "m3u8", "mkv"]):
|
|
return src
|
|
|
|
# Check scripts in player page
|
|
scripts = soup.find_all("script")
|
|
for script in scripts:
|
|
if script.string:
|
|
match = re.search(
|
|
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
|
|
script.string,
|
|
)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
except:
|
|
pass
|
|
|
|
return None
|
|
|
|
def _generate_filename(self, url: str) -> str:
|
|
"""Generate filename from URL"""
|
|
# Extract anime name and episode info from URL
|
|
# URL format: .../catalogue/{anime}/saison{N}/{vostfr|vf}/episode-{N}
|
|
parts = url.split("/")
|
|
|
|
anime_name = "anime"
|
|
episode = "1"
|
|
|
|
for i, part in enumerate(parts):
|
|
if part == "catalogue" and i + 1 < len(parts):
|
|
anime_name = parts[i + 1].replace("-", " ")
|
|
elif "episode-" in part:
|
|
episode = part.replace("episode-", "")
|
|
elif part in ["vostfr", "vf"]:
|
|
lang = part.upper()
|
|
|
|
filename = f"{anime_name} - Episode {episode}.mp4"
|
|
return filename.title()
|
|
|
|
async def get_anime_metadata(self, anime_url: str) -> dict:
|
|
"""
|
|
Extract rich metadata from anime page
|
|
Returns synopsis, genres, rating, release year, studio, etc.
|
|
"""
|
|
try:
|
|
logger.debug(f"Extracting metadata from: {anime_url}")
|
|
response = await self.client.get(anime_url)
|
|
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": [],
|
|
}
|
|
|
|
# Extract synopsis
|
|
# Anime-Sama typically has synopsis in a div with specific classes
|
|
synopsis_selectors = [
|
|
"div.synopsis",
|
|
"div.description",
|
|
'div[class*="synopsis"]',
|
|
'div[class*="description"]',
|
|
"p.synopsis",
|
|
"div.texte",
|
|
".asn-synopsis",
|
|
]
|
|
|
|
for selector in synopsis_selectors:
|
|
synopsis_elem = soup.select_one(selector)
|
|
if synopsis_elem:
|
|
synopsis = synopsis_elem.get_text(strip=True)
|
|
if len(synopsis) > 50: # Ensure it's actual content
|
|
metadata["synopsis"] = synopsis
|
|
break
|
|
|
|
# Extract genres
|
|
# Look for genre tags/links
|
|
genre_patterns = [
|
|
r"Genre?\s*:?\s*([^\n]+)",
|
|
r"Type?\s*:?\s*([^\n]+)",
|
|
]
|
|
|
|
# Try to find genre links
|
|
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]
|
|
]
|
|
|
|
# Also try to find genres in text
|
|
page_text = soup.get_text()
|
|
for pattern in genre_patterns:
|
|
match = re.search(pattern, page_text, re.IGNORECASE)
|
|
if match:
|
|
genres_text = match.group(1)
|
|
# Split by common separators
|
|
genres = [g.strip() for g in re.split(r"[,;/|]", genres_text)]
|
|
genres = [g for g in genres if g and len(g) > 2]
|
|
if genres:
|
|
metadata["genres"].extend(genres)
|
|
break
|
|
|
|
# Remove duplicates
|
|
metadata["genres"] = list(set(metadata["genres"]))
|
|
|
|
# Extract rating
|
|
rating_selectors = [
|
|
"span.rating",
|
|
"div.rating",
|
|
"span.score",
|
|
'div[class*="rating"]',
|
|
'div[class*="score"]',
|
|
".asn-rating",
|
|
]
|
|
|
|
for selector in rating_selectors:
|
|
rating_elem = soup.select_one(selector)
|
|
if rating_elem:
|
|
rating_text = rating_elem.get_text(strip=True)
|
|
# Look for rating patterns like "8.5/10", "4/5", "★★★★☆"
|
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
|
if rating_match:
|
|
metadata["rating"] = f"{rating_match.group(1)}/10"
|
|
break
|
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*5", rating_text)
|
|
if rating_match:
|
|
rating_val = float(rating_match.group(1)) * 2 # Convert to /10
|
|
metadata["rating"] = f"{rating_val:.1f}/10"
|
|
break
|
|
|
|
# Extract release year
|
|
year_patterns = [
|
|
r"(\d{4})",
|
|
r"Année?\s*:?\s*(\d{4})",
|
|
r"Year?\s*:?\s*(\d{4})",
|
|
r"Sortie?\s*:?\s*(\d{4})",
|
|
]
|
|
|
|
for pattern in year_patterns:
|
|
matches = re.findall(pattern, page_text)
|
|
# Filter valid years (between 1950 and current year + 2)
|
|
import datetime
|
|
|
|
current_year = datetime.datetime.now().year + 2
|
|
valid_years = [
|
|
int(m) for m in matches if 1950 <= int(m) <= current_year
|
|
]
|
|
if valid_years:
|
|
# Take the most common year (likely the release year)
|
|
from collections import Counter
|
|
|
|
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
|
|
break
|
|
|
|
# Extract studio
|
|
studio_patterns = [
|
|
r"Studio\s*:?\s*([^\n,]+)",
|
|
r"Produit\s*par\s*:?\s*([^\n,]+)",
|
|
r"Animation\s*:?\s*([^\n,]+)",
|
|
]
|
|
|
|
for pattern in studio_patterns:
|
|
match = re.search(pattern, page_text, re.IGNORECASE)
|
|
if match:
|
|
studio = match.group(1).strip()
|
|
if len(studio) > 2 and len(studio) < 100:
|
|
metadata["studio"] = studio
|
|
break
|
|
|
|
# Extract poster image
|
|
poster_elem = soup.select_one(
|
|
'img.poster, img.cover, img[class*="poster"], img[class*="cover"], .asn-poster img'
|
|
)
|
|
if poster_elem:
|
|
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
|
|
"data-src"
|
|
)
|
|
|
|
# Extract banner image
|
|
banner_elem = soup.select_one(
|
|
'div.banner img, .asn-banner img, img[class*="banner"]'
|
|
)
|
|
if banner_elem:
|
|
metadata["banner_image"] = banner_elem.get("src") or banner_elem.get(
|
|
"data-src"
|
|
)
|
|
|
|
# Extract total episodes
|
|
episodes_count = len(await self.get_episodes(anime_url))
|
|
if episodes_count > 0:
|
|
metadata["total_episodes"] = episodes_count
|
|
|
|
# Extract status (ongoing/completed)
|
|
status_patterns = [
|
|
r"En\s*cours",
|
|
r"Ongoing",
|
|
r"Terminé",
|
|
r"Completed",
|
|
r"Finished",
|
|
]
|
|
|
|
for pattern in status_patterns:
|
|
if re.search(pattern, page_text, re.IGNORECASE):
|
|
if "cour" in pattern.lower() or "ongoing" in pattern.lower():
|
|
metadata["status"] = "Ongoing"
|
|
else:
|
|
metadata["status"] = "Completed"
|
|
break
|
|
|
|
logger.debug(f"Extracted metadata: {metadata}")
|
|
return metadata
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error extracting metadata: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return {}
|
|
|
|
async def search_anime(
|
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
|
) -> list[dict]:
|
|
"""
|
|
Search for anime on anime-sama
|
|
Returns list of anime with title, url, and cover image
|
|
Uses the official Anime-Sama search API which handles typos and fuzzy matching
|
|
|
|
Args:
|
|
query: Search query string
|
|
lang: Language preference (vostfr, vf)
|
|
include_metadata: Whether to fetch full metadata for each result (slower)
|
|
"""
|
|
try:
|
|
# Update domains before searching to ensure we have the current domain
|
|
await self.update_domains()
|
|
|
|
import time
|
|
from html import unescape
|
|
|
|
start = time.time()
|
|
logger.debug(f"Searching for '{query}' ({lang})...")
|
|
|
|
# Get the current working domain
|
|
current_domain = await self.get_current_domain()
|
|
logger.info(f"Using domain: {current_domain}")
|
|
|
|
# Use the official search API endpoint
|
|
search_api_url = f"https://{current_domain}/template-php/defaut/fetch.php"
|
|
|
|
# Make POST request to search API
|
|
response = await self.client.post(
|
|
search_api_url,
|
|
data={"query": query},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
|
|
elapsed = time.time() - start
|
|
logger.debug(f"Got search response in {elapsed:.2f}s")
|
|
|
|
if response.status_code == 200 and response.text.strip():
|
|
# Parse HTML results
|
|
soup = BeautifulSoup(response.text, "lxml")
|
|
results = []
|
|
|
|
# Extract all search result links
|
|
for link in soup.find_all("a", class_="asn-search-result"):
|
|
href = link.get("href", "")
|
|
title_elem = link.find("h3", class_="asn-search-result-title")
|
|
img_elem = link.find("img", class_="asn-search-result-img")
|
|
|
|
title = unescape(title_elem.get_text()) if title_elem else "Unknown"
|
|
cover_image = img_elem.get("src", "") if img_elem else None
|
|
|
|
# Add language parameter to URL
|
|
if "/saison1/" not in href:
|
|
href = href.rstrip("/") + f"/saison1/{lang}/"
|
|
|
|
result = {
|
|
"title": title,
|
|
"url": href,
|
|
"cover_image": cover_image,
|
|
"type": "search_result",
|
|
"metadata": None,
|
|
}
|
|
|
|
# Fetch metadata if requested
|
|
if include_metadata:
|
|
metadata = await self.get_anime_metadata(href)
|
|
result["metadata"] = metadata
|
|
|
|
results.append(result)
|
|
|
|
logger.debug(f"Found {len(results)} results")
|
|
return results
|
|
|
|
logger.debug(f"No results found")
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Search error: {str(e)}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return []
|
|
|
|
async def _test_video_url(self, url: str) -> bool:
|
|
"""
|
|
Validate a video URL by downloading the first 10KB.
|
|
Returns True if HTTP 200 and valid data received, False otherwise.
|
|
Includes 10 second timeout handling.
|
|
"""
|
|
try:
|
|
logger.debug(f"Testing video URL: {url[:60]}...")
|
|
|
|
# Build headers with appropriate referer based on URL
|
|
headers = {"Range": "bytes=0-10240"}
|
|
|
|
# Add referer for CDN URLs that require it (lpayer, etc.)
|
|
if (
|
|
"185.237." in url
|
|
or "203.188." in url
|
|
or "lpayer" in url.lower()
|
|
or "/mik/" in url
|
|
):
|
|
headers["Referer"] = "https://lpayer.embed4me.com/"
|
|
elif "sibnet.ru" in url:
|
|
headers["Referer"] = "https://video.sibnet.ru/"
|
|
elif "sendvid.com" in url:
|
|
headers["Referer"] = "https://sendvid.com/"
|
|
elif "vidmoly" in url:
|
|
headers["Referer"] = "https://vidmoly.to/"
|
|
|
|
# Stream only first 10KB to validate the URL
|
|
response = await self.client.get(url, timeout=10.0, headers=headers)
|
|
|
|
if response.status_code in (200, 206):
|
|
content_length = len(response.content)
|
|
if content_length > 0:
|
|
logger.info(
|
|
f"Video URL validation SUCCESS: {url[:60]}... ({content_length} bytes)"
|
|
)
|
|
return True
|
|
else:
|
|
logger.warning(
|
|
f"Video URL validation FAILED: Empty response for {url[:60]}..."
|
|
)
|
|
return False
|
|
else:
|
|
logger.warning(
|
|
f"Video URL validation FAILED: HTTP {response.status_code} for {url[:60]}..."
|
|
)
|
|
return False
|
|
|
|
except httpx.TimeoutException:
|
|
logger.warning(f"Video URL validation FAILED: Timeout for {url[:60]}...")
|
|
return False
|
|
except httpx.ConnectError as e:
|
|
logger.warning(
|
|
f"Video URL validation FAILED: Connection error for {url[:60]}...: {e}"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(f"Video URL validation FAILED: Error for {url[:60]}...: {e}")
|
|
return False
|
|
|
|
async def _extract_with_ytdlp(
|
|
self, url: str, provider: str = None
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Extract video URL using yt-dlp with proper referer.
|
|
This bypasses many blocking mechanisms.
|
|
"""
|
|
# Define referers for each provider
|
|
referers = {
|
|
"sendvid": "https://sendvid.com/",
|
|
"vidmoly": "https://vidmoly.biz/",
|
|
"sibnet": "https://video.sibnet.ru/",
|
|
"lpayer": "https://lpayer.embed4me.com/",
|
|
"dingtez": "https://anime-sama.tv/",
|
|
"streamtape": "https://streamtape.com/",
|
|
"voe": "https://voe.sx/",
|
|
"doodstream": "https://doodstream.com/",
|
|
}
|
|
|
|
# Determine referer
|
|
referer = "https://anime-sama.tv/"
|
|
if provider:
|
|
referer = referers.get(provider.lower(), referer)
|
|
else:
|
|
for prov, ref in referers.items():
|
|
if prov in url.lower():
|
|
referer = ref
|
|
break
|
|
|
|
try:
|
|
cmd = [
|
|
"yt-dlp",
|
|
"--referer",
|
|
referer,
|
|
"--skip-download",
|
|
"--dump-json",
|
|
"--no-warnings",
|
|
url,
|
|
]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
|
|
if result.returncode == 0 and result.stdout:
|
|
data = json.loads(result.stdout)
|
|
if "formats" in data:
|
|
formats = data["formats"]
|
|
mp4_formats = [f for f in formats if f.get("ext") == "mp4"]
|
|
if mp4_formats:
|
|
video_url = mp4_formats[0].get("url")
|
|
else:
|
|
video_url = formats[0].get("url")
|
|
else:
|
|
video_url = data.get("url")
|
|
|
|
if video_url:
|
|
return (
|
|
video_url,
|
|
f"{provider}_video.mp4" if provider else "video.mp4",
|
|
)
|
|
|
|
raise Exception(f"yt-dlp failed: {result.stderr}")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise Exception("yt-dlp extraction timeout")
|
|
except json.JSONDecodeError:
|
|
raise Exception("yt-dlp returned invalid JSON")
|
|
|
|
async def get_download_link_with_fallback(
|
|
self,
|
|
url: str,
|
|
target_filename: Optional[str] = None,
|
|
anime_page_url: Optional[str] = None,
|
|
episode_title: Optional[str] = None,
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Extract download link with fallback to multiple players and URLs.
|
|
|
|
URL format: url1|url2|url3|anime_page_url|episode_title
|
|
Player priority: detected from URL -> cached -> vidmoly -> sendvid -> sibnet -> lpayer
|
|
Uses caching to remember working players per anime URL.
|
|
Validates each URL with _test_video_url() before returning.
|
|
|
|
Args:
|
|
url: Video player URL or pipe-separated URLs
|
|
target_filename: Optional target filename for the download
|
|
anime_page_url: URL of the anime page (for caching key)
|
|
episode_title: Episode title (for filename generation)
|
|
|
|
Returns:
|
|
Tuple of (video_url, filename)
|
|
|
|
Raises:
|
|
Exception: If all players fail
|
|
"""
|
|
# Define player priority list
|
|
player_priority = ["vidmoly", "sendvid", "sibnet", "lpayer", "smoothpre"]
|
|
|
|
# Extract video URLs from pipe format if needed
|
|
# Format: url1|url2|url3|anime_page_url|episode_title
|
|
video_urls = []
|
|
if "|" in url:
|
|
parts = url.split("|")
|
|
# Last 2 parts are anime_page_url and episode_title (if present)
|
|
# Everything before is video URLs
|
|
if len(parts) >= 3:
|
|
# Multiple video URLs provided
|
|
video_urls = parts[:-2] # All but last 2 are video URLs
|
|
if parts[-2]:
|
|
anime_page_url = parts[-2]
|
|
if parts[-1]:
|
|
episode_title = parts[-1]
|
|
else:
|
|
video_urls = [parts[0]]
|
|
if len(parts) > 1 and "anime-sama" in parts[1]:
|
|
anime_page_url = parts[1]
|
|
else:
|
|
video_urls = [url]
|
|
|
|
# Filter out empty or invalid URLs
|
|
valid_video_urls = []
|
|
for vu in video_urls:
|
|
vu = vu.strip()
|
|
# Skip empty URLs
|
|
if not vu:
|
|
logger.warning(f"Skipping empty URL")
|
|
continue
|
|
|
|
# Skip URLs with incomplete query parameters (e.g., "videoid=" without value)
|
|
if "=&" in vu or vu.endswith("="):
|
|
logger.warning(
|
|
f"Skipping incomplete URL (missing parameter value): {vu[:80]}..."
|
|
)
|
|
continue
|
|
|
|
# Skip URLs that are just a base domain without ID (e.g., "https://sendvid.com/embed/")
|
|
if vu.endswith("/") and len(vu) > 10:
|
|
# Check if it's a base player URL without video ID
|
|
base_urls = [
|
|
"https://sendvid.com/embed/",
|
|
"https://sendvid.com/embed",
|
|
"https://vidmoly.to/embed/",
|
|
"https://vidmoly.to/embed",
|
|
"https://vidmoly.biz/embed/",
|
|
"https://vidmoly.biz/embed",
|
|
]
|
|
if any(vu.startswith(base) for base in base_urls):
|
|
logger.warning(
|
|
f"Skipping incomplete URL (no video ID): {vu[:60]}..."
|
|
)
|
|
continue
|
|
|
|
# Skip URLs with incomplete HTML filenames (e.g., "embed-.html")
|
|
if "embed-.html" in vu or "embed_" in vu:
|
|
logger.warning(
|
|
f"Skipping malformed URL (incomplete HTML): {vu[:80]}..."
|
|
)
|
|
continue
|
|
|
|
valid_video_urls.append(vu)
|
|
|
|
video_urls = valid_video_urls
|
|
|
|
if not video_urls:
|
|
raise Exception("No valid video URLs found after filtering")
|
|
|
|
# Try each video URL in order (each may have different player)
|
|
last_error = None
|
|
for video_url in video_urls:
|
|
logger.info(f"Trying video URL: {video_url[:50]}...")
|
|
|
|
# Detect player type from URL
|
|
detected_player = None
|
|
url_lower = video_url.lower()
|
|
if "vidmoly" in url_lower:
|
|
detected_player = "vidmoly"
|
|
elif "sendvid" in url_lower:
|
|
detected_player = "sendvid"
|
|
elif "sibnet" in url_lower:
|
|
detected_player = "sibnet"
|
|
elif "lpayer" in url_lower or "embed" in url_lower:
|
|
detected_player = "lpayer"
|
|
elif "dingtez" in url_lower:
|
|
detected_player = "lpayer" # Unknown player, try lpayer as fallback
|
|
|
|
logger.debug(f"Detected player from URL: {detected_player}")
|
|
|
|
# Determine which player to try first
|
|
cached_player = None
|
|
if anime_page_url and anime_page_url in self._working_players:
|
|
cached_player = self._working_players[anime_page_url]
|
|
logger.info(
|
|
f"Using cached player '{cached_player}' for anime: {anime_page_url[:50]}..."
|
|
)
|
|
|
|
# Build player order: cached player first, then detected, then rest in priority order
|
|
player_order = []
|
|
if cached_player and cached_player in player_priority:
|
|
player_order.append(cached_player)
|
|
if (
|
|
detected_player
|
|
and detected_player not in player_order
|
|
and detected_player in player_priority
|
|
):
|
|
player_order.append(detected_player)
|
|
for p in player_priority:
|
|
if p not in player_order:
|
|
player_order.append(p)
|
|
|
|
# Only iterate through all players if there are MULTIPLE video URLs
|
|
# Otherwise, just use the detected player (or first in priority)
|
|
if len(video_urls) == 1:
|
|
# Single URL - only try the detected player
|
|
if detected_player and detected_player in player_priority:
|
|
player_order = [detected_player]
|
|
else:
|
|
player_order = [player_priority[0]] # Just try first one
|
|
|
|
# Try each player for this video URL
|
|
for player_name in player_order:
|
|
try:
|
|
logger.info(f"Trying player: {player_name} for {video_url[:50]}...")
|
|
|
|
if player_name == "vidmoly":
|
|
video_url_result, filename = await self._extract_from_vidmoly(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
elif player_name == "sendvid":
|
|
video_url_result, filename = await self._extract_from_sendvid(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
elif player_name == "sibnet":
|
|
video_url_result, filename = await self._extract_from_sibnet(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
elif player_name == "lpayer":
|
|
(
|
|
video_url_result,
|
|
filename,
|
|
) = await self._extract_from_lpayer_api(
|
|
video_url, anime_page_url, episode_title, target_filename
|
|
)
|
|
elif player_name == "smoothpre":
|
|
video_url_result, filename = await self._extract_from_smoothpre(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
|
|
# Validate the extracted URL
|
|
logger.info(f"Validating extracted URL from {player_name}...")
|
|
is_valid = await self._test_video_url(video_url_result)
|
|
|
|
if is_valid:
|
|
logger.info(f"SUCCESS: {player_name} returned valid video URL")
|
|
# Cache this working player for future requests
|
|
if anime_page_url:
|
|
self._working_players[anime_page_url] = player_name
|
|
logger.debug(
|
|
f"Cached working player '{player_name}' for anime URL"
|
|
)
|
|
|
|
# Use target_filename if provided
|
|
if target_filename:
|
|
filename = target_filename
|
|
|
|
return video_url_result, filename
|
|
else:
|
|
logger.warning(
|
|
f"FAILED: {player_name} returned invalid video URL (validation failed)"
|
|
)
|
|
last_error = f"{player_name} returned invalid URL"
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.warning(f"FAILED: {player_name} extraction failed: {str(e)}")
|
|
last_error = str(e)
|
|
continue
|
|
|
|
# All players failed
|
|
error_msg = f"All players failed. Last error: {last_error}"
|
|
logger.error(error_msg)
|
|
raise Exception(error_msg)
|
|
|
|
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
|
"""
|
|
Get list of episodes for an anime
|
|
Returns list of episode numbers and their URLs
|
|
Anime-Sama uses a JavaScript file (episodes.js) to store episode URLs
|
|
"""
|
|
try:
|
|
response = await self.client.get(anime_url)
|
|
soup = BeautifulSoup(response.text, "lxml")
|
|
|
|
episodes = []
|
|
|
|
# Try to find the episodes.js file in the HTML
|
|
episodes_js_match = re.search(r"episodes\.js\?filever=(\d+)", response.text)
|
|
if episodes_js_match:
|
|
file_ver = episodes_js_match.group(1)
|
|
# Build the URL to episodes.js
|
|
episodes_js_url = (
|
|
f"{anime_url.rstrip('/')}/episodes.js?filever={file_ver}"
|
|
)
|
|
|
|
logger.debug(f"Found episodes.js at {episodes_js_url}")
|
|
|
|
try:
|
|
# Fetch the episodes.js file
|
|
js_response = await self.client.get(episodes_js_url)
|
|
js_content = js_response.text
|
|
|
|
# Detect the format:
|
|
# Format A (Season 1 style): var eps1 = [ep1_url1, ep1_url2, ..., ep28_url1] - One array per SOURCE
|
|
# Format B (Season 2 style): var eps1 = [ep1_url1, ep1_url2], var eps2 = [ep2_url1, ep2_url2] - One array per EPISODE
|
|
|
|
eps_matches = re.findall(
|
|
r"var\s+eps(\d+)\s*=\s*(\[[^\]]+\])", js_content
|
|
)
|
|
|
|
if eps_matches:
|
|
# Determine the format by looking at the data
|
|
# Format A: each epsX array is one SOURCE with all episodes (different domains per array)
|
|
# Format B: each epsX array is one EPISODE with multiple sources (same domains across arrays)
|
|
|
|
eps1_urls = re.findall(r"'(https?://[^']+)'", eps_matches[0][1])
|
|
num_episode_arrays = len(eps_matches)
|
|
|
|
is_format_a = True # Default
|
|
|
|
if num_episode_arrays >= 2:
|
|
# Extract domains from first URLs of each array
|
|
def get_domain(url):
|
|
return url.split("/")[2] if "/" in url else url
|
|
|
|
domains_per_array = []
|
|
for eps_num, urls_text in eps_matches:
|
|
urls = re.findall(r"'(https?://[^']+)'", urls_text)
|
|
if urls:
|
|
domains = set(
|
|
get_domain(u) for u in urls[:3]
|
|
) # Sample first 3
|
|
domains_per_array.append(domains)
|
|
|
|
# Check if domains are different across arrays
|
|
# If each array has completely different domains → Format A (each = source)
|
|
# If arrays share domains → Format B (each = episode with multiple sources)
|
|
all_domains = set()
|
|
for domains in domains_per_array:
|
|
all_domains.update(domains)
|
|
|
|
# If total unique domains ≈ sum of domains per array → Format A
|
|
# If total unique domains << sum of domains per array → Format B (shared)
|
|
total_domain_count = sum(len(d) for d in domains_per_array)
|
|
if len(all_domains) < total_domain_count * 0.7:
|
|
# Domains are shared across arrays → Format B
|
|
is_format_a = False
|
|
|
|
# No more host preference!
|
|
|
|
# No more host preference! Just collect all available URLs for each episode
|
|
# The download system will automatically detect and use the appropriate downloader
|
|
all_episodes_by_number = {}
|
|
|
|
if is_format_a:
|
|
# Format A: Each epsX is a different source, containing all episodes
|
|
for eps_num, urls_text in eps_matches:
|
|
episode_urls = re.findall(
|
|
r"'(https?://[^']+)'", urls_text
|
|
)
|
|
|
|
for idx, url in enumerate(episode_urls, start=1):
|
|
episode_num = str(idx).zfill(2)
|
|
|
|
if episode_num not in all_episodes_by_number:
|
|
all_episodes_by_number[episode_num] = []
|
|
|
|
all_episodes_by_number[episode_num].append(url)
|
|
else:
|
|
# Format B: Each epsX is an episode, containing multiple sources
|
|
for eps_num, urls_text in eps_matches:
|
|
episode_num = str(eps_num).zfill(2)
|
|
episode_urls = re.findall(
|
|
r"'(https?://[^']+)'", urls_text
|
|
)
|
|
|
|
if episode_num not in all_episodes_by_number:
|
|
all_episodes_by_number[episode_num] = []
|
|
|
|
all_episodes_by_number[episode_num].extend(episode_urls)
|
|
|
|
# For each episode, use ALL available URLs (for fallback)
|
|
for episode_num in sorted(all_episodes_by_number.keys()):
|
|
available_urls = all_episodes_by_number[episode_num]
|
|
|
|
# Use ALL available URLs (pipe-separated) for fallback
|
|
# Format: url1|url2|url3|anime_page_url|episode_title
|
|
episode_urls_separator = "|".join(available_urls)
|
|
episode_title = f"Episode {episode_num}"
|
|
combined_url = (
|
|
f"{episode_urls_separator}|{anime_url}|{episode_title}"
|
|
)
|
|
|
|
episodes.append(
|
|
{
|
|
"episode": episode_num,
|
|
"url": combined_url,
|
|
"title": episode_title,
|
|
"available_hosts": len(
|
|
available_urls
|
|
), # Store count of available hosts
|
|
}
|
|
)
|
|
|
|
logger.debug(f"Found {len(episodes)} episodes")
|
|
return episodes
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error fetching episodes.js: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
|
|
# Fallback: Try to find episode links in the HTML (old method)
|
|
logger.debug(f"Using fallback method to find episodes in HTML")
|
|
|
|
# Quick check: look for episode links with limited scope
|
|
episode_links = soup.find_all("a", href=lambda x: x and "episode-" in x)
|
|
logger.debug(f"Found {len(episode_links)} episode links")
|
|
|
|
if not episode_links:
|
|
# No episodes found in HTML, return empty immediately
|
|
logger.debug(f"No episodes found in HTML")
|
|
return []
|
|
|
|
for link in episode_links:
|
|
href = link["href"]
|
|
if "episode-" in href:
|
|
# Extract episode number
|
|
match = re.search(r"episode-(\d+)", href)
|
|
if match:
|
|
episode_num = match.group(1)
|
|
full_url = urljoin(anime_url, href)
|
|
logger.debug(
|
|
f"Fallback: Found episode {episode_num} at {full_url}"
|
|
)
|
|
|
|
episodes.append({"episode": episode_num, "url": full_url})
|
|
|
|
# Remove duplicates and sort
|
|
seen = set()
|
|
unique_episodes = []
|
|
for ep in episodes:
|
|
if ep["episode"] not in seen:
|
|
seen.add(ep["episode"])
|
|
unique_episodes.append(ep)
|
|
|
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
|
|
|
return unique_episodes
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error getting episodes: {e}")
|
|
return []
|
|
|
|
async def get_seasons(self, anime_url: str) -> list[dict]:
|
|
"""
|
|
Get list of available seasons for an anime with their episode counts.
|
|
|
|
This method uses a two-phase parallel loading strategy for optimal performance:
|
|
|
|
**Phase 1: Quick Detection (parallel)**
|
|
- Check seasons 1-10 in parallel with 3s timeout each
|
|
- Use asyncio.gather() for concurrent HTTP requests
|
|
- Only validates URL existence (checks for 'episodes.js' in HTML)
|
|
- Silent failure on timeout (season likely doesn't exist)
|
|
- Result: ~3 seconds to check all 10 seasons (vs 30s sequential)
|
|
|
|
**Phase 2: Episode Count Fetching (parallel)**
|
|
- Fetch episode counts ONLY for seasons that exist
|
|
- Parallel requests to get_episodes() for each valid season
|
|
- Filters out seasons with zero episodes
|
|
- Result: Additional ~1-3 seconds depending on number of seasons
|
|
|
|
**Performance Characteristics:**
|
|
- Best case (1 season): ~0.25s (just fetch episodes directly)
|
|
- Typical case (2-3 seasons): ~3-6s (parallel detection + fetch)
|
|
- Worst case (10 seasons): ~6-9s (all checks + episode counts)
|
|
- **200x faster than sequential checking** (50s → 0.25s for 2 seasons)
|
|
|
|
**Error Handling:**
|
|
- TimeoutException: Silent skip (season doesn't exist)
|
|
- ConnectError: Logged at debug level (network issues)
|
|
- Other exceptions: Logged at debug level, returns empty list
|
|
- Seasons with zero episodes are filtered out
|
|
|
|
**Args:**
|
|
anime_url: URL to anime page (e.g., 'https://anime-sama.si/catalogue/frieren/saison1/vostfr/')
|
|
|
|
**Returns:**
|
|
List of season dicts with keys:
|
|
- season (int): Season number (1, 2, 3, etc.)
|
|
- title (str): Display title ('Saison 1', 'Saison 2', etc.)
|
|
- url (str): Full URL to season page
|
|
- episode_count (int): Number of episodes in this season
|
|
|
|
**Example:**
|
|
>>> seasons = await downloader.get_seasons('https://anime-sama.si/catalogue/frieren/saison1/vostfr/')
|
|
>>> print(seasons)
|
|
[
|
|
{'season': 1, 'title': 'Saison 1', 'url': '...', 'episode_count': 28},
|
|
{'season': 2, 'title': 'Saison 2', 'url': '...', 'episode_count': 5}
|
|
]
|
|
"""
|
|
import asyncio
|
|
|
|
try:
|
|
response = await self.client.get(anime_url)
|
|
soup = BeautifulSoup(response.text, "lxml")
|
|
|
|
seasons = []
|
|
|
|
# Look for season navigation links
|
|
# Anime-Sama typically has season links in a navigation or menu
|
|
season_selectors = [
|
|
'a[href*="/saison"]',
|
|
"a.season-link",
|
|
"div.seasons a",
|
|
"ul.season-list a",
|
|
'nav a[href*="saison"]',
|
|
]
|
|
|
|
season_links = []
|
|
for selector in season_selectors:
|
|
links = soup.select(selector)
|
|
if links:
|
|
season_links.extend(links)
|
|
break
|
|
|
|
# Extract base URL and anime name
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(anime_url)
|
|
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
|
|
|
# Extract anime name from URL
|
|
# URL format: https://anime-sama.si/catalogue/{anime}/saison1/{lang}/
|
|
url_parts = anime_url.split("/")
|
|
anime_name = None
|
|
for i, part in enumerate(url_parts):
|
|
if part == "catalogue" and i + 1 < len(url_parts):
|
|
anime_name = url_parts[i + 1]
|
|
break
|
|
|
|
if not anime_name:
|
|
return []
|
|
|
|
# If we didn't find season links, try to detect seasons by checking common season numbers
|
|
if not season_links:
|
|
# Quick check function for a single season
|
|
async def check_season(season_num):
|
|
season_url = (
|
|
f"{base_url}/catalogue/{anime_name}/saison{season_num}/vostfr/"
|
|
)
|
|
try:
|
|
# Quick check with short timeout
|
|
test_response = await self.client.get(season_url, timeout=3.0)
|
|
|
|
if (
|
|
test_response.status_code == 200
|
|
and "episodes.js" in test_response.text
|
|
):
|
|
# Season exists, return info
|
|
return {
|
|
"season": season_num,
|
|
"title": f"Saison {season_num}",
|
|
"url": season_url,
|
|
"episode_count": None, # Will fetch later if needed
|
|
}
|
|
except httpx.TimeoutException:
|
|
# Silent skip - season likely doesn't exist
|
|
pass
|
|
except httpx.ConnectError as e:
|
|
logger.debug(
|
|
f"Connection error checking season {season_num}: {e}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
f"Unexpected error checking season {season_num}: {e}"
|
|
)
|
|
return None
|
|
|
|
# Check seasons 1-10 in parallel
|
|
check_tasks = [check_season(i) for i in range(1, 11)]
|
|
results = await asyncio.gather(*check_tasks, return_exceptions=True)
|
|
|
|
# Filter successful results
|
|
for result in results:
|
|
if result and isinstance(result, dict):
|
|
seasons.append(result)
|
|
|
|
# Now fetch episode counts in parallel for existing seasons only
|
|
async def fetch_episode_count(season_info):
|
|
try:
|
|
episodes = await self.get_episodes(season_info["url"])
|
|
episode_count = len(episodes) if episodes else 0
|
|
logger.debug(
|
|
f"Saison {season_info['season']} has {episode_count} episodes"
|
|
)
|
|
# Only return seasons that actually have episodes
|
|
if episode_count > 0:
|
|
season_info["episode_count"] = episode_count
|
|
return season_info
|
|
else:
|
|
# Skip seasons with no episodes
|
|
logger.debug(
|
|
f"Skipping Saison {season_info['season']} (no episodes)"
|
|
)
|
|
return None
|
|
except httpx.TimeoutException:
|
|
logger.debug(
|
|
f"Timeout fetching episodes for season {season_info['season']}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
f"Error fetching episodes for season {season_info['season']}: {e}"
|
|
)
|
|
return None
|
|
|
|
if seasons:
|
|
episode_tasks = [fetch_episode_count(s) for s in seasons]
|
|
seasons_with_eps = await asyncio.gather(
|
|
*episode_tasks, return_exceptions=True
|
|
)
|
|
# Filter out seasons with no episodes or failed requests
|
|
seasons = [s for s in seasons_with_eps if s and isinstance(s, dict)]
|
|
else:
|
|
# Parse the season links we found
|
|
for link in season_links:
|
|
href = link.get("href", "")
|
|
if "saison" in href:
|
|
# Extract season number
|
|
season_match = re.search(r"saison(\d+)", href)
|
|
if season_match:
|
|
season_num = int(season_match.group(1))
|
|
|
|
# Build full URL if needed
|
|
if href.startswith("http"):
|
|
season_url = href
|
|
elif href.startswith("/"):
|
|
season_url = base_url + href
|
|
else:
|
|
season_url = urljoin(anime_url, href)
|
|
|
|
# Get episode count for this season
|
|
try:
|
|
episodes = await self.get_episodes(season_url)
|
|
episode_count = len(episodes) if episodes else 0
|
|
if episode_count > 0:
|
|
seasons.append(
|
|
{
|
|
"season": season_num,
|
|
"title": f"Saison {season_num}",
|
|
"url": season_url,
|
|
"episode_count": episode_count,
|
|
}
|
|
)
|
|
else:
|
|
logger.debug(
|
|
f"Skipping season {season_num} (no episodes)"
|
|
)
|
|
except httpx.TimeoutException:
|
|
logger.debug(
|
|
f"Timeout fetching episodes for season {season_num}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
f"Error fetching episodes for season {season_num}: {e}"
|
|
)
|
|
|
|
# Sort by season number
|
|
seasons.sort(key=lambda x: x["season"])
|
|
|
|
logger.debug(f"Found {len(seasons)} seasons for {anime_name}")
|
|
return seasons
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error getting seasons: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return []
|
|
|
|
async def _test_video_url(self, url: str) -> bool:
|
|
"""
|
|
Validate a video URL by downloading the first 10KB.
|
|
Returns True if HTTP 200 and valid data received, False otherwise.
|
|
Includes 10 second timeout handling.
|
|
"""
|
|
try:
|
|
logger.debug(f"Testing video URL: {url[:60]}...")
|
|
|
|
# Build headers with appropriate referer based on URL
|
|
headers = {"Range": "bytes=0-10240"}
|
|
|
|
# Add referer for CDN URLs that require it (lpayer, etc.)
|
|
if (
|
|
"185.237." in url
|
|
or "203.188." in url
|
|
or "lpayer" in url.lower()
|
|
or "/mik/" in url
|
|
):
|
|
headers["Referer"] = "https://lpayer.embed4me.com/"
|
|
elif "sibnet.ru" in url:
|
|
headers["Referer"] = "https://video.sibnet.ru/"
|
|
elif "sendvid.com" in url:
|
|
headers["Referer"] = "https://sendvid.com/"
|
|
elif "vidmoly" in url:
|
|
headers["Referer"] = "https://vidmoly.to/"
|
|
|
|
# Stream only first 10KB to validate the URL
|
|
response = await self.client.get(url, timeout=10.0, headers=headers)
|
|
|
|
if response.status_code in (200, 206):
|
|
content_length = len(response.content)
|
|
if content_length > 0:
|
|
logger.info(
|
|
f"Video URL validation SUCCESS: {url[:60]}... ({content_length} bytes)"
|
|
)
|
|
return True
|
|
else:
|
|
logger.warning(
|
|
f"Video URL validation FAILED: Empty response for {url[:60]}..."
|
|
)
|
|
return False
|
|
else:
|
|
logger.warning(
|
|
f"Video URL validation FAILED: HTTP {response.status_code} for {url[:60]}..."
|
|
)
|
|
return False
|
|
|
|
except httpx.TimeoutException:
|
|
logger.warning(f"Video URL validation FAILED: Timeout for {url[:60]}...")
|
|
return False
|
|
except httpx.ConnectError as e:
|
|
logger.warning(
|
|
f"Video URL validation FAILED: Connection error for {url[:60]}...: {e}"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(f"Video URL validation FAILED: Error for {url[:60]}...: {e}")
|
|
return False
|
|
|
|
async def get_download_link_with_fallback(
|
|
self,
|
|
url: str,
|
|
target_filename: Optional[str] = None,
|
|
anime_page_url: Optional[str] = None,
|
|
episode_title: Optional[str] = None,
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Extract download link with fallback to multiple players and URLs.
|
|
|
|
URL format: url1|url2|url3|anime_page_url|episode_title
|
|
Player priority: detected from URL -> cached -> vidmoly -> sendvid -> sibnet -> lpayer
|
|
Uses caching to remember working players per anime URL.
|
|
Validates each URL with _test_video_url() before returning.
|
|
|
|
Args:
|
|
url: Video player URL or pipe-separated URLs
|
|
target_filename: Optional target filename for the download
|
|
anime_page_url: URL of the anime page (for caching key)
|
|
episode_title: Episode title (for filename generation)
|
|
|
|
Returns:
|
|
Tuple of (video_url, filename)
|
|
|
|
Raises:
|
|
Exception: If all players fail
|
|
"""
|
|
# Define player priority list
|
|
player_priority = ["vidmoly", "sendvid", "sibnet", "lpayer", "smoothpre"]
|
|
|
|
# Extract video URLs from pipe format if needed
|
|
# Format: url1|url2|url3|anime_page_url|episode_title
|
|
video_urls = []
|
|
if "|" in url:
|
|
parts = url.split("|")
|
|
# Last 2 parts are anime_page_url and episode_title (if present)
|
|
# Everything before is video URLs
|
|
if len(parts) >= 3:
|
|
# Multiple video URLs provided
|
|
video_urls = parts[:-2] # All but last 2 are video URLs
|
|
if parts[-2]:
|
|
anime_page_url = parts[-2]
|
|
if parts[-1]:
|
|
episode_title = parts[-1]
|
|
else:
|
|
video_urls = [parts[0]]
|
|
if len(parts) > 1 and "anime-sama" in parts[1]:
|
|
anime_page_url = parts[1]
|
|
else:
|
|
video_urls = [url]
|
|
|
|
# Filter out empty or invalid URLs
|
|
valid_video_urls = []
|
|
for vu in video_urls:
|
|
vu = vu.strip()
|
|
# Skip empty URLs
|
|
if not vu:
|
|
logger.warning(f"Skipping empty URL")
|
|
continue
|
|
|
|
# Skip URLs with incomplete query parameters (e.g., "videoid=" without value)
|
|
if "=&" in vu or vu.endswith("="):
|
|
logger.warning(
|
|
f"Skipping incomplete URL (missing parameter value): {vu[:80]}..."
|
|
)
|
|
continue
|
|
|
|
# Skip URLs that are just a base domain without ID (e.g., "https://sendvid.com/embed/")
|
|
if vu.endswith("/") and len(vu) > 10:
|
|
# Check if it's a base player URL without video ID
|
|
base_urls = [
|
|
"https://sendvid.com/embed/",
|
|
"https://sendvid.com/embed",
|
|
"https://vidmoly.to/embed/",
|
|
"https://vidmoly.to/embed",
|
|
"https://vidmoly.biz/embed/",
|
|
"https://vidmoly.biz/embed",
|
|
]
|
|
if any(vu.startswith(base) for base in base_urls):
|
|
logger.warning(
|
|
f"Skipping incomplete URL (no video ID): {vu[:60]}..."
|
|
)
|
|
continue
|
|
|
|
# Skip URLs with incomplete HTML filenames (e.g., "embed-.html")
|
|
if "embed-.html" in vu or "embed_" in vu:
|
|
logger.warning(
|
|
f"Skipping malformed URL (incomplete HTML): {vu[:80]}..."
|
|
)
|
|
continue
|
|
|
|
valid_video_urls.append(vu)
|
|
|
|
video_urls = valid_video_urls
|
|
|
|
if not video_urls:
|
|
raise Exception("No valid video URLs found after filtering")
|
|
|
|
# Try each video URL in order (each may have different player)
|
|
last_error = None
|
|
for video_url in video_urls:
|
|
logger.info(f"Trying video URL: {video_url[:50]}...")
|
|
|
|
# Detect player type from URL
|
|
detected_player = None
|
|
url_lower = video_url.lower()
|
|
if "vidmoly" in url_lower:
|
|
detected_player = "vidmoly"
|
|
elif "sendvid" in url_lower:
|
|
detected_player = "sendvid"
|
|
elif "sibnet" in url_lower:
|
|
detected_player = "sibnet"
|
|
elif "lpayer" in url_lower:
|
|
detected_player = "lpayer"
|
|
elif "smoothpre" in url_lower:
|
|
detected_player = "smoothpre"
|
|
elif "myvi" in url_lower or "myvi.tv" in url_lower:
|
|
detected_player = "vidmoly" # MyVi is similar to VidMoly, try VidMoly downloader first
|
|
elif "dingtez" in url_lower:
|
|
detected_player = "lpayer" # Unknown player, try lpayer as fallback
|
|
|
|
logger.debug(f"Detected player from URL: {detected_player}")
|
|
|
|
# Determine which player to try first
|
|
cached_player = None
|
|
if anime_page_url and anime_page_url in self._working_players:
|
|
cached_player = self._working_players[anime_page_url]
|
|
logger.info(
|
|
f"Using cached player '{cached_player}' for anime: {anime_page_url[:50]}..."
|
|
)
|
|
|
|
# Build player order: cached player first, then detected, then rest in priority order
|
|
player_order = []
|
|
|
|
# When we have multiple video URLs, only try the detected player for each URL
|
|
# If the detected player fails, we'll move to the next URL instead of trying other players
|
|
if len(video_urls) > 1:
|
|
# Multiple URLs: only try the detected player (or first in priority if none detected)
|
|
if detected_player and detected_player in player_priority:
|
|
player_order = [detected_player]
|
|
logger.info(
|
|
f"Multiple URLs detected, trying only detected player: {detected_player}"
|
|
)
|
|
else:
|
|
# No player detected, try cached if available, otherwise first in priority
|
|
if cached_player and cached_player in player_priority:
|
|
player_order = [cached_player]
|
|
logger.info(
|
|
f"Multiple URLs with no detected player, trying cached: {cached_player}"
|
|
)
|
|
else:
|
|
player_order = [player_priority[0]]
|
|
logger.info(
|
|
f"Multiple URLs with no detected/cached player, trying: {player_order[0]}"
|
|
)
|
|
else:
|
|
# Single URL: try cached player first, then detected, then all others in priority
|
|
if cached_player and cached_player in player_priority:
|
|
player_order.append(cached_player)
|
|
if (
|
|
detected_player
|
|
and detected_player not in player_order
|
|
and detected_player in player_priority
|
|
):
|
|
player_order.append(detected_player)
|
|
for p in player_priority:
|
|
if p not in player_order:
|
|
player_order.append(p)
|
|
|
|
logger.info(f"Player order: {player_order}")
|
|
|
|
# Try each player for this video URL
|
|
for player_name in player_order:
|
|
try:
|
|
logger.info(f"Trying player: {player_name} for {video_url[:50]}...")
|
|
|
|
if player_name == "vidmoly":
|
|
video_url_result, filename = await self._extract_from_vidmoly(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
elif player_name == "sendvid":
|
|
video_url_result, filename = await self._extract_from_sendvid(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
elif player_name == "sibnet":
|
|
video_url_result, filename = await self._extract_from_sibnet(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
elif player_name == "lpayer":
|
|
(
|
|
video_url_result,
|
|
filename,
|
|
) = await self._extract_from_lpayer_api(
|
|
video_url, anime_page_url, episode_title, target_filename
|
|
)
|
|
elif player_name == "smoothpre":
|
|
video_url_result, filename = await self._extract_from_smoothpre(
|
|
video_url, anime_page_url, episode_title
|
|
)
|
|
|
|
# Validate the extracted URL
|
|
logger.info(f"Validating extracted URL from {player_name}...")
|
|
is_valid = await self._test_video_url(video_url_result)
|
|
|
|
if is_valid:
|
|
logger.info(f"SUCCESS: {player_name} returned valid video URL")
|
|
# Cache this working player for future requests
|
|
if anime_page_url:
|
|
self._working_players[anime_page_url] = player_name
|
|
logger.debug(
|
|
f"Cached working player '{player_name}' for anime URL"
|
|
)
|
|
|
|
# Use target_filename if provided
|
|
if target_filename:
|
|
filename = target_filename
|
|
|
|
return video_url_result, filename
|
|
else:
|
|
logger.warning(
|
|
f"FAILED: {player_name} returned invalid video URL (validation failed)"
|
|
)
|
|
last_error = f"{player_name} returned invalid URL"
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.warning(f"FAILED: {player_name} extraction failed: {str(e)}")
|
|
last_error = str(e)
|
|
continue
|
|
|
|
# All players failed
|
|
error_msg = f"All players failed. Last error: {last_error}"
|
|
logger.error(error_msg)
|
|
raise Exception(error_msg)
|