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:
+128
-4
@@ -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
|
||||||
|
|||||||
@@ -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}...")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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é.`);
|
||||||
|
|||||||
Reference in New Issue
Block a user