feat: download latest season only + fix lpayer CDN + HLS support

- Watchlist 'Suivre' now downloads only the latest season instead of all episodes
- Fix lpayer CDN 403 errors by adding proper Referer header for IP ranges
- Add HLS/m3u8 stream download support using ffmpeg
- Improve episode filename format: 'Anime - SX - Episode XX.mp4'
- Add CDN detection for lpayer IPs (185.237.x.x, 203.188.x.x, /mik/ path)
This commit is contained in:
root
2026-03-01 09:29:16 +00:00
parent 42daab1e50
commit d179694fb2
4 changed files with 224 additions and 21 deletions
+128 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import os
import uuid
import logging
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
@@ -124,13 +125,18 @@ class DownloadManager:
downloader = get_downloader(task.url)
# Extract episode title from pipe-separated URL if present
# Format: video_url|anime_page_url|episode_title
# Format: video_url1|video_url2|...|anime_page_url|episode_title
target_filename = None
if '|' in task.url:
parts = task.url.split('|')
if len(parts) >= 3:
target_filename = parts[2].strip()
logger.debug(f"Extracted target filename from pipe: {target_filename}")
# Last part is episode title, second to last is anime page URL
if len(parts) >= 2:
# Get the last part as episode title
potential_title = parts[-1].strip()
# Only use it if it looks like a title (not a URL)
if potential_title and not potential_title.startswith('http'):
target_filename = potential_title
logger.debug(f"Extracted target filename from pipe: {target_filename}")
download_url, filename = await downloader.get_download_link(task.url, target_filename)
@@ -146,6 +152,15 @@ class DownloadManager:
task.file_path = str(self.download_dir / task.filename)
# Check if URL is HLS/m3u8 - use ffmpeg to download
if download_url.endswith('.m3u8') or '.m3u8?' in download_url:
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
success = await self._download_hls(download_url, task)
if success:
return
# If ffmpeg fails, fall through to regular download attempt
logger.warning("ffmpeg download failed, trying regular download")
# Check if download_url is a local file path (VidMoly M3U8 pre-download)
if os.path.exists(download_url):
logger.info(f"VidMoly already downloaded file to: {download_url}")
@@ -279,3 +294,112 @@ class DownloadManager:
# Log completion info
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
"""Download HLS/m3u8 stream using ffmpeg"""
import subprocess
import re
try:
# Build ffmpeg command for HLS download
cmd = [
'ffmpeg',
'-y', # Overwrite output file
'-headers', 'Referer: https://lpayer.embed4me.com/',
'-i', m3u8_url,
'-c', 'copy', # Stream copy (no re-encoding)
'-bsf:a', 'aac_adtstoasc', # Fix AAC streams
'-progress', 'pipe:1', # Output progress to stdout
task.file_path
]
logger.info(f"Starting ffmpeg HLS download: {task.filename}")
# Run ffmpeg as subprocess
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
start_time = asyncio.get_event_loop().time()
# Read progress from ffmpeg
while True:
if task.status == DownloadStatus.CANCELLED:
process.terminate()
return False
if task.status == DownloadStatus.PAUSED:
process.terminate()
return False
try:
line = await asyncio.wait_for(process.stdout.readline(), timeout=1.0)
if not line:
break
line = line.decode('utf-8', errors='ignore').strip()
# Parse ffmpeg progress output
if line.startswith('out_time_ms='):
try:
out_time_us = int(line.split('=')[1])
out_time_sec = out_time_us / 1_000_000
# Update progress based on duration (if known)
# ffmpeg doesn't always report total duration
task.downloaded_bytes = int(out_time_sec * 1000000) # Approximate
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > 0:
task.speed = task.downloaded_bytes / elapsed
except (ValueError, IndexError):
pass
elif line.startswith('total_size='):
try:
size = int(line.split('=')[1])
if size > 0:
task.total_bytes = size
if task.downloaded_bytes > 0:
task.progress = (task.downloaded_bytes / size) * 100
except (ValueError, IndexError):
pass
except asyncio.TimeoutError:
# Check if process is still running
if process.returncode is not None:
break
continue
# Wait for process to complete
await process.wait()
if process.returncode == 0:
# Check if file was created
if os.path.exists(task.file_path):
file_size = os.path.getsize(task.file_path)
logger.info(f"✅ HLS download complete: {task.filename} ({file_size / (1024*1024):.2f} MB)")
task.status = DownloadStatus.COMPLETED
task.progress = 100.0
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
return True
else:
logger.error(f"HLS download failed: file not created")
return False
else:
# Get stderr for error message
stderr = await process.stderr.read()
error_msg = stderr.decode('utf-8', errors='ignore')
logger.error(f"ffmpeg failed with code {process.returncode}: {error_msg[:500]}")
return False
except FileNotFoundError:
logger.error("ffmpeg not found - cannot download HLS streams")
return False
except Exception as e:
logger.error(f"HLS download error: {e}")
return False
+37 -8
View File
@@ -489,7 +489,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
# 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) -> tuple[str, str]:
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
@@ -539,13 +539,18 @@ class AnimeSamaDownloader(BaseAnimeSite):
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
# 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')
@@ -553,11 +558,9 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url = yt_data.get('url')
if video_url:
filename = f"lpayer_{video_id}.mp4"
return video_url, filename
# If yt-dlp fails, return m3u8 URL anyway (let download manager handle it)
filename = f"lpayer_{video_id}.mp4"
return m3u8_url, filename
async def _extract_from_player(self, player_url: str) -> str | None:
@@ -876,11 +879,24 @@ class AnimeSamaDownloader(BaseAnimeSite):
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={"Range": "bytes=0-10240"}
headers=headers
)
if response.status_code in (200, 206):
@@ -1087,7 +1103,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url, anime_page_url, episode_title
)
elif player_name == 'lpayer':
video_url_result, filename = await self._extract_from_lpayer_api(video_url)
video_url_result, filename = await self._extract_from_lpayer_api(video_url, anime_page_url, episode_title, target_filename)
# Validate the extracted URL
logger.info(f"Validating extracted URL from {player_name}...")
@@ -1494,11 +1510,24 @@ class AnimeSamaDownloader(BaseAnimeSite):
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={"Range": "bytes=0-10240"}
headers=headers
)
if response.status_code in (200, 206):
@@ -1651,7 +1680,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url, anime_page_url, episode_title
)
elif player_name == 'lpayer':
video_url_result, filename = await self._extract_from_lpayer_api(video_url)
video_url_result, filename = await self._extract_from_lpayer_api(video_url, anime_page_url, episode_title, target_filename)
# Validate the extracted URL
logger.info(f"Validating extracted URL from {player_name}...")