diff --git a/app/download_manager.py b/app/download_manager.py index 51422a8..8ff3a4a 100644 --- a/app/download_manager.py +++ b/app/download_manager.py @@ -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 diff --git a/app/downloaders/anime_sites/animesama.py b/app/downloaders/anime_sites/animesama.py index a3d82a8..ea9c595 100644 --- a/app/downloaders/anime_sites/animesama.py +++ b/app/downloaders/anime_sites/animesama.py @@ -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}...") diff --git a/main.py b/main.py index 2e6fbf9..dcb28f0 100644 --- a/main.py +++ b/main.py @@ -2094,9 +2094,12 @@ async def check_watchlist_item( @app.post("/api/watchlist/{item_id}/download-all", tags=["Watchlist"]) async def download_all_episodes( item_id: str, + background_tasks: BackgroundTasks, 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: item = watchlist_manager.get_by_id(item_id) if not item: @@ -2105,18 +2108,64 @@ async def download_all_episodes( if item.user_id != current_user.id: raise HTTPException(status_code=403, detail="Access denied") - # Temporarily set last_episode_downloaded to 0 to trigger download of ALL episodes - watchlist_manager.update(item_id, {"last_episode_downloaded": 0}) + downloader = get_downloader(item.anime_url) + 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 - # So we don't restore the original value - the new value reflects what was actually downloaded + # Get episodes from the latest season + 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 { "status": "success", - "message": f"Downloading all episodes for {item.anime_title}", - "result": result + "message": f"Downloading {len(task_ids)} episodes from latest season for {item.anime_title}", + "task_ids": task_ids, + "total_episodes": len(episodes), + "season_url": latest_season_url } except HTTPException: raise diff --git a/static/js/watchlist-ui.js b/static/js/watchlist-ui.js index ea7eede..14c60bc 100644 --- a/static/js/watchlist-ui.js +++ b/static/js/watchlist-ui.js @@ -247,7 +247,8 @@ async function handleAddToWatchlist(animeUrl, providerId) { if (downloadResponse.ok) { 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 { // 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é.`);