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 os
import uuid import uuid
import logging import logging
import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
@@ -124,13 +125,18 @@ class DownloadManager:
downloader = get_downloader(task.url) downloader = get_downloader(task.url)
# Extract episode title from pipe-separated URL if present # 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 target_filename = None
if '|' in task.url: if '|' in task.url:
parts = task.url.split('|') parts = task.url.split('|')
if len(parts) >= 3: # Last part is episode title, second to last is anime page URL
target_filename = parts[2].strip() if len(parts) >= 2:
logger.debug(f"Extracted target filename from pipe: {target_filename}") # 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) 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) 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) # Check if download_url is a local file path (VidMoly M3U8 pre-download)
if os.path.exists(download_url): if os.path.exists(download_url):
logger.info(f"VidMoly already downloaded file to: {download_url}") logger.info(f"VidMoly already downloaded file to: {download_url}")
@@ -279,3 +294,112 @@ class DownloadManager:
# Log completion info # Log completion info
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0 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)") 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 # 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)}") 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""" """Extract video URL from Lplayer using API decryption"""
import requests import requests
@@ -539,13 +539,18 @@ class AnimeSamaDownloader(BaseAnimeSite):
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) 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: if result.returncode == 0 and result.stdout:
yt_data = json.loads(result.stdout) yt_data = json.loads(result.stdout)
if 'formats' in yt_data: if 'formats' in yt_data:
# Get best mp4 format # Get best mp4 format (highest resolution)
formats = yt_data['formats'] formats = yt_data['formats']
mp4_formats = [f for f in formats if f.get('ext') == 'mp4'] mp4_formats = [f for f in formats if f.get('ext') == 'mp4']
if mp4_formats: 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') video_url = mp4_formats[0].get('url')
else: else:
video_url = formats[0].get('url') video_url = formats[0].get('url')
@@ -553,11 +558,9 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url = yt_data.get('url') video_url = yt_data.get('url')
if video_url: if video_url:
filename = f"lpayer_{video_id}.mp4"
return video_url, filename return video_url, filename
# If yt-dlp fails, return m3u8 URL anyway (let download manager handle it) # If yt-dlp fails, return m3u8 URL anyway (let download manager handle it)
filename = f"lpayer_{video_id}.mp4"
return m3u8_url, filename return m3u8_url, filename
async def _extract_from_player(self, player_url: str) -> str | None: async def _extract_from_player(self, player_url: str) -> str | None:
@@ -876,11 +879,24 @@ class AnimeSamaDownloader(BaseAnimeSite):
try: try:
logger.debug(f"Testing video URL: {url[:60]}...") 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 # Stream only first 10KB to validate the URL
response = await self.client.get( response = await self.client.get(
url, url,
timeout=10.0, timeout=10.0,
headers={"Range": "bytes=0-10240"} headers=headers
) )
if response.status_code in (200, 206): if response.status_code in (200, 206):
@@ -1087,7 +1103,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url, anime_page_url, episode_title video_url, anime_page_url, episode_title
) )
elif player_name == 'lpayer': 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 # Validate the extracted URL
logger.info(f"Validating extracted URL from {player_name}...") logger.info(f"Validating extracted URL from {player_name}...")
@@ -1494,11 +1510,24 @@ class AnimeSamaDownloader(BaseAnimeSite):
try: try:
logger.debug(f"Testing video URL: {url[:60]}...") 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 # Stream only first 10KB to validate the URL
response = await self.client.get( response = await self.client.get(
url, url,
timeout=10.0, timeout=10.0,
headers={"Range": "bytes=0-10240"} headers=headers
) )
if response.status_code in (200, 206): if response.status_code in (200, 206):
@@ -1651,7 +1680,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url, anime_page_url, episode_title video_url, anime_page_url, episode_title
) )
elif player_name == 'lpayer': 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 # Validate the extracted URL
logger.info(f"Validating extracted URL from {player_name}...") logger.info(f"Validating extracted URL from {player_name}...")
+57 -8
View File
@@ -2094,9 +2094,12 @@ async def check_watchlist_item(
@app.post("/api/watchlist/{item_id}/download-all", tags=["Watchlist"]) @app.post("/api/watchlist/{item_id}/download-all", tags=["Watchlist"])
async def download_all_episodes( async def download_all_episodes(
item_id: str, item_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user_from_token) current_user: User = Depends(get_current_user_from_token)
): ):
"""Download ALL episodes for a watchlist item (used when first following an anime)""" """Download the LATEST SEASON episodes for a watchlist item (used when first following an anime)"""
from app.downloaders import get_downloader
try: try:
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item: if not item:
@@ -2105,18 +2108,64 @@ async def download_all_episodes(
if item.user_id != current_user.id: if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
# Temporarily set last_episode_downloaded to 0 to trigger download of ALL episodes downloader = get_downloader(item.anime_url)
watchlist_manager.update(item_id, {"last_episode_downloaded": 0}) latest_season_url = item.anime_url # Default to current URL
result = await episode_checker.manual_check(item_id) # Try to get the latest season if provider supports it
if hasattr(downloader, 'get_seasons'):
try:
seasons = await downloader.get_seasons(item.anime_url)
if seasons and len(seasons) > 0:
# Get the last season (most recent)
latest_season = seasons[-1]
latest_season_url = latest_season.get('url', item.anime_url)
logger.info(f"Found {len(seasons)} seasons, using latest: {latest_season.get('title', 'unknown')}")
except Exception as e:
logger.warning(f"Could not fetch seasons, using default URL: {e}")
# Note: download_new_episodes already updates last_episode_downloaded via update_check_time # Get episodes from the latest season
# So we don't restore the original value - the new value reflects what was actually downloaded episodes = await downloader.get_episodes(latest_season_url, item.lang)
if not episodes:
return {
"status": "warning",
"message": f"No episodes found for {item.anime_title}",
"result": {"new_episodes_found": 0, "episodes_downloaded": []}
}
# Create download tasks for all episodes of the latest season
task_ids = []
# Extract season number from URL for filename
import re
season_match = re.search(r'saison(\d+)', latest_season_url, re.IGNORECASE)
season_num = season_match.group(1) if season_match else "1"
# Clean anime title for filename
anime_title_clean = item.anime_title.replace('/', '-').replace('\\', '-').strip()
for episode in episodes:
# Build a nice filename: "Anime Title - S1 - Episode 01.mp4"
ep_num = episode.get('episode', '01')
filename = f"{anime_title_clean} - S{season_num} - Episode {ep_num}.mp4"
request = DownloadRequest(url=episode['url'], filename=filename)
task = download_manager.create_task(request)
task_ids.append(task.id)
background_tasks.add_task(download_manager.start_download, task.id)
# Update watchlist with total episodes count
watchlist_manager.update(item_id, {
"last_episode_downloaded": len(episodes),
"total_episodes": len(episodes)
})
return { return {
"status": "success", "status": "success",
"message": f"Downloading all episodes for {item.anime_title}", "message": f"Downloading {len(task_ids)} episodes from latest season for {item.anime_title}",
"result": result "task_ids": task_ids,
"total_episodes": len(episodes),
"season_url": latest_season_url
} }
except HTTPException: except HTTPException:
raise raise
+2 -1
View File
@@ -247,7 +247,8 @@ async function handleAddToWatchlist(animeUrl, providerId) {
if (downloadResponse.ok) { if (downloadResponse.ok) {
const downloadResult = await downloadResponse.json(); const downloadResult = await downloadResponse.json();
alert(`✅ "${result.anime_title}" a été ajouté et le téléchargement de tous les épisodes a commencé!\n\nVous recevrez automatiquement les nouveaux épisodes.`); const episodeCount = downloadResult.total_episodes || 'tous les';
alert(`✅ "${result.anime_title}" a été ajouté!\n\n📥 Téléchargement de la dernière saison lancé (${episodeCount} épisodes).\n\nVous recevrez automatiquement les nouveaux épisodes.`);
} else { } else {
// Still show success even if download failed // Still show success even if download failed
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!\n\nLe téléchargement automatique des nouveaux épisodes est activé.`); alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!\n\nLe téléchargement automatique des nouveaux épisodes est activé.`);